前言
如今前端框架react,vue,angular都拥有自己的模板引擎,但是我们通过学习原生js的模板引擎,尤其是底层对各种字符串和表达式的处理,可以有助于更好的去理解其他框架是如何渲染模板数据的.
本文借鉴underscore源码,使用70行左右的代码实现一款简易版的模板引擎.包含如下核心功能,比如数据渲染,表达式渲染(兼容if语句和for循环)以及html字符串渲染.
用户端调用方式如下,编写compile函数,期待输出相应结果.
1.渲染数据
<?= ?>
代表输出变量的值
const data = {
country: 'China',
};
const template = compile(` //compile生成模板函数
<div>
<?= country?>
</div>
`);
console.log(template(data)); //template传入参数data,生成模板字符串
输出结果:
<div>China</div>
2.条件判断
<? ?>
可在其中直接书写js语句,比如 if 条件判断、for循环
const data = {
country: 'China',
gender: 'male',
};
const template = compile(`
<div>
<? if(gender === 'male'){?>
<?= country?>
<?}?>
</div>
`);
console.log(template(data));
输出结果:
<div>China</div>
3.循环语句
const data = {
country: 'China',
gender: 'male',
array: [1, 2, 3],
};
const template = compile(`
<div>
<? for(var i = 0; i< array.length ; i++) {?>
<span><?= gender + i ?></span>
<?}?>
</div>
`);
console.log(template(data));
输出结果:
<div><span>male0</span><span>male1</span><span>male2</span></div>
值渲染
初始先实现一个最简单的需求,渲染两个值如下:
const data = {
country: 'China',
gender: 'male',
};
const template = compile('<div><?= country?><span><?= gender?></span></div>');
console.log(template(data));
期待的结果:
<div>China<span>male</span></div>
从上面的执行代码可知,compile传入模板字符串后返回一个新函数,当向这个函数传递data执行后就能得到最终的结果.
with语句
在讨论模板引擎的实现之前,先学习一个知识点with语法.使用with能避免冗余的对象调用,看以下案例.
function test(data){
with(data){
age = 100;
}
console.log(data);
}
test({age:1})
结果:
{age: 100}
with可以限定上下文对象的作用范围.在with包裹的范围内,没有定义过的变量就代表着data上的属性,可以直接操作.比如上面with传入data,那么在with内部就可以直接操作age属性,而不用再加data前缀.
with的特性有助于模板的编译.在with作用下,模板字符串可以直接写成属性调用,而不用加对象的前缀.
逻辑分析
compile传入模板字符串后会返回一个新函数,再调用data就能返回编译后的最终结果.利用with的特性,compile写成如下形式就能达到目的.
function compile(string){
return function(data){
var _p = '';
with(data){
_p += '<div>'+country+'<span>'+gender+'</span>'+'</div>';
}
return _p;
}
}
string 现在为 '<div><?= country?><span><?= gender?></span></div>'
如果将 string 变换成 '<div>'+country+'<span>'+gender+'</span>'+'</div>'
就能实现模板编译.但现在碰到的问题是对string无论做任何处理也只能返回一个总的字符串,根本无法做到类似上面<div>
添加单引号,而 country
不加单引号.
因此compile里面不能像上面一样直接返回一个函数.为了能让with内部的标签加引号而属性不加引号,可以使用传参的方式创建函数.
将compile函数改造如下:
function compile(string){
var template_str = `
var _p = '';
with(data){
_p += '<div>'+country+'</div>';
}
return _p;
`;
var fn = new Function('data',template_str );
return fn;
}
现在只需要将 string
转化成 template_str
的样子就大功告成了.
function compile(string) {
var template_str = `
var _p = '';
with(data){
_p += '<div>'+country+'</div>';
}
return _p;
`;
function render() {
var str = '';
str += "var _p = ''";
str += 'with(data){';
str += '_p +=';
str += templateParse();
str += ';}return _p;';
}
function templateParse() {
var reg = /<\?=([\s\S]+?)\?>/g;
string.replace(reg, function (matches, $1, offset) {
console.log($1,offset);
});
}
var template_str = render();
var fn = new Function('data', template_str);
return fn;
}
输出结果:
country 5
gender 24
function compile(string) {
function render() {
var str = '';
str += "var _p = '';";
str += 'with(data){';
str += '_p +=';
str = templateParse(str);
str += ';}return _p;';
console.log(str);
return str;
}
function templateParse(str) {
var reg = /<\?=([\s\S]+?)\?>/g;
var index = 0;
string.replace(reg, function (matches, $1, offset) {
str += "'" + string.slice(index, offset) + "'";
str += '+';
str += $1;
str += '+';
index = offset + matches.length;
});
str += "'" + string.slice(index) + "'";
return str;
}
var template_str = render();
var fn = new Function('data', template_str);
return fn;
}
var _p = '';with(data){_p +='<div>'+ country+'<span>'+ gender+'</span></div>';}return _p;
<div>China<span>male</span></div>
表达式处理
const data = {
country: 'China',
gender: 'male',
};
const template = compile(
'<div> <? if(country === "China"){ ?> <span><?= gender?></span> <?}?> </div>'
);
console.log(template(data));
<div><span>male</span></div>
逻辑分析
最开始的想法把模板字符串想办法转化成下面形式就可以了,但实践中发现不管是 if 还是 for 表达式都不能直接和字符串相加,结果会报错
function render(data){
var _p += '';
with(data){
_p += '<div>' + if(country === "China"){ return '<span>'+gender+'</span>'; } + '</div>';
}
return _p;
}
既然表达式不能与字符串直接相加,那么只能将表达式的逻辑和字符串隔离开.改造如下,在每个表达式前面加一个分号,将前面的字符串相加的代码结束.随后直接渲染表达式的内容,但是表达式内部包裹的内容要使用_p加起来.
function render(data){
var _p += '';
with(data){
_p += '<div>';
if(country === "China")
{
_p+='<span>'+gender+'</span>';
}
_p += '</div>';
}
return _p;
}
表达式和值的渲染不同,它不仅有if语法,它还有if else, if else if,以及 for 循环语句
但不管是哪一种表达式,我们都可以从上面需要的的渲染结构中总结一些规律.1.表达式前面要加一个分号将前面代码逻辑隔离开 2.表达式本身不用加引号直接选渲染 3.表达式后面的内容需要用_p加起来并赋值给_p
function compile(string) {
function render() {
var str = '';
str += "var _p = '';";
str += 'with(data){';
str += '_p +=';
str = templateParse(str);
str += ';}return _p;';
console.log(str);
return str;
}
function templateParse(str) {
var reg = /<\?=([\s\S]+?)\?>|<\?([\s\S]+?)\?>/g;
var index = 0;
string.replace(reg, function (matches, $1, $2, offset) {
str += "'" + string.slice(index, offset) + "'";
if ($1) {
//渲染值
str += '+';
str += $1;
str += '+';
} else if ($2) {
//渲染表达式
str += ';'; //第一步加个分号将前面的逻辑终止
str += $2; //第二步直接拼接表达式
str += '_p+='; //第三步要将表达式包裹的内容与_p相加并赋值给_p
}
index = offset + matches.length;
});
str += "'" + string.slice(index) + "'";
return str;
}
var template_str = render();
var fn = new Function('data', template_str);
return fn;
}
render最后编译出的函数体
var _p = '';with(data){_p +='<div> '; if(country === "China"){ _p+=' <span>'+ gender+'</span> ';}_p+=' </div>';}return _p;
最终结果:
<div> <span>male</span> </div>
渲染HTML代码
const data = {
code: '<div style="color:red">name:张三</div>',
};
const template = compile('<div><?- country?></div>');
console.log(template(data));
期待结果:
<div><div style="color:red">name:张三</div></div>
<?- ?>
用来标记输出html字符串
渲染html代码非常简单,只需要将templateParse函数内的正则新增一条,在条件判断里面将html字符串拼接上去即可
改动如下
function templateParse(str) {
var reg = /<\?=([\s\S]+?)\?>|<\?-([\s\S]+?)\?>|<\?([\s\S]+?)\?>/g;
var index = 0;
string.replace(reg, function (matches, $1, $2, $3, offset) {
str += "'" + string.slice(index, offset) + "'";
if ($1) {
//渲染值
str += '+';
str += $1;
str += '+';
} else if ($2) {
//渲染html字符串
str += '+' + $2 + '+';
} else if ($3) {
//渲染表达式
str += ';'; //第一步加个分号将前面的逻辑终止
str += $3; //第二步直接拼接表达式
str += '_p+='; //第三步要将表达式包裹的内容与_p相加并赋值给_p
}
index = offset + matches.length;
});
str += "'" + string.slice(index) + "'";
return str;
}
但是仅仅将html字符串拼接上去是不安全的,为了预防xss攻击,我们需要将html字符串中的特殊字符进行转义.
将代码进行如下修改,即可实现对特殊字符编码的目的。
//将html字符串传递给 esacper 函数处理一遍
else if ($2) {
//渲染html字符串
str += '+ esacper(' + $2 + ') +';
}
//处理html字符串的特殊符号,预防xss攻击
function esacper(str) {
const keyMap = {
//需要转译的队列
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '&hx27;',
'`': '٠',
};
const keys = Object.keys(keyMap);
const reg = new RegExp(`(?:${keys.join('|')})`, 'g');
const replace = (value) => {
return keyMap[value];
};
return reg.test(str) ? str.replace(reg, replace) : str;
}
输出结果:
<div><div style="color:red">name:张三</div></div>
最终代码
最终代码如下,70行左右的代码即可实现一款包含值渲染,表达式渲染以及html字符串渲染的简易版模板引擎,如果还需要其他功能可自行扩展增强.
function compile(string) {
string = string.replace(/\n|\r\n/g, ''); //为了调用时兼容es6模板字符串
/**
* 将html字符串的特殊字符转义,预防xss攻击
*/
function esacper(str) {
const keyMap = {
//需要转译的队列
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '&hx27;',
'`': '٠',
};
const keys = Object.keys(keyMap);
const reg = new RegExp(`(?:${keys.join('|')})`, 'g');
const replace = (value) => {
return keyMap[value];
};
return reg.test(str) ? str.replace(reg, replace) : str;
}
function render() {
var str = '';
str += esacper.toString();
str += "var _p = '';";
str += 'with(data){';
str += '_p +=';
str = templateParse(str);
str += ';}return _p;';
return str;
}
function templateParse(str) {
var reg = /<\?=([\s\S]+?)\?>|<\?-([\s\S]+?)\?>|<\?([\s\S]+?)\?>/g;
var index = 0;
string.replace(reg, function (matches, $1, $2, $3, offset) {
str += "'" + string.slice(index, offset) + "'";
if ($1) {
//渲染值
str += '+';
str += $1;
str += '+';
} else if ($2) {
//渲染html字符串
str += '+ esacper(' + $2 + ') +';
} else if ($3) {
//渲染表达式
str += ';'; //第一步加个分号将前面的逻辑终止
str += $3; //第二步直接拼接表达式
str += '_p+='; //第三步要将表达式包裹的内容与_p相加并赋值给_p
}
index = offset + matches.length;
});
str += "'" + string.slice(index) + "'";
return str;
}
var template_str = render();
var fn = new Function('data', template_str);
return fn;
}