来吧,造个模板引擎轮子——目标代码生成

上篇上上篇,这一篇将介绍在上一篇区分出“模板语言”与“自然语言”的结构之后,如何进行“语法分析”、“语义分析”和“目标代码生成”。

这里几个关键词都加了引号,主要是因为和经典编译原理上定义的几个环节只是意合而非形同,不过用这样经典的三段式结构来打比方也许可以更好理解一些。

语法分析

我们还是用上一篇文章中的例子

1
2
3
4
5
6
7
<div class="list">
<% if (list.length > 0) %>
<% foreach item in list %>
<p>Hello, <%= item.name %></p>
<% /foreach %>
<% /if %>
</div>

它的解析结果是

1
2
3
4
5
6
7
8
9
自然 <div class="list">
模板 <% if (list.length > 0) %>
模板 <% foreach item in list %>
自然 <p>Hello,
模板 <%= item.name %>
自然 </p>
模板 <% /foreach %>
模板 <% /if %>
自然 </div>

为了减少干扰,我们先只看当中的模板语言部分

1
2
3
4
5
<% if (list.length > 0) %>
<% foreach item in list %>
<%= item.name %>
<% /foreach %>
<% /if %>

严格的说,我们需要将这个代码序列构建成一棵语法树,也就是常说的AST(Abstract Syntax Tree)。但因为模板语言的语法结构通常都很简单,而且我们用的也是手写的办法,这里我们就用一种很直观的方法,对它直接进行语法、语义两部合一的分析

遍历这个序列,解析每一句模板语句的内容,对if/foreach这些语句使用匹配算法。注意elseelseif是一个比较特殊的情况。

由于有了匹配关系,事实上可以在过程中保留匹配深度,这样甚至直接已经可以构建AST了。匹配关系对于代码生成并不是必须的,因为对于合法的模板代码,我们甚至只需要对它进行直译就能生成目标代码。但很多模板语法的特性是很依赖这个的(后面会说),于是乎还是有匹配关系才能可持续发展。

对于一些表达式这类的东西,比如item.index + 1,本来也应该对其构建AST,但模板语言既然脱胎于某种语言(比如我们现在的JS),就可以发挥这个优势,对于表达式,直接当成JS代码看就行(这个在下面生成目标代码的时候会详细的说)。

举个例子,当看到上面第三行<%= item.name %>=时,说明这句话是输出一个表达式的,那么具体这个表达式是什么其实可以不关心(当然也许为了优化是需要关心的,这个以后会专门说),直接把它当做一坨整体来看待就行。

而对于一些相对复杂点的模板语言,比如Smarty,它在早期的时候定义了好多表达式语法,比如什么$something eq "1"这种,而后来的版本又支持以几乎和PHP一模一样的语法来写$something == "1"了,简直是蛋疼,我都不想说它……

所以对于每一句模板语法,在对它进行解析中都要知道它哪一部分是可以看成“表达式”的,例如if (表达式)foreach 变量名 in 表达式

将上面的分析工作做完以后通常都会获得一个程序内用的数据结构,可能是AST,也可能还是一个顺序表,比如

1
2
3
4
5
if语句,表达式`list.length > 0`
foreach语句,变量名`item`,表达式`list`
变量输出语句,表达式`item.name`
/foreach语句
/if语句

然后在不考虑实现更多功能的情况下,已经可以用它进行代码生成了。

代码生成

