1. 程式人生 > 其它 >前端渲染引擎doT.js解析

前端渲染引擎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更值得推薦,它的主要優勢在於:

  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程式碼段,從而得到最終檢視。

下面是一些常用語法表示式對照表:

原始碼分析及實現原理

和後端渲染不同,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|/*[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著重強調兩點設計思路:

  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( /[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原始碼的解讀,我們發現:

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

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