前端渲染引擎doT.js解析
背景
前端渲染有很多框架,而且形式和內容在不斷髮生變化。這些演變的背後是設計模式的變化,而歸根到底是功能劃分邏輯的演變:MVC—>MVP—>MVVM(忽略最早混在一起的寫法,那不稱為模式)。近幾年興起的React、Vue、Angular等框架都屬於MVVM模式,能幫我們實現介面渲染、事件繫結、路由分發等複雜功能。但在一些只需完成資料和模板簡單渲染的場合,它們就顯得笨重而且學習成本較高了。
例如,在美團外賣的開發實踐中,前端經常從後端介面取得長串的資料,這些資料擁有相同的樣式模板,前端需要將這些資料在同一個樣式模板上做重複渲染操作。
解決這個問題的模板引擎有很多,doT.js(出自女程式設計師Laura Doktorova之手)是其中非常優秀的一個。下表將doT.js與其他同類引擎做了對比:
可以看出,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程式碼段,從而得到最終檢視。
下面是一些常用語法表示式對照表:
原始碼分析及實現原理
和後端渲染不同,doT.js的渲染完全交由前端來進行,這樣做主要有以下好處:
- 脫離後端渲染語言,不需要依賴後端專案的啟動,從而降低了開發耦合度、提升開發效率;
- View層渲染邏輯全在JavaScript層實現,容易維護和修改;
- 資料通過介面得到,無需考慮後端資料模型變化,只需關心資料格式。
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程式碼,作為可執行語句,通過new Function()建立的新方法返回。
程式碼解析重點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: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( /[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同樣使用了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( /[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語句改造:
改造之前效能:
改造之後效能:
如果讀者對效能對比原始碼比較感興趣可以訪問 https://github.com/chen2009277025/TemplateTest 。
總結
通過對doT.js原始碼的解讀,我們發現:
- doT.js的條件判斷語法標籤不直觀。當開發者在使用過程中條件判斷巢狀過多時,很難找到對應的結束語法符號,開發者需要自己嚴格規範程式碼書寫,否則會給開發和維護帶來困難。
- doT.js限制開發者自定義語法標籤,相比較之下baiduTemplate提供可自定義標籤的功能,而baiduTemplate的效能瓶頸恰好是提供自定義語法標籤的功能。
很多解決我們問題的外掛的程式碼往往簡單明瞭,那些龐大的外掛反而存在負面影響或無用功能。技術領域有一個軟體設計正規化:“約定大於配置”,旨在減少軟體開發人員需要做決定的數量,做到簡單而又不失靈活。在外掛編寫過程中開發者應多注意使用場景和效能的有機結合,使用恰當的語法,儘可能減少開發者的配置,不求迎合各個場景。