前端模板引擎现在已经被广泛应用于前端开发了,几乎每个项目都会使用。微博上甚至出现了“不写个模板引擎就没办法在前端界混了”的言论。当然,这是玩笑话,却也能在一定程度上反映前端模板的普及程度。如果你还没有了解过前端模板引擎,赶紧去补补课吧。

本文其实算不上是一篇讲模板引擎设计的文章,写这篇文章的动力来自于自己使用过一些模板引擎(jQuery Tmpl、jade、ejs、artTemplate以及ThinkPHP自带后端模板引擎)之后的心得,所以可能不会涉及到模板引擎设计的方方面面,更多地是讲模板引擎之间的一些有差异的细节以及我的思考和取舍。

来历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var tempHtml = '<table>' +
' <tr>' +
' <th>Hello</th>' +
' <th>World</th>' +
' <th>!</th>' +
' </tr>' +
' <tr>' +
' <td>' + myData.col1 + '</td>' +
' <td>' + myData.col2 +'</td>' +
' <td>' + (myData.col3 === 'yes'?'!':'?') + '</td>' +
' </tr>' +
' </table>';
document.querySelector('#myDiv').innerHTML = tempHtml;

相信上面的代码对哪怕做过一点点涉及界面开发的都应该很熟悉吧。当我们想把一段结构和一段数据组合起来,再放到页面上时,就会常常面临这样一段复杂的代码。

这段代码相信不用我说你也会觉得它实在有点复杂:要处理结构中字符串本身的拼接,还要注意结构与数据的拼接,处理数据拼接时还要注意运算优先级(尤其在使用?:三元运算符时),还要为了可读性考虑纠结的缩进……

当然,最麻烦的还不是这里,当我们想要对一个数据循环遍历并输出时,居然还要自己去写循环,再一圈一圈地把这些结构拼起来,最后再拼上首尾的结构。而当数据为空时,又要自己去写一个“暂无数据”之类的占位符……

为了解决这些问题,前端模板引擎应运而生。

使用了模板引擎之后,上面的例子可能是类似这样:

1
2
3
4
5
6
7
8
var tmpl = '<table>' +
......
' <td>{%col1}</td>' +
' <td>{%col2}</td>' +
' <td>{{if col3==="yes"}}!{{else}}?{{end if}}' +
......
document.querySelector('#myDiv').innerHTML = template(tmplHtml,myData);

这个例子看起来跟上面也没啥区别呀?字符串拼接一点没见少。没错,但这里有了一个变化,即字符串拼接不再是必须行为(为了可读性而换行拼接另算),因此将这段模板存放到别的地方成为可能,比如直接放入HTML中,使用时用js提取过来即可。这样,代码的复杂度就大大降低了。

职责

上面的例子我们看到了模板引擎带来的好处,可以极大简化代码的编写,自然也可以减少出错的可能性。那具体说来,模板引擎到底应该做些什么事情?套用一下重构中的结构、表现、行为分离的概念,模板引擎在做的事情也无非是几个分离,但对应的对象是结构、数据、逻辑和表现:

  • 结构:最终要展现出来的结构框架,也即“模板”本身,比如上例中的表格。
  • 数据:不用多解释,要展现出来的数据。
  • 逻辑:即业务逻辑代码,不包含可能存在于模板引擎中的逻辑处理。
  • 表现:数据的具体表现,比如日期可以有各种不同的格式。

数据和结构分离

这是一个模板引擎最最基本的功能,即将结构框架和要展现的数据完全独立开来,不让它们直接进行拼接。比如上例中,在使用了模板引擎之后,tmplmyData就已经不存在直接拼接的行为,而是最后使用模板引擎将它们两者组合起来。如果这一点做不到,实在不能称为“模板引擎”。

数据和逻辑分离

