20行实现JS模板引擎

项目中经常会用到框架或第三方的模板引擎,以便我们更快更方便的去绑定数据,处理dome渲染,那么神奇的模板引擎背后,原理是什么,为什么仅仅通过插值表达式就能绑定数据,并可以执行简单的JS逻辑运算呢,下面让我们一起走进模板引擎的黑魔法世界吧…

常见的模板语法

artTemplate

//artTemplate
{{if admin}}
{{include 'admin_content'}}

{{each list}}
<div>{{$index}}. {{$value.user}}</div>
{{/each}}
{{/if}}

<%if (admin){%>
<%include('admin_content')%>

<%for (var i=0;i<list.length;i++) {%>
<div><%=i%>. <%=list[i].user%></div>
<%}%>
<%}%>

插值匹配

var re = /<%([^%>]+)?%>/,
tpl = '<div>产品:<%productName%>, 价格:<%productPrice%></div>';
re.exec(tpl)
=>
["<%productName%>", "productName"]

使用循环获取所有的匹配数据

while(match = re.exec(tpl)) {
console.log(match);
}
=>
["<%productName%>", "productName", index: 5, input: "<div><%productName%>. <%productPrice%></div>"]
["<%productPrice%>", "productPrice", index: 22, input: "<div><%productName%>. <%productPrice%></div>"]

替换变量字符

一种是使用replace替换

var t;
while(match = re.exec(tpl)) {
t = t.replace(match[0], data[match[1]])
}
console.log(t);

但是这种方法不便处理复杂的绑定

var data = {product:{name:'TV', price:'200'%>;
var tpl = '<div>产品:<%product.name%>, 价格:<%product.price%></div>';

这种会被解析成
<div>产品:undefined, 价格:undefined</div>

模板转JS

另一种是将模板解析为可执行函数,也就是字符串模板转JS
通常在使用其它模板时,在插值表达式中会做一些简单的JS处理,如

<%name || ''%>

<%age>18?'成年':'未成年'%>

<%for (var i in items){%>
<div>items[i].name</div>
<%}%>

这就需要 <%这里必须是一个可执行的JS函数%>

那么怎样将模板里的字符串和JS函数分离开来,组成一个新可执行函数呢
例如模板是这样的
产品:<%product.name}}, 价格:<%product.price%>
那么解析后就应该是这样的

var r = [];
r.push('产品:');
r.push(product.name);
r.push(', 价格:');
r.push(product.price);
r.join('');

但是如果有循环数据操作

<%for (var i in products)%>
<div><%products[i].name%></div>
<%}%>

那么解析后就是这样的:

var r = [];
r.push(for (var i in products));
r.push('<div>');
r.push(products[i].name);
r.push('</div>');
r.join(});

数组中直接pushJS代码会报错,那么必须变成下面的形式

var r = [];
for (var i in products){
r.push('<div>');
r.push(products[i].name);
r.push('</div>');
}
r.join('');

将js执行语句与变量分离开来,就达到了模板转JS的效果,就可以正常执行了

将模板转化成可执行的JS字符串
新增处理 js代码 与 普通字符 的函数 add

