背景
后端渲染的框架有很多,但是方法和内容在不断变化。 这种演变的背后是设计模式的变化,归根到底是功能定义逻辑的演变:MVC->MVP->MVVM(忽略最早的混合写法,不叫模式)。 近年来流行的React、Vue、Angular等框架都属于MVVM模型,可以帮助我们实现界面渲染、事件绑定、路由分发等复杂功能。 但在某些只需要完成数据和模板的简单渲染的场合,就显得比较繁琐,学习成本较高。
例如,在美团订餐的开发实践中,后端经常从前端接口获取长串数据。 这些数据具有相同的样式模板,后端需要在相同的样式模板上对这些数据进行重复的渲染操作。
有很多模板引擎可以解决这个问题,doT.js(由女程序员 Laura Doktorova 创建)是最好的之一。 下表将 doT.js 与其他类似引擎进行了比较:
|框架|大小|压缩版大小|迭代次数|条件表达式|自定义句型||————|————|————|————||doT.js|6KB|4KB |✓ |✓|✓||小胡子|18.9KB|9.3KB|✓|✗|✓||车把|512KB|62.3KB|✓|✓|✓||artTemplate (腾讯)|-|5.2KB|✓|✓ |✓ ||百度模板(百度)|9.45KB|6KB|✓|✓|✓||jQuery-tmpl|18.6KB|5.98KB|✓|✓|✓|
可以看出,doT.js 表现出色。 而且,它的性能也非常出色。 我在MacPro上使用Chrome浏览器(版本:56.0.2924.87)做了100条数据、10000次渲染性能测试。 结果如下:
性能测试
从上面可以看出,doT.js 更值得推荐。 其主要优点是: 1、紧凑精简js渲染html,源代码不超过两百行,大小6KB,压缩版只有4KB; 2.支持丰富的表达方式,包含几乎所有应用场景的表达语句; 3、性能优良; 4.不依赖第三方库。
本文主要分析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}}
条件判断
如果
{{?条件表达式}}
{{?i>3}}
条件转移
否则/elseif
{{??i==2}}
依次通过
为了
{{~循环变量}}
{{~it.arr:项目}}…{{~}}
实施方式
函数名()
{{=函数名称()}}
{{=它。 问好()}}
源码分析及实现原理
与前端渲染不同的是,doT.js 的渲染完全交给了后端。 这样做有以下主要好处:
与前端渲染语言分离,不需要依赖前端项目的启动,从而增加开发耦合,提高开发效率; View层渲染逻辑全部在JavaScript层实现,易于维护和更改; 通过socket获取数据,不需要考虑前端数据模型的改变,只需要关心数据格式。
doT.js源码核心:
...
// 去掉所有制表符、空格、换行
str = ("var out='" + (c.strip ? str.replace(/(^|r|n)t* +| +t*(r|n|$)/g," ")
.replace(/r|n|t|/*[sS]*?*//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代码,并通过newFunction()创建的新方法将其作为可执行句子返回。
代码分析重点1:定期替换
定期替换是doT.js的核心设计思想。 本文不对正则表达式进行展开,仅分析doT.js的设计思想。 我们首先看一下doT.js中使用的常规规则:
templateSettings: {
evaluate: /{{([sS]+?(}?)+)}}/g, //表达式
interpolate: /{{=([sS]+?)}}/g, // 插入的变量
encode: /{{!([sS]+?)}}/g, // 在这里{{!不是用来做判断,而是对里面的代码做编码
use: /{{#([sS]+?)}}/g,
useParams: /(^|[^w$])def(?:.|[['"])([w$.]+)(?:['"]])?s*:s*([w$.]+|"[^"]+"|'[^']+'|{[^}]+})/g,
define: /{{##s*([w.$]+)s*(:|=)([sS]+?)#}}/g,// 自定义模式
defineParams:/^s*([w$]+):([sS]+)/, // 自定义参数
conditional: /{{?(?)?s*([sS]*?)s*}}/g, // 条件判断
iterate: /{{~s*(?:}}|([sS]+?)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源码中,模板中正则替换句型的流程如下:
渲染过程
代码分析重点2:newFunction()应用
定义函数时,通常使用Function关键字并指定调用的函数名。 在JavaScript中,函数也是对象,可以通过函数对象(FunctionObject)来创建函数。 正如链表对象对应的类型是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来执行字符串代码。 newFunction() 正好解决了这个问题。 回顾一下doT代码中的“newFunction(c.varname,str)”,不难理解varname就是可执行字符串str中传入的变量。
newFcuntion的详细定义和使用请阅读Function的详细介绍。
性能原因
读完本文,你可能会有疑问:为什么doT.js在众多引擎中表现如此出色? 通过阅读其他引擎的源码,我发现它们的核心代码段都存在各种问题。
jQuery-tmpl
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( /[rtn]/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同样采用了newFunction()的形式来编译模板,而在性能对比中,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表达式。 为了防止多次从数据中取出变量,使用了with语句。 这看似提高了效率,但却产生了性能问题:当 JavaScript 中执行方法时,会形成执行上下文。 该执行上下文保存了方法的作用域链,主要用于标识符解析。 当代码流到达 with 表达式时,运行时上下文的作用域链会暂时更改,并创建一个新的可变对象,其中包含指定对象的所有属性。 该对象被插入到作用域链的末尾,这意味着函数的所有局部变量现在都被拖到第二个作用域链对象中。 这样,访问数据的属性非常快,但是访问局部变量就变得很慢。 ,所以接入成本较高,如右图所示。
和
当这个插件在 GitHub 上推出时,作者 Boris Moore 主要关注两个设计思路:
模板缓存,重复使用模板时,直接使用显存中缓存的模板。 在本文作者看来,这是一个无用的功能。 在实际使用中,无论是直接用String编写的模板,还是从Dom中获取的模板,都会以变量的形式存储在显存中。 如果变量使用得当,它将出现在页面上。 该模板在整个生命周期内可用。 经过源码分析,发现jQuery-tmpl的模板缓存并没有缓存模板编译结果,在多次执行渲染时会导致多次编译。 再加上代码的性能消耗,严重拖慢了整个渲染过程。 Template标签,可以从缓存模板中检索对应的子节点。 这是一个很好的设计思想js渲染html,可以实现数据变化时只重新渲染部分界面的功能。 而且我认为:模板将渲染结果交给开发者并渲染到界面指定位置后,模板引擎的工作就应该结束了,剩下的节点操作就应该由开发者灵活控制了。
在不改变原有设计思路的情况下,尽量提高源代码的性能。
我们保留改进前的性能进行比较:
性能改进
首先我们来做第一个性能提升,把源码中的with字样去掉。
第一次提升后:
绩效提升2
拿第二部分来改进和实现BorisMoore设计理念中的模板缓存:
绩效提升3
这部分优化代码段被我们修改为:
function buildTmplFn( markup ) {
if(!compledStr){
// Convert the template into pure JavaScript
compledStr = jQuery.trim(markup)
.replace( /([\'])/g, "\$1" )
.replace( /[rtn]/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选择先将模板编译结果返回给开发者,这样如果多次使用同一个模板进行渲染,就不会重复编译。 。
仅 25 行的模板:tmpl
(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(/[rtn]/g, " ")
.split("<%").join("t")
.replace(/((^|%>)[^t]*)'/g, "$1r")
.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语句去掉:
改造前性能:
tmpl 性能改进
改造后性能:
tmpl 性能提高 2
如果读者对性能对比源码感兴趣,可以访问。
总结
通过分析doT.js源码,我们发现:
doT.js的条件判断语句标签并不直观。 当开发者在使用过程中嵌套过多的条件判断时,很难找到对应的结束句符号。 开发者需要严格规范代码编写,否则会造成开发和维护的困难。 doT.js 限制开发者自定义句子标签。 相比之下,baiduTemplate提供了可定制标签的功能。 baiduTemplate的性能困境恰恰在于提供自定义句子标签的功能。
很多解决我们问题的插件的代码往往简单明了,但是这些庞大的插件都有副作用或者无用的功能。 技术领域有一种软件设计范式:“约定小于配置”,它减少了软件开发人员需要做出的决策数量,变得简单灵活。 在插件编译过程中,开发者应该更加注重使用场景和性能的有机结合,使用合适的句型,尽可能减少开发者的配置,不要追求取悦每个场景。
关于作者