代码生成可以用非常简单实用的办法,就是直译,比如将<% if (xxx) %>直译为if (xxx) {,将<% foreach item in list %>直译为for (var i=0, len=list.length; i<len; i++) { var item = list[i];,而<% /foreach %><% /if %>就直译为};而那些“自然语言”和变量输出语句则当做字符串拼接。这样,上面的代码可能就变成了(加点缩进“美化”一下)

1
2
3
4
5
6
7
8
9
10
11
var html = '';
html += '<div class="list">';
if (list.length > 0) {
for (var i=0, len=list.length; i<len; i++) {
var item = list[i];
html += '<p>Hello, ';
html += item.name;
html += '</p>';
}
}
html += '</div>';

哈哈,粗看之下已经可以运行了啊,很简单无脑有没有。注意,因为htmlilen这样的变量名很可能是会被用户用的,所以实际这个环节我们要弄一个“变量名生成器”,生成一堆犄角旮旯的变量名,来保证不会和用户代码中的变量名重了。

呃,等等,list这个变量名,没定义过啊,因为我们传进来这个函数的是一个data对象,而上面用到这个list其实是它的某一个字段,所以应该访问data.list才是对的。但那样的话满世界的data.很让人心烦(当然不care这个事情的话下面的心思也不用花了),所以我们要解决一个变量访问的办法。

这里介绍两种思路,一种是用类似于etpl的“安全变量访问”,将表达式解析为一个变量访问函数的调用,比如item.name就被解析成了get_variable(['item', 'name']),这样做其实有一定的局限性,比如用索引方式访问item['name']怎么算呢?要处理还是不处理?再比前文当中提到的变量作用域的问题(当然可以不提供或者有限提供变量声明语法来规避这个问题)嗯,还挺纠结的呢是不是。

另一种方法是将代码中可能成为变量名的内容提取出来,比如上述的模板片段中就会有listlengthitemname这几个,然后将他们提前定义一下。例如上面的代码就会变成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var list = data['list'];
var length = data['length'];
var item = data['item'];
var name = data['name'];
var html = '';
html += '<div class="list">';
if (list.length > 0) {
for (var i=0, len=list.length; i<len; i++) {
var item = list[i];
html += '<p>Hello, ';
html += item.name;
html += '</p>';
}
}
html += '</div>';

可以发现虽然定义了一大堆,但其实只有list是被正确使用的,lengthname其实是没用的,而item则是被后来定义的又给盖了。

然后对上面的函数套一些胶水代码,比如

1
2
3
4
function (data) {
// 上面那一堆
return html;
}

注意,现在这货是个字符串,我们需要用new Function或者eval的方式来把它弄成前文所说的render函数,然后对render函数传入data参数就大功告成了。

代码生成中的优化

字符串拼接

模板引擎本质上就是一个拼字符串的工具,毫无疑问,字符串拼接是模板引擎所生成的代码质量的一个核心因素。上面的例子当中直接使用字符串连接,在V8里是非常快的。如果你的代码想要对IE67进行优化,建议对老版本的IE生成push到数组然后最终join的办法。我记忆中IE8还是9以后字符串连接就比join要快了。

循环语句的生成

我们知道在JS里面遍历数组和遍历KV对象是不一样的,对于上面的例子我们生成的是遍历数组的代码,但事实上很多模板引擎都用一个foreach语法提供了两种功能。

最简单的办法是:生成两份代码,判断遍历目标是数组/KV对象的时候分别执行,例如

1
2
3
4
5
if (isArray(xxxxxx)) {
// 一份完整的遍历数组的代码
} else if (isObject(xxxxxx)) {
// 一份完整的遍历KV的代码
}

在此之前,考虑到xxxxxx这个表达式可能访问起来有代价,可以先定义一个临时变量对它进行一下“缓存”。

对于遍历KV对象,如果愿意要求传入参数只能是plain object,那么可以预先用Object.getOwnPropertyNames获取到它所有的key,然后用遍历数组的办法来遍历,这个方法(至少在V8下)比直接用for in要快不少,但因为对于带原型的对象,Object.getOwnPropertyNamesfor in之间会因为原型上的属性(甚至可能是用defineProperty定义的属性)而表现的很不一致,多数情况下使用for in会更稳妥一些。

小结

到了这里,除了函数声明/调用外,我们几乎已经有了一个能用的模板引擎了,它能够支持

  • 变量输出
  • 循环
  • 条件

下篇文章也许是这个系列最后一篇了,将会介绍如何实现包括函数声明/调用在内的更多feature。