前端渲染引擎doT.js解析

aboutwe 7年前發布 | 37K 次閱讀 jQuery 前端技術

背景

前端渲染有很多框架,而且形式和內容在不斷發生變化。這些演變的背后是設計模式的變化,而歸根到底是功能劃分邏輯的演變: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更值得推薦,它的主要優勢在于:

  1. 小巧精簡,源代碼不超過兩百行,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 }}
條件判斷 if {{? 條件表達式}} {{? i > 3}}
條件轉折 else/else if {{??}}/{{?? 表達式}} {{?? i ==2}}
循環遍歷 for {{~ 循環變量}} {{~ it.arr:item}}...{{~}}
執行方法 funcName() {{= funcName() }} {{= it.sayHello() }}

源碼分析及實現原理

和后端渲染不同,doT.js的渲染完全交由前端來進行,這樣做主要有以下好處:

  1. 脫離后端渲染語言,不需要依賴后端項目的啟動,從而降低了開發耦合度、提升開發效率;
  2. View層渲染邏輯全在JavaScript層實現,容易維護和修改;
  3. 數據通過接口得到,無需考慮后端數據模型變化,只需關心數據格式。

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著重強調兩點設計思路:

  1. 模板緩存,在模板重復使用時,直接使用內存中緩存的模板。在本文作者看來,這是一個雞肋的功能,在實際使用中,無論是直接寫在String中的模板還是從Dom獲取的模板都會以變量的形式存放在內存中,變量使用得當,在頁面整個生命周期內都能取到這個模板。通過源碼分析之后發現jQuery-tmpl的模板緩存并不是對模板編譯結果進行緩存,并且會造成多次執行渲染時產生多次編譯,再加上代碼with性能消耗,嚴重拖慢整個渲染過程。
  2. 模板標記,可以從緩存模板中取出對應子節點。這是一個不錯的設計思路,可以實現數據改變只重新渲染局部界面的功能。但是我覺得:模板將渲染結果交給開發者,并渲染到界面指定位置之后,模板引擎的工作就應該結束了,剩下的對節點操作應該靈活的掌握在開發者手上。

不改變原來設計思路基礎之上,嘗試對源代碼進行性能提升。

先保留提升前性能作為對比:

首先來我們做第一次性能提升,移除源碼中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源碼的解讀,我們發現:

  1. doT.js的條件判斷語法標簽不直觀。當開發者在使用過程中條件判斷嵌套過多時,很難找到對應的結束語法符號,開發者需要自己嚴格規范代碼書寫,否則會給開發和維護帶來困難。
  2. doT.js限制開發者自定義語法標簽,相比較之下baiduTemplate提供可自定義標簽的功能,而baiduTemplate的性能瓶頸恰好是提供自定義語法標簽的功能。

很多解決我們問題的插件的代碼往往簡單明了,那些龐大的插件反而存在負面影響或無用功能。技術領域有一個軟件設計范式:“約定大于配置”,旨在減少軟件開發人員需要做決定的數量,做到簡單而又不失靈活。在插件編寫過程中開發者應多注意使用場景和性能的有機結合,使用恰當的語法,盡可能減少開發者的配置,不求迎合各個場景。

 

 

來自:http://tech.meituan.com/dot.html

 

 本文由用戶 aboutwe 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!