前端渲染有很多框架,而且形式和內(nèi)容在不斷發(fā)生變化。這些演變的背后是設(shè)計(jì)模式的變化,而歸根到底是功能劃分邏輯的演變:MVC—>MVP—>MVVM(忽略早混在一起的寫(xiě)法,那不稱(chēng)為模式)。近幾年興起的React、Vue、Angular等框架都屬于MVVM模式,能幫我們實(shí)現(xiàn)界面渲染、事件綁定、路由分發(fā)等復(fù)雜功能。但在一些只需完成數(shù)據(jù)和模板簡(jiǎn)單渲染的場(chǎng)合,它們就顯得笨重而且學(xué)習(xí)成本較高了。
例如,在美團(tuán)外賣(mài)的開(kāi)發(fā)實(shí)踐中,前端經(jīng)常從后端接口取得長(zhǎng)串的數(shù)據(jù),這些數(shù)據(jù)擁有相同的樣式模板,前端需要將這些數(shù)據(jù)在同一個(gè)樣式模板上做重復(fù)渲染操作。
解決這個(gè)問(wèn)題的模板引擎有很多,doT.js(出自女程序員Laura Doktorova之手)是其中非常的一個(gè)。下表將doT.js與其他同類(lèi)引擎做了對(duì)比:
可以看出,doT.js表現(xiàn)突出。而且,它的性能也很,本人在Mac Pro上的用Chrome瀏覽器(版本為:56.0.2924.87)上做100條數(shù)據(jù)10000次渲染性能測(cè)試,結(jié)果如下:
從上可以看出doT.js更值得推薦,它的主要優(yōu)勢(shì)在于:
本文主要對(duì)doT.js的源碼進(jìn)行分析,探究一下這類(lèi)模板引擎的實(shí)現(xiàn)原理。
如果之前用過(guò)doT.js,可以跳過(guò)此小節(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代碼段,從而得到終視圖。
下面是一些常用語(yǔ)法表達(dá)式對(duì)照表:
和后端渲染不同,doT.js的渲染完全交由前端來(lái)進(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é)起來(lái)就是一句話:用正則表達(dá)式匹配預(yù)置模板中的語(yǔ)法規(guī)則,將其轉(zhuǎn)換、拼接為可執(zhí)行HTML代碼,作為可執(zhí)行語(yǔ)句,通過(guò)new Function()創(chuàng)建的新方法返回。
正則替換是doT.js的核心設(shè)計(jì)思路,本文不對(duì)正則表達(dá)式做擴(kuò)充講解,僅分析doT.js的設(shè)計(jì)思路。先來(lái)看一下doT.js中用到的正則:
templateSettings: { evaluate: /\{\{([\s\S]+?(\}?)+)\}\}/g, //表達(dá)式
interpolate: /\{\{=([\s\S]+?)\}\}/g, // 插入的變量
encode: /\{\{!([\s\S]+?)\}\}/g, // 在這里{{!不是用來(lái)做判斷,而是對(duì)里面的代碼做編碼
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 // 是否跳過(guò)一些特殊字符 }
源碼中將正則定義寫(xiě)到一起,這樣方便了維護(hù)和管理。在早期版本的doT.js中,處理?xiàng)l件表達(dá)式的方式和tmpl一樣,采用直接替換成可執(zhí)行語(yǔ)句的形式,在新版本的doT.js中,修改成僅一條正則就可以實(shí)現(xiàn)替換,變得更加簡(jiǎn)潔。
doT.js源碼中對(duì)模板中語(yǔ)法正則替換的流程如下:
函數(shù)定義時(shí),一般通過(guò)Function關(guān)鍵字,并指定一個(gè)函數(shù)名,用以調(diào)用。在JavaScript中,函數(shù)也是對(duì)象,可以通過(guò)函數(shù)對(duì)象(Function Object)來(lái)創(chuàng)建。正如數(shù)組對(duì)象對(duì)應(yīng)的類(lèi)型是Array,日期對(duì)象對(duì)應(yīng)的類(lèi)型是Date一樣,如下所示:
var funcName = new Function(p1,p2,...,pn,body);
參數(shù)的數(shù)據(jù)類(lèi)型都是字符串,p1到pn表示所創(chuàng)建函數(shù)的參數(shù)名稱(chēng)列表,body表示所創(chuàng)建函數(shù)的函數(shù)體語(yǔ)句,funcName就是所創(chuàng)建函數(shù)的名稱(chēng)(可以不指定任何參數(shù)創(chuàng)建一個(gè)匿名函數(shù))。
下面的定義是等價(jià)的。
例如:
// 一般函數(shù)定義方式 function func1(a,b){ return a+b;
} // 參數(shù)是一個(gè)字符串通過(guò)逗號(hào)分隔 var func2 = new Function('a,b','return a+b'); // 參數(shù)是多個(gè)字符串 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
從上面的代碼中可以看出,F(xiàn)unction的后一個(gè)參數(shù),被轉(zhuǎn)換為可執(zhí)行代碼,類(lèi)似eval的功能。eval執(zhí)行時(shí)存在瀏覽器性能下降、調(diào)試?yán)щy以及可能引發(fā)XSS(跨站)攻擊等問(wèn)題,因此不推薦使用eval執(zhí)行字符串代碼,new Function()恰好解決了這個(gè)問(wèn)題?;剡^(guò)頭來(lái)看doT代碼中的”new Function(c.varname, str)”,就不難理解varname是傳入可執(zhí)行字符串str的變量。
具體關(guān)于new Fcuntion的定義和用法,詳細(xì)請(qǐng)閱讀Function詳細(xì)介紹。
讀到這里可能會(huì)產(chǎn)生一個(gè)疑問(wèn):doT.js的性能為什么在眾多引擎如此突出?通過(guò)閱讀其他引擎源代碼,發(fā)現(xiàn)了它們核心代碼段中都存在這樣那樣的問(wè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 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 ) { //省略部分模板替換語(yǔ)句,若要閱讀全部代碼請(qǐng)?jiān)L問(wèn):https://github.com/BorisMoore/jquery-tmpl }) +
"');}return __;"
); }
在上面的代碼中看到,jQuery-teml同樣使用了new Function()的方式編譯模板,但是在性能對(duì)比中jQuery-teml性能相比doT.js相差甚遠(yuǎn),出現(xiàn)性能瓶頸的關(guān)鍵在于with語(yǔ)句的使用。
with語(yǔ)句為什么對(duì)性能有這么大的影響?我們來(lái)看下面的代碼:
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--;
}
}
}
上面代碼中使用了一個(gè)with表達(dá)式,為了避免多次從datas中取變量而使用了with語(yǔ)句。這看起來(lái)似乎提升了效率,但卻產(chǎn)生了一個(gè)性能問(wèn)題:在JavaScript中執(zhí)行方法時(shí)會(huì)產(chǎn)生一個(gè)執(zhí)行上下文,這個(gè)執(zhí)行上下文持有該方法作用域鏈,主要用于標(biāo)識(shí)符解析。當(dāng)代碼流執(zhí)行到一個(gè)with表達(dá)式時(shí),運(yùn)行期上下文的作用域鏈被臨時(shí)改變了,一個(gè)新的可變對(duì)象將被創(chuàng)建,它包含指定對(duì)象的所有屬性。此對(duì)象被插入到作用域鏈的前端,意味著現(xiàn)在函數(shù)的所有局部變量都被推入第二個(gè)作用域鏈對(duì)象中,這樣訪問(wèn)datas的屬性非???,但是訪問(wèn)局部變量的速度卻變慢了,所以訪問(wèn)代價(jià)更高了,如下圖所示。
這個(gè)插件在GitHub上面介紹時(shí),作者Boris Moore著重強(qiáng)調(diào)兩點(diǎn)設(shè)計(jì)思路:
不改變?cè)瓉?lái)設(shè)計(jì)思路基礎(chǔ)之上,嘗試對(duì)源代碼進(jìn)行性能提升。
先保留提升前性能作為對(duì)比:
首先來(lái)我們做次性能提升,移除源碼中with語(yǔ)句。
次提升后:
接下來(lái)第二部提升,落實(shí)Boris Moore設(shè)計(jì)理念中的模板緩存:
優(yōu)化后的這一部分代碼段被我們修改成了:
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,
//省略部分模板替換語(yǔ)句 } 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源碼中沒(méi)有用到with這類(lèi)消耗性能的語(yǔ)句,與此同時(shí)doT.js選擇先將模板編譯結(jié)果返回給開(kāi)發(fā)者,這樣如要重復(fù)多次使用同一模板進(jìn)行渲染便不會(huì)反復(fù)編譯。
(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; }; })();
閱讀這段代碼會(huì)驚奇的發(fā)現(xiàn),它更像是baiduTemplate精簡(jiǎn)版。相比baiduTemplate而言,它移除了baiduTemplate的自定義語(yǔ)法標(biāo)簽的功能,使得代碼更加精簡(jiǎn),也避開(kāi)了替換用戶語(yǔ)法標(biāo)簽而帶來(lái)的性能消耗。對(duì)于doT.js來(lái)說(shuō),性能問(wèn)題的關(guān)鍵是with語(yǔ)句。
綜合上述我對(duì)tmpl的源碼進(jìn)行移除with語(yǔ)句改造:
改造之前性能:
改造之后性能:
如果讀者對(duì)性能對(duì)比源碼比較感興趣可以訪問(wèn): https://github.com/chen2009277025/TemplateTest 。
通過(guò)對(duì)doT.js源碼的解讀,我們發(fā)現(xiàn):
很多解決我們問(wèn)題的插件的代碼往往簡(jiǎn)單明了,那些龐大的插件反而存在負(fù)面影響或無(wú)用功能。技術(shù)領(lǐng)域有一個(gè)軟件設(shè)計(jì)范式:“約定大于配置”,旨在減少軟件開(kāi)發(fā)人員需要做決定的數(shù)量,做到簡(jiǎn)單而又不失靈活。在插件編寫(xiě)過(guò)程中開(kāi)發(fā)者應(yīng)多注意使用場(chǎng)景和性能的有機(jī)結(jié)合,使用恰當(dāng)?shù)恼Z(yǔ)法,盡可能減少開(kāi)發(fā)者的配置,不求迎合各個(gè)場(chǎng)景。
本站文章版權(quán)歸原作者及原出處所有 。內(nèi)容為作者個(gè)人觀點(diǎn), 并不代表本站贊同其觀點(diǎn)和對(duì)其真實(shí)性負(fù)責(zé),本站只提供參考并不構(gòu)成任何投資及應(yīng)用建議。本站是一個(gè)個(gè)人學(xué)習(xí)交流的平臺(tái),網(wǎng)站上部分文章為轉(zhuǎn)載,并不用于任何商業(yè)目的,我們已經(jīng)盡可能的對(duì)作者和來(lái)源進(jìn)行了通告,但是能力有限或疏忽,造成漏登,請(qǐng)及時(shí)聯(lián)系我們,我們將根據(jù)著作權(quán)人的要求,立即更正或者刪除有關(guān)內(nèi)容。本站擁有對(duì)此聲明的最終解釋權(quán)。