前端渲染引擎doT.js解析
背景
前端渲染有很多框架,而且形式和內容在不斷發生變化。這些演變的背后是設計模式的變化,而歸根到底是功能劃分邏輯的演變: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更值得推薦,它的主要優勢在于:
- 小巧精簡,源代碼不超過兩百行,6KB的大小,壓縮版只有4KB;
- 支持表達式豐富,涵蓋幾乎所有應用場景的表達式語句;
- 性能優秀;
- 不依賴第三方庫。
本文主要對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的渲染完全交由前端來進行,這樣做主要有以下好處:
- 脫離后端渲染語言,不需要依賴后端項目的啟動,從而降低了開發耦合度、提升開發效率;
- View層渲染邏輯全在JavaScript層實現,容易維護和修改;
- 數據通過接口得到,無需考慮后端數據模型變化,只需關心數據格式。
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;
}
...</code></pre>
這段代碼總結起來就是一句話:用正則表達式匹配預置模板中的語法規則,將其轉換、拼接為可執行HTML代碼,作為可執行語句,通過new Function()創建的新方法返回。
代碼解析重點1:正則替換
正則替換是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源碼中對模板中語法正則替換的流程如下:

代碼解析重點2:new Function()運用
函數定義時,一般通過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的定義和用法,詳細請閱讀 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( /[\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 __;"
);
}</code></pre>
在上面的代碼中看到,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著重強調兩點設計思路:
- 模板緩存,在模板重復使用時,直接使用內存中緩存的模板。在本文作者看來,這是一個雞肋的功能,在實際使用中,無論是直接寫在String中的模板還是從Dom獲取的模板都會以變量的形式存放在內存中,變量使用得當,在頁面整個生命周期內都能取到這個模板。通過源碼分析之后發現jQuery-tmpl的模板緩存并不是對模板編譯結果進行緩存,并且會造成多次執行渲染時產生多次編譯,再加上代碼with性能消耗,嚴重拖慢整個渲染過程。
- 模板標記,可以從緩存模板中取出對應子節點。這是一個不錯的設計思路,可以實現數據改變只重新渲染局部界面的功能。但是我覺得:模板將渲染結果交給開發者,并渲染到界面指定位置之后,模板引擎的工作就應該結束了,剩下的對節點操作應該靈活的掌握在開發者手上。
不改變原來設計思路基礎之上,嘗試對源代碼進行性能提升。
先保留提升前性能作為對比:

首先來我們做第一次性能提升,移除源碼中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選擇先將模板編譯結果返回給開發者,這樣如要重復多次使用同一模板進行渲染便不會反復編譯。
僅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(/[\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語句改造:
改造之前性能:

改造之后性能:

如果讀者對性能對比源碼比較感興趣可以訪問 https://github.com/chen2009277025/TemplateTest 。
總結
通過對doT.js源碼的解讀,我們發現:
- doT.js的條件判斷語法標簽不直觀。當開發者在使用過程中條件判斷嵌套過多時,很難找到對應的結束語法符號,開發者需要自己嚴格規范代碼書寫,否則會給開發和維護帶來困難。
- doT.js限制開發者自定義語法標簽,相比較之下baiduTemplate提供可自定義標簽的功能,而baiduTemplate的性能瓶頸恰好是提供自定義語法標簽的功能。
很多解決我們問題的插件的代碼往往簡單明了,那些龐大的插件反而存在負面影響或無用功能。技術領域有一個軟件設計范式:“約定大于配置”,旨在減少軟件開發人員需要做決定的數量,做到簡單而又不失靈活。在插件編寫過程中開發者應多注意使用場景和性能的有機結合,使用恰當的語法,盡可能減少開發者的配置,不求迎合各個場景。
來自:http://tech.meituan.com/dot.html