[译] 只有 20 行的 JavaScript 模板引擎

写于 2016.06.13javascript

原文连接:JavaScript template engine in just 20 linescss

(译者吐槽:只收藏不点赞都是耍流氓)html

前言

我仍旧在为个人JS预处理器AbsurdJS进行开发工做。它本来是一个CSS预处理器,但以后它扩展成为了CSS/HTML预处理器,很快它将支持JS到CSS/HTML的转换。它就像一个模板引擎同样可以生成HTML代码,也就是说它可以用数据填充模板当中的标识片断。前端

所以,我但愿去写一个能够知足我当前需求的模板引擎。AbsurdJS主要做为NodeJS的模块使用,但同时它也能够在客户端使用。为了这个目的,我没法使用市面上已经存在的模板引擎,由于它们几乎全都依赖于NodeJS,而且难以在浏览器中使用。我须要一个更小,纯JS写成的模板引擎。我浏览了这篇由John Resig写的博客,彷佛这正是我须要的东西。我把当中的代码稍做修改,而且浓缩到了20行。java

这段代码的运行原理很是有趣,我将在这篇文章中一步一步为你们展现John的wonderful 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 })); 复制代码

一个简单的函数,传入模板数据做为参数,正如你所想象的,咱们想要获得如下的结果:浏览器

<p>Hello, my name is Krasimir. I'm 29 years old.</p> 复制代码

咱们要作的第一件事就是获取模板中的标识片断<%...%>,而后用传入引擎中的数据去填充它们。我决定用正则表达式去完成这些功能。正则不是个人强项,因此你们将就一下,若是有更好的正则也欢迎向我提出。bash

var re = /<%([^%>]+)?%>/g;
复制代码

咱们将会匹配全部以<%开头以%>结尾的代码块,末尾的g(global)表示咱们将匹配多个。有许多的方法可以用于匹配正则,可是咱们只须要一个可以装载字符串的数组就够了,这正是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);
}
复制代码

运行,发现全部的标识片断已经被咱们获取到了。

二、数据填充与逻辑处理

在获取了标识片断之后,咱们就要对它们进行数据的填充。使用.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
}
复制代码

OK,正常运行。但很明显这并不足够,咱们当前的数据结构很是简单,但实际开发中咱们将面临更复杂的数据结构:

{
    name: "Krasimir Tsonev",
    profile: { age: 29 }
}
复制代码

出现错误的缘由,是当咱们在模板中输入<%profile.age%>的时候,咱们获得的data["profile.age"]是undefined的。显然.replace方法是行不通的,咱们须要一些别的方法把真正的JS代码插入到<%和%>当中,就像如下栗子:

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); // 输出 3
复制代码

fn是个真正的函数,它包含一个参数,其函数体为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>";
复制代码

显然,咱们把模板分红了文本和JS代码。正如上述代码,咱们使用了简单的字符串拼接的方式去获取最终结果,可是这个方法没法100%实现咱们的需求,由于以后咱们还要处理诸如循环之类的JS逻辑,像这样:

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变量中,做为构建函数体的过程方法。这里有一个棘手的地方,咱们须要跳过标识符<%%>,不然当中的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("");
复制代码

呃……这不是咱们想要的。this.namethis.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; } 复制代码

标识片断中的内容将经过一个boolean值进行控制。如今咱们获得了一个正确的函数体:

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) {%>' + 
'<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'; } 复制代码

咱们添加了一个新的正则。这个正则的做用是,若是一段JS代码以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后看到了这篇文章以为很是优秀,一步步讲解生动且深刻,代码通过本人测试均能正确获得文章描述的结果。

模板引擎有多种设计思路,本文仅仅为其中的一种,其性能等参数还有待测试和提升,仅供学习使用。 谢谢你们~

相关文章
相关标签/搜索