所谓数据和逻辑分离,是指数据处理应该和业务逻辑分离开来。业务逻辑应该只专业于数据读写、用户交互等与业务具体相关的事情,至于数据展现上的一些抉择,比如这里是显示“Yes”还是显示“是”,应该交给处理数据的部分来进行。这样的分工可以有效保持数据处理部分的完整性,使业务逻辑和数据处理部分的耦合减少。对这两部分来说,都是既便于复用,也便于后期维护。

分支逻辑

在上例中,我们可以看到在处理的处理中有一个分支逻辑,用于判断col3的值是否为yes,然后生成不同的内容。很明显的是,这个逻辑是因为数据而存在,因而应该被放到数据处理部分,因而上例将它写入了模板中。

反过来,如果我们使用了一个无逻辑模板引擎,即逻辑引擎无力处理这种简单的逻辑,我们就只能将这个判断写到业务逻辑中,先判断col3 === yes,然后根据这个结构在myData中写入一个新的值col3Display。这样的话很不利于维护,比如如果col3改成了col3,首先想到的是需要在模板中更改名,然后需要在模板处理的部分改名,除了这些之处,还需要修改处理col3Display的地方,而这个地方是存在于业务逻辑中的。这种耦合也让业务逻辑和模板分别复用变成几乎不可能的事情。

一般而言,模板中需要支持的逻辑主要为分支逻辑和遍历逻辑(下面会提及)即可,其它复杂的逻辑使用得并不普遍。当前市面上的模板引擎几乎100%包含这些简单逻辑的处理,如上面举的例子,这是非常合理的。

数据遍历

基于上面数据和逻辑分离的思想,除了分支逻辑之外,模板引擎还应该支持一个更为基础的功能,即数据自动遍历。也就是说,当我们传入一个数据数组的时候,模板引擎应该能够对数组中的元素自动遍历,对每个数据生成组合后的html片段再组合起来返回给用户,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var tmplStr = '<li>My Name is {%name%}, I\'m {%age%} years old.</li>';
var arr = [{
name:'TooBug',
age:18
},{
name:'ThreeBug',
age:18.1
}];
var html1 = template(tmplStr,arr);
// 结果:
// <li>My Name is TooBug, I\'m 18 years old.</li>
// <li>My Name is ThreeBug, I\'m 18.1 years old.</li>

如果模板没有自动遍历的功能,那么开发者又只好把用于数据处理的逻辑写入业务代码了:

1
2
3
4
5
6
var tmplHtml = '';
arr.forEach(function(dataItem){
tmplHtml += template(tmplStr,dataItem);
});

早期的artTemplate就不支持自动遍历,要么采用上面的方法在业务代码中做,要么在业务代码中把数组再包装成一个对象,在模板中写遍历的代码。这是一件很纠结的事情。(to 糖饼:特此吐槽。)

表现与逻辑分离

数据的最佳存储方式与数据的最佳表现方式很多时候并不一致。比如时间类型的数据,最佳的存储方式无疑是时间戳(如1377399298),而最佳的表现方式则是我们最为熟悉的年月日的表示方式(如2013年8月25日)。此时就需要在数据展现前进行一些格式化。

数据的格式化不像分支逻辑或者遍历逻辑那么简单,它是一个五花八门的工作。比如就时间而言,就有无数种格式,有时候需要2013年8月25日,有时候需要2013-08-25,有时候需要11:03,有时候需要2 mins ago……更别说其它更多的数据类型了。因此,做数据格式化往往有一些专门的逻辑,简单一点的可能是一个小函数,复杂一点的则可能是类似moment.js之类的库。

于是,如何处理格式化库、业务逻辑、模板引擎的关系就成为一个很重要的问题。

按照我崇尚的逻辑分离的思想,格式化代码不应该出现在业务代码中,最理想的方式是作为一个单独的文件外挂进来,然后由模板引擎直接调用。拿moment.js为例,最理想的方式就是可以直接在模板中写类似这样的代码:

1
2
3
......
<td>moment(pubDate).format('YYYY-MM-DD')</td>
......

