前端渲染有很多框架,而且形式和内容在不断发生变化。这些演变的背后是设计模式的变化,而归根到底是功能划分逻辑的演变:MVC—>MVP—>MVVM(忽略最早混在一起的写法,那不称为模式)。近几年兴起的 React、vue、Angular 等框架都属于 MVVM 模式,能帮我们实现界面渲染、事件绑定、路由分发等复杂功能。但在一些只需完成数据和模板简单渲染的场合,它们就显得笨重而且学习成本较高了。
例如,在美团外卖的开发实践中,前端经常从后端接口取得长串的数据,这些数据拥有相同的样式模板,前端需要将这些数据在同一个样式模板上做重复渲染操作。
解决这个问题的模板引擎有很多,doT.js(出自女程序员 Laura Doktorova 之手)是其中非常优秀的一个。下表将 doT.js 与其他同类引擎做了对比:
框架 | 大小 | 压缩版本大小 | 迭代 | 条件表达式 | 自定义语法 |
---|---|---|---|---|---|
doT.js | 6KB | 4KB | √ | √ | √ |
mustache | 18.9 KB | 9.3 KB | √ | × | √ |
Handlebars | 512KB | 62.3KB | √ | √ | √ |
artTemplate(腾讯) | - | 5.2KB | √ | √ | √ |
BaiduTemplate(百度) | 9.45KB | 6KB | √ | √ | √ |
jQuery-tmpl | 18.6KB | 5.98KB | √ | √ | √ |
可以看出,doT.js 表现突出。而且,它的性能也很优秀,本人在 Mac Pro 上的用 Chrome 浏览器(版本为:56.0.2924.87)上做 100 条数据 10000 次渲染性能测试,结果如下:
从上可以看出 doT.js 更值得推荐,它的主要优势在于:
本文主要对 doT.js 的源码进行分析,探究一下这类模板引擎的实现原理。
如果之前用过 doT.js,可以跳过此小节,doT.js 使用示例如下:
- <script type="text/html" id="tpl">
- < div > <a > name: {
- { = it.name
- }
- } < /a>
- <p>age:{{= it.age}}</p > <p > hello: {
- { = it.sayHello()
- }
- } < /p>
- <select>
- {{~ it.arr:item}}
- <option {{?item.id == it.stringParams2}}selected{{?}} value="{{=item.id}}">
- {{=item.text}}
- </option > {
- {~
- }
- } < /select>
- </div >
- </script>
- <script>
- $("#app").html(doT.template($("#tpl").html())({
- name: 'stringParams1',
- stringParams1: 'stringParams1_value',
- stringParams2: 1,
- arr: [{
- id: 0,
- text: 'val1'
- },
- {
- id: 1,
- text: 'val2'
- }],
- sayHello: function() {
- return this[this.name]
- }
- }));
- </script>
可以看出 doT.js 的设计思路:将数据注入到预置的视图模板中渲染,返回 HTML 代码段,从而得到最终视图。
下面是一些常用语法表达式对照表:
项目 | JavaScript 语法 | 对应语法 | 案例 |
---|---|---|---|
输出变量 | = | {{= 变量名}} | {{=it.name}} |
条件判断 | if | {{? 条件表达式}} | {{? i> 3}} |
条件转折 | else/else if | {{??}}/{{?? 表达式}} | {{?? i ==2}} |
循环遍历 | for | {{~ 循环变量}} | {{~ it.arr:item}}...{{~}} |
执行方法 | funcName() | {{= funcName() }} | {{= it.sayHello() }} |
和后端渲染不同,doT.js 的渲染完全交由前端来进行,这样做主要有以下好处:
doT.js 源码核心:
- ...
- // 去掉所有制表符、空格、换行
- str = ("var out='" + (c.strip ? str.replace(/(^|\r|\n)\t* +| +\t*(\r|\n|$)/g," ")
- .replace(/\r|\n|\t|\/\*[\s\S]*?\*\//g,""): str)
- .replace(/'|\\/g, "\\$&")
- .replace(c.interpolate || skip, function(m, code) {
- return cse.start + unescape(code,c.canReturnNull) + cse.end;
- })
- .replace(c.encode || skip, function(m, code) {
- needhtmlencode = true;
- return cse.startencode + unescape(code,c.canReturnNull) + cse.end;
- })
- // 条件判断正则匹配,包括if和else判断
- .replace(c.conditional || skip, function(m, elsecase, code) {
- return elsecase ?
- (code ? "';}else if(" + unescape(code,c.canReturnNull) + "){out+='" : "';}else{out+='") :
- (code ? "';if(" + unescape(code,c.canReturnNull) + "){out+='" : "';}out+='");
- })
- // 循环遍历正则匹配
- .replace(c.iterate || skip, function(m, iterate, vname, iname) {
- if (!iterate) return "';} } out+='";
- sid+=1; indv=iname || "i"+sid; iterate=unescape(iterate);
- return "';var arr"+sid+"="+iterate+";if(arr"+sid+"){var "+vname+","+indv+"=-1,l"+sid+"=arr"+sid+".length-1;while("+indv+"<l"+sid+"){"
- +vname+"=arr"+sid+"["+indv+"+=1];out+='";
- })
- // 可执行代码匹配
- .replace(c.evaluate || skip, function(m, code) {
- return "';" + unescape(code,c.canReturnNull) + "out+='";
- })
- + "';return out;")
- ...
- try {
- return new Function(c.varname, str);//c.varname 定义的是new Function()返回的函数的参数名
- } catch (e) {
- /* istanbul ignore else */
- if (typeof console !== "undefined") console.log("Could not create a template function: " + str);
- throw e;
- }
- ...
这段代码总结起来就是一句话:用正则表达式匹配预置模板中的语法规则,将其转换、拼接为可执行 HTML 代码,作为可执行语句,通过 new Function() 创建的新方法返回。
正则替换是 doT.js 的核心设计思路,本文不对正则表达式做扩充讲解,仅分析 doT.js 的设计思路。先来看一下 doT.js 中用到的正则:
- templateSettings: {
- evaluate: /\{\{([\s\S]+?(\}?)+)\}\}/g,
- //表达式
- interpolate: /\{\{=([\s\S]+?)\}\}/g,
- // 插入的变量
- encode: /\{\{!([\s\S]+?)\}\}/g,
- // 在这里{{!不是用来做判断,而是对里面的代码做编码
- use: /\{\{#([\s\S]+?)\}\}/g,
- useParams: /(^|[^\w$])def(?:\.|\[[\'\"])([\w$\.]+)(?:[\'\"]\])?\s*\:\s*([\w$\.]+|\"[^\"]+\"|\'[^\']+\'|\{[^\}]+\})/g,
- define: /\{\{##\s*([\w\.$]+)\s*(\:|=)([\s\S]+?)#\}\}/g,
- // 自定义模式
- defineParams: /^\s*([\w$]+):([\s\S]+)/,
- // 自定义参数
- conditional: /\{\{\?(\?)?\s*([\s\S]*?)\s*\}\}/g,
- // 条件判断
- iterate: /\{\{~\s*(?:\}\}|([\s\S]+?)\s*\:\s*([\w$]+)\s*(?:\:\s*([\w$]+))?\s*\}\})/g,
- // 遍历
- varname: "it",
- // 默认变量名
- strip: true,
- append: true,
- selfcontained: false,
- doNotSkipEncoded: false // 是否跳过一些特殊字符
- }
源码中将正则定义写到一起,这样方便了维护和管理。在早期版本的 doT.js 中,处理条件表达式的方式和 tmpl 一样,采用直接替换成可执行语句的形式,在最新版本的 doT.js 中,修改成仅一条正则就可以实现替换,变得更加简洁。
doT.js 源码中对模板中语法正则替换的流程如下:
函数定义时,一般通过 Function 关键字,并指定一个函数名,用以调用。在 JavaScript 中,函数也是对象,可以通过函数对象(Function Object)来创建。正如数组对象对应的类型是 Array,日期对象对应的类型是 Date 一样,如下所示:
- var funcName = new Function(p1,p2,...,pn,body);
参数的数据类型都是字符串,p1 到 pn 表示所创建函数的参数名称列表,body 表示所创建函数的函数体语句,funcName 就是所创建函数的名称(可以不指定任何参数创建一个匿名函数)。
下面的定义是等价的。
例如:
- // 一般函数定义方式
- function func1(a, b) {
- return a + b;
- }
- // 参数是一个字符串通过逗号分隔
- var func2 = new Function('a,b', 'return a+b');
- // 参数是多个字符串
- var func3 = new Function('a', 'b', 'return a+b');
- // 一样的调用方式
- console.log(func1(1, 2));
- console.log(func2(2, 3));
- console.log(func3(1, 3));
- // 输出
- 3 // func1
- 5 // func2
- 4 // func3
从上面的代码中可以看出,Function 的最后一个参数,被转换为可执行代码,类似 eval 的功能。eval 执行时存在浏览器性能下降、调试困难以及可能引发 XSS(跨站)攻击等问题,因此不推荐使用 eval 执行字符串代码,new Function() 恰好解决了这个问题。回过头来看 doT 代码中的"new Function(c.varname, str)",就不难理解 varname 是传入可执行字符串 str 的变量。
具体关于 new Fcuntion 的定义和用法,详细请阅读 。
读到这里可能会产生一个疑问:doT.js 的性能为什么在众多引擎如此突出?通过阅读其他引擎源代码,发现了它们核心代码段中都存在这样那样的问题。
- function buildTmplFn( markup ) {
- return new Function("jQuery","$item",
- // Use the variable __ to hold a string array while building the compiled template. (See https://github.com/jquery/jquery-tmpl/issues#issue/10).
- "var $=jQuery,call,__=[],$data=$item.data;" +
- // Introduce the data as local variables using with(){}
- "with($data){__.push('" +
- // Convert the template into pure JavaScript
- jQuery.trim(markup)
- .replace( /([\\'])/g, "\\$1" )
- .replace( /[\r\t\n]/g, " " )
- .replace( /\$\{([^\}]*)\}/g, "{{= $1}}" )
- .replace( /\{\{(\/?)(\w+|.)(?:\(((?:[^\}]|\}(?!\}))*?)?\))?(?:\s+(.*?)?)?(\(((?:[^\}]|\}(?!\}))*?)\))?\s*\}\}/g,
- function( all, slash, type, fnargs, target, parens, args ) {
- //省略部分模板替换语句,若要阅读全部代码请访问:https://github.com/BorisMoore/jquery-tmpl
- }) +
- "');}return __;"
- );
- }
在上面的代码中看到,jQuery-teml 同样使用了 new Function() 的方式编译模板,但是在性能对比中 jQuery-teml 性能相比 doT.js 相差甚远,出现性能瓶颈的关键在于 with 语句的使用。
with 语句为什么对性能有这么大的影响?我们来看下面的代码:
- var datas = {
- persons: ['李明', '小红', '赵四', '王五', '张三', '孙行者', '马婆子'],
- gifts: ['平民', '巫师', '狼', '猎人', '先知']
- };
- function go() {
- with(datas) {
- var personIndex = 0,
- giftIndex = 0,
- i = 100000;
- while (i) {
- personIndex = Math.floor(Math.random() * persons.length);
- giftIndex = Math.floor(Math.random() * gifts.length) console.log(persons[personIndex] + '得到了新的身份:' + gifts[giftIndex]);
- i--;
- }
- }
- }
上面代码中使用了一个 with 表达式,为了避免多次从 datas 中取变量而使用了 with 语句。这看起来似乎提升了效率,但却产生了一个性能问题:在 JavaScript 中执行方法时会产生一个执行上下文,这个执行上下文持有该方法作用域链,主要用于标识符解析。当代码流执行到一个 with 表达式时,运行期上下文的作用域链被临时改变了,一个新的可变对象将被创建,它包含指定对象的所有属性。此对象被插入到作用域链的最前端,意味着现在函数的所有局部变量都被推入第二个作用域链对象中,这样访问 datas 的属性非常快,但是访问局部变量的速度却变慢了,所以访问代价更高了,如下图所示。
这个插件在 GitHub 上面介绍时,作者 Boris Moore 着重强调两点设计思路:
不改变原来设计思路基础之上,尝试对源代码进行性能提升。
先保留提升前性能作为对比:
首先来我们做第一次性能提升,移除源码中 with 语句。
第一次提升后:
接下来第二部提升,落实 Boris Moore 设计理念中的模板缓存:
优化后的这一部分代码段被我们修改成了:
- function buildTmplFn( markup ) {
- if(!compledStr){
- // Convert the template into pure JavaScript
- compledStr = jQuery.trim(markup)
- .replace( /([\\'])/g, "\\$1" )
- .replace( /[\r\t\n]/g, " " )
- .replace( /\$\{([^\}]*)\}/g, "{{= $1}}" )
- .replace( /\{\{(\/?)(\w+|.)(?:\(((?:[^\}]|\}(?!\}))*?)?\))?(?:\s+(.*?)?)?(\(((?:[^\}]|\}(?!\}))*?)\))?\s*\}\}/g,
- //省略部分模板替换语句
- }
- return new Function("jQuery","$item",
- // Use the variable __ to hold a string array while building the compiled template. (See https://github.com/jquery/jquery-tmpl/issues#issue/10).
- "var $=jQuery,call,__=[],$data=$item.data;" +
- // Introduce the data as local variables using with(){}
- "__.push('" + compledStr +
- "');return __;"
- )
- }
在 doT.js 源码中没有用到 with 这类消耗性能的语句,与此同时 doT.js 选择先将模板编译结果返回给开发者,这样如要重复多次使用同一模板进行渲染便不会反复编译。
- (function(){
- var cache = {};
- this.tmpl = function (str, data){
- var fn = !/\W/.test(str) ?
- cache[str] = cache[str] ||
- tmpl(document.getElementById(str).innerHTML) :
- new Function("obj",
- "var p=[],print=function(){p.push.apply(p,arguments);};" +
- "with(obj){p.push('" +
- str
- .replace(/[\r\t\n]/g, " ")
- .split("<%").join("\t")
- .replace(/((^|%>)[^\t]*)'/g, "$1\r")
- .replace(/\t=(.*?)%>/g, "',$1,'")
- .split("\t").join("');")
- .split("%>").join("p.push('")
- .split("\r").join("\\'")
- + "');}return p.join('');");
- return data ? fn( data ) : fn;
- };
- })();
阅读这段代码会惊奇的发现,它更像是 baiduTemplate 精简版。相比 baiduTemplate 而言,它移除了 baiduTemplate 的自定义语法标签的功能,使得代码更加精简,也避开了替换用户语法标签而带来的性能消耗。对于 doT.js 来说,性能问题的关键是 with 语句。
综合上述我对 tmpl 的源码进行移除 with 语句改造:
改造之前性能:
改造之后性能:
如果读者对性能对比源码比较感兴趣可以访问 。
通过对 doT.js 源码的解读,我们发现:
很多解决我们问题的插件的代码往往简单明了,那些庞大的插件反而存在负面影响或无用功能。技术领域有一个软件设计范式:"约定大于配置",旨在减少软件开发人员需要做决定的数量,做到简单而又不失灵活。在插件编写过程中开发者应多注意使用场景和性能的有机结合,使用恰当的语法,尽可能减少开发者的配置,不求迎合各个场景。
来源: http://www.tuicool.com/articles/JFJBju2