var add = function(line, isJS){
isJS ? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
((!(/^\s{1,}$/.test(line))) && (code +='r.push("' + line.replace(/"/g, '\\"').replace(/[\r\t\n]/g, '') + '");\n'));
return add;
}

isJS 如果是js语句或变量,则判断是否是纯变量,如果不是则输出,如果是,则添加push到输出结果中
如果不是js语句,则先转义"号,替换掉换行和空格

var tplEngine = function(tpl, data){
var re = /<%([^%>]+)?%>/g;
var temp = tpl;
var cursor=0;
var reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g;
var add = function(line, isJS){
isJS ? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
((!(/^\s{1,}$/.test(line))) && (code +='r.push("' + line.replace(/"/g, '\\"').replace(/[\r\t\n]/g, '') + '");\n'));
return add;
}

var code = 'var r=[];\n';
while(res = re.exec(tpl)){
console.log(tpl.slice(cursor, res.index));
console.log(res[0]);
add(tpl.slice(cursor, res.index))(res[1], 1);
cursor = res.index + res[0].length;

};
console.log(tpl.substr(cursor, tpl.length));

add(tpl.substr(cursor, tpl.length))
code += 'return r.join("");';

console.log(code);
return temp;
}

处理简单逻辑

tplEngine('<h3>我买了个<%name%>,价格是<%price%></h3>');

//输出结果:
<h3>我买了个
<%name%>
,价格是
<%price%>
</h3>

var r=[];
r.push("<h3>我买了个");
r.push(name);
r.push(",价格是");
r.push(price);
r.push("</h3>");
return r.join("");

处理复杂逻辑

tplEngine('<h3>我买了个<%name%>,价格是<%price%></h3>');

//输出结果:
<h3>我买了个
<%name%>
,价格是
<%price%>
</h3>

var r=[];
r.push("<h3>我买了个");
r.push(name);
r.push(",价格是");
r.push(price);
r.push("</h3>");
return r.join("");

这样就可以在模板中处理复杂的JS逻辑了,例如:

<h3><%title%></h3>
<%if(mods.length && showMods){%>
<ul>
<%for (var i in mods)%>
<li>mods[i].name</li>
<%}%>
</ul>
<%}%>

解析结果:

var r=[];
r.push("<p>喜欢的水果:</p>");
if (fruits.length){
for (var i in fruits){
r.push("<label>");
r.push(fruits[i].name);
r.push("</label><input type=\"checkbox\" name=\"fruit\" ");
if (fruits[i].checked){
r.push("checked");
}
r.push(">");
}
}
return r.join("");

通过字符串生成函数

到这里,我们就可以正常解析简单和稍微带点逻辑的JS模板了,但是,至此我们通过code+=的方式,得到的仍然是一些字符串,那么如何将字符串转变成可执行的JS代码呢,又怎么传入数据解析呢?
那么就需要引入JS的一个特性:通过字符串生成函数

看下面的例子:

var fn = new Function("arg", "console.log(arg + 1);");
fn(2); // 3

等价于:
var fn = function(arg) {
console.log(arg + 1);
}
fn(2); // 3

利用这个特性,可以轻松的将我们生成的代码字符串转成一个可执行的函数,并传递一个数据对象进去

完整代码

var tplEngine = function(tplId, data){
var re = /<%([^%>]+)?%>/g, tpl = document.getElementById(tplId).innerHTML, temp = tpl, cursor=0, reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g;
add = function(line, isJS){
isJS ? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
((!(/^\s{1,}$/.test(line))) && (code +='r.push("' + line.replace(/"/g, '\\"').replace(/[\r\t\n]/g, '') + '");\n'));
return add;
}
var code = 'var r=[];\n';
while(res = re.exec(tpl)){
add(tpl.slice(cursor, res.index))(res[1], 1);
cursor = res.index + res[0].length;
};
add(tpl.substr(cursor, tpl.length))
code += 'return r.join("");';
return new Function(code).apply(data);
}

var r = tplEngine('tpl2',{fruits:[{name:'apple', checked:true},{name:'bannar', checked:false}]});
console.log(r);
<!-- document.getElementById('box2').innerHTML = r; -->

输出结果:
<p>喜欢的水果:</p><label>apple</label><input type="checkbox" name="fruit" checked><label>bannar</label><input type="checkbox" name="fruit" >

不同的模板引擎仍然是有不同的解析逻辑的,下面是一段 artTemplate 的核心解析逻辑:

// html与逻辑语法分离
forEach(source.split(openTag), function (code) {
code = code.split(closeTag);

var $0 = code[0];
var $1 = code[1];

// code: [html]
if (code.length === 1) {

mainCode += html($0);

// code: [logic, html]
} else {

mainCode += logic($0);

if ($1) {
mainCode += html($1);
}
}


});
var code = headerCode + mainCode + footerCode;

由此可以看出 artTemplate 是使用两次split的方式,分离普通字符与JS逻辑字符的
简单的如下

<h3>我买了个<%name%>,价格是<%price%></h3>
split('<%') =>数组
<h3>我买了个 name%>,价格是 price%></h3>
split('%>') =>遍历数组
<h3>我买了个
name
,价格是
price
</h3>

第二次split,取split之后,数组的第一个就是我们想要的JS逻辑了,再对JS逻辑做变量和逻辑代码的判断,就可以得出最终要执行的JS代码字符了
最后通过字符串创建函数的方式完成模板的编译

var Render = new Function("$data", "$filename", code);

后记

通过以上方式,一个简单的JS模板引擎就可以运行了,但是仍然有很多问题,
比如插值表达式中如果含有一些特殊字符里就会解析出错,所以上面的正则依然是很弱的,
对于模板中的特殊字符处理,还有简单js语法逻辑语法的优化
上下文的处理
很多优秀的模板都有自己实现的一套模板语法,比如

{{each data as item i }} {{/each}}

一个完善的引擎还有很多很多的逻辑和错误处理,这里只是抛砖引玉,通过上面了解模板引擎的运行机制,以便于我们更好的去了解别人的框架源码,并提供更好的解决方案。

注意: exec 加g与不加g的区别,若指定了 g,则下次调用 exec 时,会从上个匹配的 lastIndex 开始查找,若不指定则会每次从第一个开始查找,这就是上面为什么要用temp临时变量来存储替换的原因!!!