事实上,目前大部分模板也是允许这样操作的。但也有部分模板引擎选择了封闭了外部变量的访问,以artTemplate为典型。封闭对外部变量的访问最大的考量就在于阻止在模板中意外修改外部变量。因此,artTemplate在封闭对外部变量访问的同时,提供了另一种方案,即辅助方法机制。用户可以为模板引擎指定一些辅助方法,模板引擎可以访问这些辅助方法。如:

1
template.helper('myFormat', helper.myFormat);

加了这句代码之后就可以在artTemplate的模板中使用moment库来做日期时间的格式化。

就上面这个例子来说,与直接使用外挂js中的格式化方法唯一的区别只是是否有helper这个全局命名空间的区别(artTemplate理想的方式是辅助方法不占用全局命名空间)。但我觉得,“辅助方法”这个概念又增加了不少的学习门槛,想必作者也为此接到了不少咨询。作为模板用户应该知道它所编写的代码会产生怎样的后果,作为模板引擎不应该以易用性和可维护性为代价来保障这个有点牵强的安全性。

在处理格式化方法的问题时,还有一种方案,以jQuery tmpl为代表,在调用tmpl方法时可以传入一个对象,对象中的成员函数都可以直接在模板中使用。这种方案与artTemplate的辅助方法机制有些类似,但jQuery tmpl中的辅助方法只对本次渲染的模板有效。

1
2
3
4
5
$('myTest').tmpl({
myFormat:function(val){
return val;
}
},myData);

这种方案其实有点难评价是好是坏。它的就地编写就地使用的方式用起来还是很方便的,而且方法直接写在渲染语句中,也不会给维护带来特别大的问题。但丢一大堆方法在渲染参数中还是多少会有些不爽。算是一种折衷的方案吧。

易用性

易用性是衡量一个模板引擎是否优秀的重要指标,因为它的目的就是简化前端开发工作,如果引入模板引擎反而使得代码更加复杂难懂,则有些得不偿失,毕竟模板引擎也是有学习和性能成本的。

简化还是繁化

如果一个模板写得人头昏眼花,也许应该回过头来想一下这个模板引擎设计是不是有问题。看一段代码:

1
2
3
4
5
<%if(myData.testArr){%>
<%for(var i=0;i<myData.testArr.length;i++){%>
<input type="checked"<%if(myData.testArr[i].checked){%> checked<%}%>/>
<%}%>
<%}%>

上面这段代码简化自某个项目的真实代码,一眼看去会不会觉得非常繁杂?其实代码很简单,无非是判断一个数组是否存在,然后对数组元素遍历输出checkbox。但看起来就是觉得头疼,各种符号穿插其中,各种符号鱼龙混杂,甚至连编辑器高亮都可能完全失效。

我觉得这个模板引擎的语法设计是有如下问题的:

  • 为降低学习成本使用原生JS语法,这个想法是好的,但这个做法却在客观上增加了代码的复杂性,比如需要用户自己管理临时变量,需要自己管理代码的开始与结束(大括号)。
  • 对JS原生语法的支持有限,比如对于数组的遍历,并不支持使用原生的forEach方法,进一步加大代码复杂性。
  • 没有较好地处理“逻辑插值”的问题。(所谓“逻辑插值”是指标记中的某个部分需要按分支逻辑来处理的情况。)导致了在标签属性部分的代码十分复杂。而更为严重的是,尖括号<>会严重干扰到编辑器的语法解析过程,导致语法高亮出现错误。

来看jade的处理方法:

1
2
3
- if(myData.testArr)
- each dataItem in myData.testArr
input(type="checked",checked=dataItem.checked)

再来看一下上面提到的三个问题:

  • 学习成本:上面的jade代码相信你可以秒懂,既然如此,学习成本便不是问题。
  • 原生语法:jade通过-来区分逻辑与标记,在逻辑代码中,随意使用任何原生js语法。
  • 逻辑插值:jade使用checked=true/false的方式处理布尔属性,避免了大部分逻辑插值的情况,虽然不能完全解决问题,但已经足够好读了。

