简易Javascript 模板引擎
最近在研究学习JavaScript的模板引擎的实现原理,读到Krasimir Tsonev的这篇介绍JS模板引擎实现原理的文章,对于新手很是受用,因此翻译过来。
我还在忙于开发基于JavaScript的预处理器——AbsurdJS, 它最开始仅作为CSS预处理器,后来扩展为CSS/HTML预处理器。简而言之,它可以将JavaScript转换为CSS/HTML。自然的,由于它可以生成HTML,因此可以作为模板引擎使用,即将数据填充到标签中。
因此,我想写一个简单的模板引擎逻辑,来完美兼容现在的项目。AbsurdJS主要作为NodeJS模块来使用,不过有时候也需要在客户端使用。我发现目前还没有引擎能满足这一需求,因为大多数引擎都是基于NodeJS运行环境的,从而很难移植到浏览器运行。我需要一个小巧且用纯JavaScript写的引擎。恰好John Resig的这篇文章中提出的引擎满足我的需求。我对它稍作修改,并减缩到20行代码。研究这段脚本的原理是很有意思的事情。本文中我会一步一步重构这个引擎,以便读者能够理解来自John的绝妙idea。
首先,我们的模板引擎应该长这样:
var TemplateEngine = function(tpl, data) {
// magic here ...
}
var template = '<p>Hello, my name is <%name%>. I\'m <%age%> years old.</p>';
console.log(TemplateEngine(template, {
name: "Krasimir",
age: 29
}));
一个简单的函数,输入参数是tpl模板和data对象。可以猜到,最终我们期望得到的结果应该是:
<p>Hello, my name is Krasimir. I'm 29 years old.</p>
首先我们需要获得模板中的动态片段,然后用传入引擎中的真实数据替换它们。我们可以用正则表达式来实现,这不是我的强项,所以欢迎指正并提出更好的正则表达式。
var re = /<%([^%>]+)?%>/g;
这样,我们会捕获所有以<%开头,以%>结尾的的片段。标志位g表示获取所有的匹配项。接受正则表达式的方法有很多,这里我们需要的是一个元素是字符串的数组,这正是exec能干的事情。
var re = /<%([^%>]+)?%>/g;
var match = re.exec(tpl);
用console.log将match变量打印出来,我们会得到:
[
"<%name%>",
" name ",
index: 21,
input: "<p>Hello, my name is <%name%>. I\'m <%age%> years old.</p>"
]
现在,我们拿到数据了,但是如上所示,返回的数组仅有一个元素,而我们需要处理所有的匹配项。因此,我们要在上述代码的外面加一个while循环。
var re = /<%([^%>]+)?%>/g, match;
while(match = re.exec(tpl)) {
console.log(match);
}
运行上面的代码,可以同时得到<%name%>和<%age%>。
现在事情开始变得有趣了。我们需要将传入函数的真实数据来替换模板中的占位符。最简单的方法是对模板应用.replace方法。可以这样写:
var TemplateEngine = function(tpl, data) {
var re = /<%([^%>]+)?%>/g, match;
while(match = re.exec(tpl)) {
tpl = tpl.replace(match[0], data[match[1]])
}
return tpl;
}
OK, 这有用,但当然还不够好。我们现在传入的是最简单的对象,所以可以用data[“property”]的方式来取值。但是在实际应用中,我们可能会遇到复杂的嵌套对象。例如,将我们的数据改为:
{
name: "Krasimir Tsonev",
profile: { age: 29 }
}
现在我们的引擎失效了,因为当输入<%profile.age%>时,我们会得到data[“profile.age”],这个值实际上是undefined。因此,我们需要修改引擎的实现。在这种情况下.replace方法无法胜任。最好的方式是在<%和%>之间放置JavaScript代码,最好能够根据传入的数据来执行。例如:
var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>';
该怎么实现呢?John用了new Function语法,也即以字符串为参数来创建函数。我们先看一个简单的例子:
var fn = new Function("arg", "console.log(arg + 1);");
fn(2); // outputs 3
fn实际上是一个接收一个参数的函数,它的函数体是console.log(arg+1);换句话说,上述代码等同于:
var fn = function(arg) {
console.log(arg + 1);
}
fn(2); // outputs 3
通过这种方式,我们可以通过简单字符串来定义一个函数的参数和函数体。这正好满足我们的需求。但是在定义这样一个函数之前,我们需要构建函数的函数体。函数最终应返回编译好的模板。用之前的例子的话,编译好的模板应该是这样的形式:
return
"<p>Hello, my name is " +
this.name +
". I\'m " +
this.profile.age +
" years old.</p>";
我们需要将模板分成普通文本和有意义的JavaScript代码。如上所示,我们可以通过简单的拼接来得到想要的结果。但是,这种方法不能100%满足要求。因为我们传入的可执行的JavaScript代码可能会做循环,例如:
var template =
'My skills:' +
'<%for(var index in this.skills) {%>' +
'<%this.skills[index]%>%this.skills[index]%>' +
'<%}%>'; < code>%}%>';>
如果用拼接,结果会是这样:
return
'My skills:' +
for(var index in this.skills) { +
'<a href="">' +
this.skills[index] +
'</a>' +
}
毫无疑问,这肯定会报错。因此我决定采用John的文章中所用的方法:将所有的字符串片段存入一个数组中,最后再将数组的元素用join方法拼接起来。
var r = [];
r.push('My skills:');
for(var index in this.skills) {
r.push('<a href="">');
r.push(this.skills[index]);
r.push('</a>');
}
return r.join('');
下一步,我们需要收集自定义函数的不同行。我们已经从模板中获得了一些信息。我们知道占位符的内容及其位置。所以通过使用一个辅助变量(cursor)我们就能能够得到预期的结果。
var TemplateEngine = function(tpl, data) {
var re = /<%([^%>]+)?%>/g,
code = 'var r=[];\n',
cursor = 0, match;
var add = function(line) {
code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}
while(match = re.exec(tpl)) {
add(tpl.slice(cursor, match.index));
add(match[1]);
cursor = match.index + match[0].length;
}
add(tpl.substr(cursor, tpl.length - cursor));
code += 'return r.join("");'; // <-- return the result
console.log(code);
return tpl;
}
var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>';
console.log(TemplateEngine(template, {
name: "Krasimir Tsonev",
profile: { age: 29 }
}));
变量code用于保存函数的函数体。它以数组的定义开始,而cursor表示现在在模板中所处的位置。我们需要这样一个变量来遍历整个字符串并跳过数据片段。这里使用了一个add函数,它的作用是将代码行添加到code变量的末尾。这里有一个坑要注意:我们需要对双引号转义,否则生成的脚本会是非法的。运行整个实例,我们可以在控制台看到:
var r=[];
r.push("<p>Hello, my name is ");
r.push("this.name");
r.push(". I'm ");
r.push("this.profile.age");
return r.join("");
呃… 与我们预期的不一致。这里的this.name和this.profile不应该加引号。对add方法做一点修改可以解决这个bug:
var add = function(line, js) {
js? code += 'r.push(' + line + ');\n' :
code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}
var match;
while(match = re.exec(tpl)) {
add(tpl.slice(cursor, match.index));
add(match[1], true); // <-- say that this is actually valid js
cursor = match.index + match[0].length;
}
传入占位符的内容的同时传入一个布尔变量(表示传入的字符串是否是合法的js代码)。现在可以生成正确的函数体了。
var r=[];
r.push("<p>Hello, my name is ");
r.push(this.name);
r.push(". I'm ");
r.push(this.profile.age);
return r.join("");
现在我们要做的是创建这个函数并执行它,在模板引擎的末尾,我们不返回tpl,而是返回这个函数:
return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);
我们甚至不需要给这个函数传任何参数,我们用apply方法来调用它。它会自动设置作用域,因此我们用的this.name可以生效,这里的this实际上指向传入的data。
我们马上就要完成了。还有最后一件事,我们需要支持更复杂的操作,如:if/else语句和循环。仍用上面都的例子并应用目前为止的代码。
var template =
'My skills:' +
'<%for(var index in this.skills) {%>' +
'<%this.skills[index]%>%this.skills[index]%>' +
'<%}%>'; console.log(templateengine(template, { skills: ["js", "html", "css"] })); < code>%}%>';>
结果会报错Uncaught SyntaxError: Unexpected token for。通过调试并输出code变量,我们就能发现问题所在。
var r=[];
r.push("My skills:");
r.push(for(var index in this.skills) {);
r.push("<a href=\"\">");
r.push(this.skills[index]);
r.push("</a>");
r.push(});
r.push("");
return r.join("");
包含for循环的代码行不应该push到数组中,而应该放在脚本内执行。因此我们需要在给code添加内容之前多做一步检查。
var re = /<%([^%>]+)?%>/g,
reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g,
code = 'var r=[];\n',
cursor = 0;
var add = function(line, js) {
js? code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n' :
code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}
引入了一个新的正则表达式,它告诉我们如果js代码以if、for、else、switch、case、break、{或}开始的话,则单纯的将这一行加到code末尾,否则则在外面包一层push语句。结果如下:
var r=[];
r.push("My skills:");
for(var index in this.skills) {
r.push("<a href=\"#\">");
r.push(this.skills[index]);
r.push("</a>");
}
r.push("");
return r.join("");
现在,所有模板都能正确编译了。
My skills:<a href="#">js</a><a href="#">html</a><a href="#">css</a>
实际上最后一步的修正使我们的引擎变得更加强大。我们可以直接在模板中使用复杂的逻辑。例如:
var template =
'My skills:' +
'<%if(this.showSkills) {%>' +
'<%for(var index in this.skills) {%>' +
'<%this.skills[index]%>%this.skills[index]%>' +
'<%}%>' + '<%} else {%>' +
'none
' +
'<%}%>'; console.log(templateengine(template, { skills: ["js", "html", "css"], showskills: true })); < code>%}%>';>%}%>'>%for(var>
我对代码做了一些细小的优化,最终版如下所示:
var TemplateEngine = function(html, options) {
var re = /<%([^%>]+)?%>/g, reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g, code = 'var r=[];\n', cursor = 0, match;
var add = function(line, js) {
js? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
(code += line != '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : '');
return add;
}
while(match = re.exec(html)) {
add(html.slice(cursor, match.index))(match[1], true);
cursor = match.index + match[0].length;
}
add(html.substr(cursor, html.length - cursor));
code += 'return r.join("");';
return new Function(code.replace(/[\r\t\n]/g, '')).apply(options);
}
它仅有15行!!!