本文详细解析了一个仅由20行JavaScript代码构成的轻量级模板引擎。通过提取标识片段、数据填充、函数构造及逻辑优化四个步骤,展示了如何利用正则表达式和Function构造函数实现简单的模板渲染,适用于前端基础学习与理解模板引擎原理。
本文分享一个JavaScript模板引擎的实现案例,供读者参考学习。
原文链接:JavaScript template engine in just 20 lines
前言
笔者目前正致力于开发JS预处理器AbsurdJS。该项目最初定位为CSS预处理器,随后扩展为支持CSS/HTML的预处理,未来还将支持从JS到CSS/HTML的转换。其核心功能类似于模板引擎,即通过数据填充模板中的占位符来生成HTML代码。
基于上述需求,笔者希望编写一个满足当前场景的模板引擎。AbsurdJS主要作为Node.js模块使用,同时也需支持浏览器端运行。由于市面上现有的模板引擎大多依赖Node.js环境,在浏览器中集成较为困难,因此笔者需要一个体积更小、纯JavaScript编写的模板引擎。参考John Resig的博客文章后,笔者对其代码进行了修改和精简,最终浓缩为20行代码。
这段代码的运行机制非常有趣,下文将逐步解析John提出的这一巧妙思路。
1、提取标识片段
这是初始阶段需要实现的功能:
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
}));定义一个简单的函数,接收模板字符串和数据对象作为参数,预期输出如下结果:
<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>"
]虽然匹配到了正确的结果,但如所见,仅匹配到了一个标识片段<%name%>。因此,我们需要使用while循环来提取所有标识片段。
var re = /<%([^%>]+)?%>/g, match;
while(match = re.exec(tpl)) {
console.log(match);
}运行代码后,发现所有标识片段均已被成功获取。
2、数据填充与逻辑处理
获取标识片段后,接下来需进行数据填充。使用.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;
}
data = {
name: "Krasimir Tsonev",
age: 29
}代码正常运行。然而,当前数据结构较为简单,实际开发中往往面临更复杂的数据结构:
{
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); // 输出 3fn是一个真正的函数,它包含一个参数,其函数体为console.log(arg + 1)。上述代码等价于以下写法:
var fn = function(arg) {
console.log(arg + 1);
}
fn(2); // 输出 3通过new Function,我们可以利用字符串创建函数,这正是我们所需的功能。在创建该函数之前,需要构造其函数体。该函数体应当返回最终拼接好的模板。沿用前文的模板字符串,想象该函数应返回的结果:
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) {%>' +
'<a href=""><%this.skills[index]%></a>' +
'<%}%>';如果使用字符串拼接,结果将变为:
return
'My skills:' +
for(var index in this.skills) { +
'<a href="">' +
this.skills[index] +
'</a>' +
}这显然会引发错误。这也是笔者决定参照John文章编写逻辑的原因——将所有的字符串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>');
}
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变量中,作为构建函数体的过程方法。这里有一个棘手的地方,我们需要跳过标识符<%%>,否则其中的JavaScript脚本将失效。如果直接运行上述代码,结果将是:
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.age不应带有引号。我们改进一下add函数:
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;
}标识片段中的内容将通过一个布尔值进行控制。现在我们得到了一个正确的函数体:
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对象。
3、代码优化
大体上已完成。最后一件事,我们需要支持更多复杂的表达式,如if/else表达式和循环等。让我们用同样的例子尝试运行下列代码:
var template =
'My skills:' +
'<%for(var index in this.skills) {%>' +
'<a href="#"><%this.skills[index]%></a>' +
'<%}%>';
console.log(TemplateEngine(template, {
skills: ["js", "html", "css"]
}));结果将报错,错误为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到数组中,而是应直接放置在脚本内部。为解决此问题,在将代码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';
}我们添加了一个新的正则。该正则的作用是,如果一段JavaScript代码以if, for, else, switch, case, break, |开头,则将其直接添加到函数体中;否则,将其push到code变量中。以下是修改后的结果:
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) {%>' +
'<a href="#"><%this.skills[index]%></a>' +
'<%}%>' +
'<%} else {%>' +
'<p>none</p>' +
'<%}%>';
console.log(TemplateEngine(template, {
skills: ["js", "html", "css"],
showSkills: true
}));经过一些细微的优化后,最终版本如下:
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行。
后记(译者注)
这是笔者首次完整翻译文章,语句中若有错漏之处,敬请谅解。今后将继续努力,争取分享更多优质文章。
由于对前端框架及模板引擎等工具特别感兴趣,笔者非常希望学习其底层原理,因此选择了一个相对简单的模板引擎进行研究。经Google搜索后找到这篇文章,觉得其讲解生动深入,代码经本人测试均能正确得到文章描述的结果。
模板引擎有多种设计思路,本文仅为其中一种,其性能等参数尚待测试和提高,仅供学习参考。 谢谢大家~
感兴趣的朋友可以使用在线HTML/CSS/JavaScript代码运行工具:http://tools.jb51.net/code/HtmlJsRun测试上述代码运行效果。
更多关于JavaScript相关内容感兴趣的读者可查看本站专题:《javascript面向对象入门教程》、《JavaScript错误与调试技巧总结》、《JavaScript数据结构与算法技巧总结》、《JavaScript遍历算法与技巧总结》及《JavaScript数学运算用法总结》
希望本文所述对大家JavaScript程序设计有所帮助。