此外,jade还有同时适用于对象和数组的each语法,遍历起来十分方便。

看完上面的例子,应该不用多说了,模板如果不能简化开发工作,反而使代码变得复杂和难以维护,那么我个人认为宁可不要。

模板标记的选用

在模板标记的选用上,各个引擎可谓是八仙过海各显神通。在这个问题上,也确实没有很多可以拿来比较的东西,但还是有些小点值得一说。

模板标记与页面重构

按照彪叔的观点,模板标记应该选用“看起来像文本”的标记,比如尖括号就应该避免,因为这样可以让重构同学在做页面的时候大概预览到模板标记所在处的效果,而不是被隐藏掉。

不过,这其实是个不折不扣的伪命题。举个例子,<%=var%>这种语法来自ASP,而这种语法在浏览器中并不会被隐藏掉,而是原样显示,因此并不会影响预览效果。至于编码时,则更没区别了,只是敲不同的键而已。既然如此,那么所谓“被隐藏掉”是在哪里呢?答案其实是——DreamWeaver的可视化界面……面对已经被边缘掉的DW,这个问题的的确确可以被彻底忽视了。

当然,如果你坚持使用DW的话,我仍然要说这是个伪命题,因为时至今日,已经很少有人再把模板标记放到正常的文档流中了,因此不管选择什么样的标记,这些不在文档流中的模板都始终不会被看到。

避免与后端语言模板冲突

这倒是一个很值得考虑的问题。在使用jQuery tmpl与ThinkPHP时,由于两者的模板机制均有使用$符号,因此经常导致前端模板标记被后端解析,从而导致页面异常。最终只能通过后端强制指定不解析模板来解决问题。

考虑到这个问题的话,在选用前端模板标记时其实也没办法做到尽善尽美,因为后端模板的标记也不少,很难避免所有可能冲突的情况。但我们可以适当做些考虑,避开一些常用的后端模板。比如我会更倾向于使用{%raw%}{%var%}{%endraw%}的方式(虽然我也没法证明它是一个更好的选择)。

美观

呵呵。美观是个很主观的话题,所以作者觉得哪个好看就用哪个吧……

不过,除了输出变量之外,其它的模板标记其实更多的是一种语法设计,在这方面,确实要考虑美观的事情,上面说过,如果一个模板写出来异常复杂就不好了。

模板标记转义

其实这个问题与模板标记本身关系不大,主要是指如何让模板强制不解析某个模板标记,原样输出它。一般比较理想的方式是对标记进行转义,但转义也有两个层次的含义:

  • 在HTML级别的转义,比如我要输出<%=var%>,则直接在模板中写转义后的&lt;%=var%&gt;
  • 在模板级别的转义,比如我要在jade中输出#{var},则在模板中写入\#{var}即可。

对于这两种转义,我更看重的是第二种,因为一个模板应当要有输出任何字符的能力,包括它用到的模板标记本身。(注意在HTML级别转义的话,输出的结果文本是不一样的。)

模板位置及提取

