从零构建JavaScript轻量级模板引擎:原理与实现

作者:半夏时间:2026-10-29 09:20:00浏览:453

本文详细解析如何从零开始编写一个轻量级的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行!

作者最新文章