从零构建JavaScript轻量级模板引擎:原理与实现
本文详细解析如何从零开始编写一个轻量级的JavaScript模板引擎。通过正则表达式提取模板占位符,利用new Function动态生成执行函数,并处理字符串拼接与代码块解析,最终实现支持嵌套对象、条件判断及循环语句的模板引擎。
引言与背景
为了完善现有的模板引擎并使其能与既有逻辑协同工作,作者决定编写一段精简代码。AbsurdJS主要作为NodeJS模块发布,但也提供客户端版本。鉴于现有引擎多运行于NodeJS环境,无法直接在浏览器中使用,作者需要一个小巧、纯JavaScript编写且能在浏览器中直接运行的解决方案。偶然间,作者发现了John Resig的一篇博客,其中的思路完美契合需求。经过少量修改,代码精简至约20行,其逻辑精妙。本文将逐步重现该引擎的编写过程,展示这一想法的精辟之处。
基础实现:正则匹配与替换
最初的构思如下:
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
}));
设计一个简单函数,接收模板字符串和数据对象作为输入,输出渲染后的HTML字符串,例如:
<p>Hello, my name is Krasimir. I'm 29 years old.</p> 实现的第一步是识别模板中的参数,并将其替换为传入的具体数据。作者选择使用正则表达式来完成这一任务,并邀请读者对代码质量提出批评。
var re = /<%([^%>]+)?%>/g;
该正则表达式用于捕获所有以<%开头、%>结尾的代码片段。末尾的g标志表示全局匹配,即查找所有符合条件的片段。在JavaScript中,有多种使用正则表达式的方法,这里利用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;
while(match = re.exec(tpl)) {
console.log(match);
}
运行上述代码后,<%name%>和<%age%>均会被打印出来。
接下来是核心部分。识别出模板中的匹配项后,需将其替换为函数传入的实际数据。最简单的方法是使用replace函数,写法如下:
var TemplateEngine = function(tpl, data) {
var re = /<%([^%>]+)?%>/g;
while(match = re.exec(tpl)) {
tpl = tpl.replace(match[0], data[match[1]])
}
return tpl;
}
虽然可以运行,但存在不足。此处通过data["property"]方式使用简单对象传递数据,但实际场景中可能需要更复杂的嵌套对象。因此,对data对象进行了如下修改:
{
name: "Krasimir Tsonev",
profile: { age: 29 }
}
核心原理:Function构造函数
直接这样写无法运行,因为在模板中使用<%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) {%>' +
'<a href=""><%this.skills[index]%></a>' +
'<%}%>';
如果使用字符串拼接,代码应如下所示:
return
'My skills:' +
for(var index in this.skills) { +
'<a href="">' +
this.skills[index] +
'</a>' +
}
该代码无法直接运行,会报错。因此,采用了John文章中的逻辑,将所有字符串放入数组,最后在程序结束时拼接起来。
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;
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中。需特别注意,需对code包含的双引号字符进行转义(escape),否则生成的函数代码会出错。运行上述代码,控制台输出如下:
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不应有引号,需再次修改。
var add = function(line, js) {
js? code += 'r.push(' + line + ');\n' :
code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}
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;
}
将占位符的内容和一个布尔值一起作为参数传给add函数,用于区分。这样即可生成所需的函数体。
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("");
剩余工作是创建并执行函数。因此,在模板引擎最后,将原本返回模板字符串的语句替换为如下内容:
代码如下:
return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);我们甚至无需显式传递参数给该函数。使用apply方法调用它,它会自动设定函数执行的上下文。这就是为什么能在函数内使用this.name。此处this指向data对象。
进阶:支持复杂语句
模板引擎接近完成,但还需支持更多复杂语句,如条件判断和循环。继续以上述例子编写。
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循环的那一行不应直接放入数组,而应作为脚本的一部分直接运行。因此,在将内容添加到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';
}
此处新增了一个正则表达式,用于判断代码中是否包含if、for、else等关键字。若有,则直接添加到脚本代码中,否则添加到数组中。运行结果如下:
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;
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行!
JavaScript闭包与类(Class)的区别及适用场景对比
2026-11-05 15:48:57
JavaScript入门要学ES6吗?let与箭头函数等新特性解析
2026-11-05 14:49:16
JavaScript 8种数据类型详解:基本类型与引用类型的区别
2026-11-05 14:25:15
怎么在JavaScript中使用async/await优化Ajax异步流程
2026-11-05 13:56:15
JavaScript replace回调函数实现动态正则替换逻辑
2026-11-05 13:52:13