在文章开头的例子中,我们把模板写到了JS中,随后也做了说明,模板文本是可以写到HTML中的。接下来就来看看将模板放到HTML中的几种主要方法。(还有更多的方法,玉伯有一篇文章中有详述,由于原文被墙,可以到CSDN转载的页面查看:《[浅析]淘宝详情页的BigRender优化的最佳方式》

直接放入文档流中的DOM

将模板直接放到DOM中是一种比较原始的方案,这种方法会在写HTML时直接将模板写进去,然后使用JS动态从父容器中取出进行渲染,最后将生成的HTML字符串再写回父容器中。这种方案的弊端很多:

  • 模板会被渲染出来,这是开发者不希望看到的。而如果使用css来隐藏的话又在项目中添加了没有意义的代码。
  • 模板存在被修改的可能。一旦DOM节点被渲染后就无法保证它不被修改,有可能等我们用JS去取模板时,它已经被改得面目全非甚至都不在文档中了。(外部JS库、UI库、开发框架如jQuery Mobile、浏览器插件都可能修改页面中的DOM。)

后来,在前辈们的探索下,找到了一种比较完美的方式:放到textarea中,这种方案在很大程度上避开了以上问题,只需要处理textarea本身即可。取用的时候只要取textarea的值即可。

放入script标签

将模板标记放到textarea中,虽然使得模板本身避免了被渲染和修改,但textarea本身还是需要隐藏。后来,前辈们又发现一个更NB的方案:将模板放到type属性不为script(以及一堆同义词)的script标签中,这个script是一个标准的DOM元素,但又不会受到其它的影响,浏览器也会直接忽略它,并不渲染。

现在一个常见的模板放到script标签中可能是这样:

1
2
3
4
<script type="text/tmpl" id="myTmpl">
模板放这里
......
</script>

这是一种比较完美的方式,也是现在比较主流的方式。

template元素

鉴于模板引擎的广泛使用,web components组件规范中直接定义了一个template元素,专门用来存放模板。

1
2
3
4
<template id="myTmpl">
模板放这里
......
</template>

目前Chrome和Firefox均已支持这个元素(移动端暂没有浏览器支持)。

模板提取方式

好吧,上面三小节基本是在吹水,其实模板本身放哪里跟模板引擎的关系并不太大,模板引擎只要接受模板字符串进行处理就好了。但说回来,如果模板引擎能辅助用户进行模板的自动提取,则无疑是在易用性上的一个很好的亮点。

目前jQuery tmpl和artTemplate都做了这些方面的努力。在jQuery tmpl中,直接在(经jQuery包裹后的)包含模板的元素上调用.tmpl方法即可,jQuery tmpl会自动去页面中提取出模板字符串并进行处理:

1
$('#myTmpl').tmpl(myData);

而artTemplate允许通过指定包含模板的元素ID的方式自动提取模板:

1
template.render('myTmpl',myData);

性能

任何JS库、框架都逃不开性能这个命题,模板引擎自然也不例外。但模板引擎的性能一直是一个争议不断的话题。

性能是否是伪命题

我们来看一个性能测试的页面http://aui.github.io/artTemplate/test/test-speed.html,其实结果很有意思,看完才知道,原来不同的模板引擎之间的性能差异真的如此巨大!

不过更有意思的是,测试完成后,作者写了一句话:“测试已完成,请不要迷恋速度。”这其实是句很有意思的话,之前也有跟作者聊过性能方面的问题,作者也认为“性能是个伪命题”的观点。原因是这个测试的数据量是100*10000=100W。也即在百万级别的数据渲染时,才有几秒钟的性能差异。

回到我们的日常web开发,单次渲染数据量上千就已经差不多是极限了,此时模板引擎之间的性能差异微乎其微,几乎无法被用户感知。

另外还有一个特别值得注意的地方,模板引擎做的工作只是“模板+数据=包含数据的HTML字符串”,而这些HTML字符串真正显示在页面上还要经过一道DOM操作,而这个DOM操作在数据量大时比模板引擎本身的计算所要消耗的时间要大得多!如果100W条数据显示在DOM上,我觉得即使是Chrome也很难逃脱卡死的命运。

这样看来,模板引擎的性能问题还真可能是个伪命题。那是否可以忽略这一问题呢?我认为也不可取,模板引擎还是要关注性能的,因为你不知道用户会怎么使用你的引擎。比如我就曾经为了偷懒,在一个页面上渲染了6000条数据,此时模板引擎在IE下的表现也可以差到用户可感知的程度。

有关预编译

《高性能JavaScript模板引擎原理解析》一文中,artTemplate作者解释了其高性能来源于针对模板的预编译。即在首次渲染时会根据模板解析的结果生成一个用于拼接数据的函数,后续调用时直接使用这个函数而不需要再次解析模板。这是个非常好的思路,目前也有越来越多的模板引擎采用了预编译的方式,所以我猜测一下,上面的测试如果换用最新版、并引入更多的模板引擎再做一次的话,差异应该不会那么明显。

关于预编译的时机,目前的模板引擎基本都放在浏览器端首次调用时进行预编译。artTemplate是我见到的第一个尝试把模板预编译过程放在构建阶段的模板引擎,它可以在发布前对产品中用到的模板进行预处理,最后发出去的直接是拼接字符串的函数。详细详情可以在这里查看。

这其实是个很不错的思路,尤其是在前端编译的概念有燎原之势的这个时机,只要集成方便,还是很有前途的。不过略遗憾的是,目前artTemplate的预编译工具还没有提供Grunt.js的插件,需要单独编译。

顺序发散一下,如果我们在引入jQuery的项目发布前,扫描一下调用的API,然后把jQuery也在发布前预编译一遍会怎样?其实想象空间还挺大的。

功能

前面已经说过,一个模板引擎最主要的功能就是实现数据和逻辑、结构的分离,简化开发工作。因此一些剩下的功能就被放到了最后,这些并不是模板引擎的主要考量因素,但如果做好,也会成为很不错的亮点。

异常处理

模板引擎都有一套特定的语法,当我们写的模板无法按照这套语法进行解析或者是在处理数据时发生错误时便会产生错误。有一部分模板引擎在产生错误时不会做过多的处理,此时错误被直接抛出,但产生错误的代码行却往往是模板引擎本身,一定程度上会给调试带来困难。

artTemplate在错误处理上有一些尝试,出错时会带上出错的模板本身,这样开发者可以定位到出错的模板代码。而jade则更进一步,直接能定位到出错的位置所在的字符。

当然,这些辅助信息并不一定100%准确,因为很可能错误产生于真正报错之前,这是几乎所有的语言调试过程中面临的一个问题。不过,有了这些辅助信息还是能提升开发者的调试效率的。

包含、mixin复用

在后端模板的世界中,模板之间的相互包含是一件非常普遍的事情,因为后端模板往往是以页为单位来进行输出渲染,而相对来讲,前端模板的应用场景则会显示更碎片化。因此个人觉得前端模板中的模板相互包含、引用并不是一个非常普遍的需求。不过,也有不少的模板为了与在后端(Node.js)的使用方式保持一致,也为前端提供了模板包含功能。

除了使用包含来完成模板复用外,jade还提供了一种叫作mixin的方式来复用模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mixin article(title)
.article
.article-wrapper
h1= title
if block
block
else
p No content provided
+article('Hello world')
+article('Hello world')
p This is my
p Amazing article

会编译成:

1
2
3
4
5
6
7
8
9
10
11
12
13
<div class="article">
<div class="article-wrapper">
<h1>Hello world</h1>
<p>No content provided</p>
</div>
</div>
<div class="article">
<div class="article-wrapper">
<h1>Hello world</h1>
<p>This is my</p>
<p>Amazing article</p>
</div>
</div>

这种方式其实已经非常接近Shadow DOM中对DOM的封装思路。(当然,如果你玩过LESS、SASS之类的CSS预处理语言,也会觉得这个mixin的概念似曾相识。)

个人以为,相对模板包含而言,这种mixin的复用方式其实是对前端模板而言更为友好的方式。

洋洋洒洒地码了这么多,其实仍然没有去讲怎么设计一个模板引擎,只是提出了自己使用模板引擎过程中看到的一些差异和一些值得思考的点。真正设计一个工业级的模板引擎还是颇费工夫的。

至于模板引擎的实现,则完全是编码的硬功夫了,除了编译原理外也没啥好说的了,就不打算说了。(装下B,真实原因是我也没写过……Wahaha……)

2013-12-04:感谢Barret Lee在评论中给出一篇好文《javascript模板引擎原理,几行代码的事儿》,这是一篇真正在讲如何用js实现一个模板引擎的文章,推荐阅读。