前端渲染引擎doT.js解析
背景
前端渲染有很多框架,而且形式和內(nèi)容在不斷發(fā)生變化。這些演變的背后是設(shè)計(jì)模式的變化,而歸根到底是功能劃分邏輯的演變:MVC—>MVP—>MVVM(忽略最早混在一起的寫法,那不稱為模式)。近幾年興起的React、Vue、Angular等框架都屬于MVVM模式,能幫我們實(shí)現(xiàn)界面渲染、事件綁定、路由分發(fā)等復(fù)雜功能。但在一些只需完成數(shù)據(jù)和模板簡單渲染的場合,它們就顯得笨重而且學(xué)習(xí)成本較高了。
例如,在美團(tuán)外賣的開發(fā)實(shí)踐中,前端經(jīng)常從后端接口取得長串的數(shù)據(jù),這些數(shù)據(jù)擁有相同的樣式模板,前端需要將這些數(shù)據(jù)在同一個樣式模板上做重復(fù)渲染操作。
解決這個問題的模板引擎有很多,doT.js(出自女程序員Laura Doktorova之手)是其中非常優(yōu)秀的一個。下表將doT.js與其他同類引擎做了對比:
| 框架 | 大小 | 壓縮版本大小 | 迭代 | 條件表達(dá)式 | 自定義語法 | | ———— | ————- | ———— | ———— | | 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表現(xiàn)突出。而且,它的性能也很優(yōu)秀,本人在Mac Pro上的用Chrome瀏覽器(版本為:56.0.2924.87)上做100條數(shù)據(jù)10000次渲染性能測試,結(jié)果如下:
從上可以看出doT.js更值得推薦,它的主要優(yōu)勢在于: 1. 小巧精簡,源代碼不超過兩百行,6KB的大小,壓縮版只有4KB; 2. 支持表達(dá)式豐富,涵蓋幾乎所有應(yīng)用場景的表達(dá)式語句; 3. 性能優(yōu)秀; 4. 不依賴第三方庫。
本文主要對doT.js的源碼進(jìn)行分析,探究一下這類模板引擎的實(shí)現(xiàn)原理。
如何使用
如果之前用過doT.js,可以跳過此小節(jié),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的設(shè)計(jì)思路:將數(shù)據(jù)注入到預(yù)置的視圖模板中渲染,返回HTML代碼段,從而得到最終視圖。
下面是一些常用語法表達(dá)式對照表:
| 輸出變量 | = | {{= 變量名}} | {{=it.name }} |
| 條件判斷 | if | {{? 條件表達(dá)式}} | {{? i > 3}} |
| 條件轉(zhuǎn)折 | else/else if | {{??}}/{{?? 表達(dá)式}} | {{?? i ==2}} |
| 循環(huán)遍歷 | for | {{~ 循環(huán)變量}} | {{~ it.arr:item}}…{{~}} |
| 執(zhí)行方法 | funcName() | {{= funcName() }} | {{= it.sayHello() }} |
源碼分析及實(shí)現(xiàn)原理
和后端渲染不同,doT.js的渲染完全交由前端來進(jìn)行,這樣做主要有以下好處:
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+='");})// 循環(huán)遍歷正則匹配.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+='";})// 可執(zhí)行代碼匹配.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()返回的函數(shù)的參數(shù)名} catch (e) {/* istanbul ignore else */if (typeof console !== "undefined") console.log("Could not create a template function: " + str);throw e;} ...這段代碼總結(jié)起來就是一句話:用正則表達(dá)式匹配預(yù)置模板中的語法規(guī)則,將其轉(zhuǎn)換、拼接為可執(zhí)行HTML代碼,作為可執(zhí)行語句,通過new Function()創(chuàng)建的新方法返回。
代碼解析重點(diǎn)1:正則替換
正則替換是doT.js的核心設(shè)計(jì)思路,本文不對正則表達(dá)式做擴(kuò)充講解,僅分析doT.js的設(shè)計(jì)思路。先來看一下doT.js中用到的正則:
templateSettings: {evaluate: /\{\{([\s\S]+?(\}?)+)\}\}/g, //表達(dá)式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]+)/, // 自定義參數(shù)conditional: /\{\{\?(\?)?\s*([\s\S]*?)\s*\}\}/g, // 條件判斷iterate: /\{\{~\s*(?:\}\}|([\s\S]+?)\s*\:\s*([\w$]+)\s*(?:\:\s*([\w$]+))?\s*\}\})/g, // 遍歷varname: "it", // 默認(rèn)變量名strip: true,append: true,selfcontained: false,doNotSkipEncoded: false // 是否跳過一些特殊字符 }源碼中將正則定義寫到一起,這樣方便了維護(hù)和管理。在早期版本的doT.js中,處理?xiàng)l件表達(dá)式的方式和tmpl一樣,采用直接替換成可執(zhí)行語句的形式,在最新版本的doT.js中,修改成僅一條正則就可以實(shí)現(xiàn)替換,變得更加簡潔。
doT.js源碼中對模板中語法正則替換的流程如下:
代碼解析重點(diǎn)2:new Function()運(yùn)用
函數(shù)定義時,一般通過Function關(guān)鍵字,并指定一個函數(shù)名,用以調(diào)用。在JavaScript中,函數(shù)也是對象,可以通過函數(shù)對象(Function Object)來創(chuàng)建。正如數(shù)組對象對應(yīng)的類型是Array,日期對象對應(yīng)的類型是Date一樣,如下所示:
var funcName = new Function(p1,p2,...,pn,body);參數(shù)的數(shù)據(jù)類型都是字符串,p1到pn表示所創(chuàng)建函數(shù)的參數(shù)名稱列表,body表示所創(chuàng)建函數(shù)的函數(shù)體語句,funcName就是所創(chuàng)建函數(shù)的名稱(可以不指定任何參數(shù)創(chuàng)建一個匿名函數(shù))。
下面的定義是等價的。
例如:
// 一般函數(shù)定義方式 function func1(a,b){return a+b; } // 參數(shù)是一個字符串通過逗號分隔 var func2 = new Function('a,b','return a+b'); // 參數(shù)是多個字符串 var func3 = new Function('a','b','return a+b'); // 一樣的調(diào)用方式 console.log(func1(1,2)); console.log(func2(2,3)); console.log(func3(1,3)); // 輸出 3 // func1 5 // func2 4 // func3從上面的代碼中可以看出,Function的最后一個參數(shù),被轉(zhuǎn)換為可執(zhí)行代碼,類似eval的功能。eval執(zhí)行時存在瀏覽器性能下降、調(diào)試?yán)щy以及可能引發(fā)XSS(跨站)攻擊等問題,因此不推薦使用eval執(zhí)行字符串代碼,new Function()恰好解決了這個問題。回過頭來看doT代碼中的”new Function(c.varname, str)“,就不難理解varname是傳入可執(zhí)行字符串str的變量。
具體關(guān)于new Fcuntion的定義和用法,詳細(xì)請閱讀Function詳細(xì)介紹。
性能之因
讀到這里可能會產(chǎn)生一個疑問:doT.js的性能為什么在眾多引擎如此突出?通過閱讀其他引擎源代碼,發(fā)現(xiàn)了它們核心代碼段中都存在這樣那樣的問題。
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 JavaScriptjQuery.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 ) {//省略部分模板替換語句,若要閱讀全部代碼請?jiān)L問:https://github.com/BorisMoore/jquery-tmpl}) +"');}return __;");}在上面的代碼中看到,jQuery-teml同樣使用了new Function()的方式編譯模板,但是在性能對比中jQuery-teml性能相比doT.js相差甚遠(yuǎn),出現(xiàn)性能瓶頸的關(guān)鍵在于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表達(dá)式,為了避免多次從datas中取變量而使用了with語句。這看起來似乎提升了效率,但卻產(chǎn)生了一個性能問題:在JavaScript中執(zhí)行方法時會產(chǎn)生一個執(zhí)行上下文,這個執(zhí)行上下文持有該方法作用域鏈,主要用于標(biāo)識符解析。當(dāng)代碼流執(zhí)行到一個with表達(dá)式時,運(yùn)行期上下文的作用域鏈被臨時改變了,一個新的可變對象將被創(chuàng)建,它包含指定對象的所有屬性。此對象被插入到作用域鏈的最前端,意味著現(xiàn)在函數(shù)的所有局部變量都被推入第二個作用域鏈對象中,這樣訪問datas的屬性非常快,但是訪問局部變量的速度卻變慢了,所以訪問代價更高了,如下圖所示。
這個插件在GitHub上面介紹時,作者Boris Moore著重強(qiáng)調(diào)兩點(diǎn)設(shè)計(jì)思路:
不改變原來設(shè)計(jì)思路基礎(chǔ)之上,嘗試對源代碼進(jìn)行性能提升。
先保留提升前性能作為對比:
首先來我們做第一次性能提升,移除源碼中with語句。
第一次提升后:
接下來第二部提升,落實(shí)Boris Moore設(shè)計(jì)理念中的模板緩存:
優(yōu)化后的這一部分代碼段被我們修改成了:
function buildTmplFn( markup ) {if(!compledStr){// Convert the template into pure JavaScriptcompledStr = 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選擇先將模板編譯結(jié)果返回給開發(fā)者,這樣如要重復(fù)多次使用同一模板進(jìn)行渲染便不會反復(fù)編譯。
僅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;}; })();閱讀這段代碼會驚奇的發(fā)現(xiàn),它更像是baiduTemplate精簡版。相比baiduTemplate而言,它移除了baiduTemplate的自定義語法標(biāo)簽的功能,使得代碼更加精簡,也避開了替換用戶語法標(biāo)簽而帶來的性能消耗。對于doT.js來說,性能問題的關(guān)鍵是with語句。
綜合上述我對tmpl的源碼進(jìn)行移除with語句改造:
改造之前性能:
改造之后性能:
如果讀者對性能對比源碼比較感興趣可以訪問 https://github.com/chen2009277025/TemplateTest 。
總結(jié)
通過對doT.js源碼的解讀,我們發(fā)現(xiàn):
很多解決我們問題的插件的代碼往往簡單明了,那些龐大的插件反而存在負(fù)面影響或無用功能。技術(shù)領(lǐng)域有一個軟件設(shè)計(jì)范式:“約定大于配置”,旨在減少軟件開發(fā)人員需要做決定的數(shù)量,做到簡單而又不失靈活。在插件編寫過程中開發(fā)者應(yīng)多注意使用場景和性能的有機(jī)結(jié)合,使用恰當(dāng)?shù)恼Z法,盡可能減少開發(fā)者的配置,不求迎合各個場景。
作者簡介
- 建輝,美團(tuán)外賣高級前端研發(fā)工程師,2015年加入美團(tuán)外賣事業(yè)部。目前在前端業(yè)務(wù)增長組,主要負(fù)責(zé)運(yùn)營平臺搭建,主導(dǎo)運(yùn)營活動業(yè)務(wù)。
總結(jié)
以上是生活随笔為你收集整理的前端渲染引擎doT.js解析的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Spring Boot中的事务管理
- 下一篇: 闲鱼账号被封怎么办?解封看这里!