解讀前端模板引擎Mustache.js源碼

解讀前端模板引擎Mustache.js源碼

原文地址:http://www.111cn.net/wy/js-ajax/74919.htm
Mustache是個不錯的js模板引擎,不僅支持js,還有PHP/RUBY/nodejs等好多語言。Mustache.js可以寫成JQ插件,不依賴其他庫,用在團隊比較適合,並且一些不錯的web項目也選擇了它,性能方面應該都不會有太大差距。

mustache是一個很輕的前端模板引擎,因爲之前接手的項目用了這個模板引擎,自己就也繼續用了一會覺得還不錯,最近項目相對沒那麼忙,於是就抽了點時間看了一下這個的源碼。源碼很少,也就只有六百多行,所以比較容易閱讀。做前端的話,還是要多看優秀源碼,這個模板引擎的知名度還算挺高,所以其源碼也肯定有值得一讀的地方。

本人前端小菜,寫這篇博文純屬自己記錄一下以便做備忘,同時也想分享一下,希望對園友有幫助。若解讀中有不當之處,還望指出。

如果沒用過這個模板引擎,建議 去 https://github.com/janl/mustache.js/ 試着用一下,上手很容易。

取部分官方demo代碼(當然還有其他基本的list遍歷輸出): 

 代碼如下 複製代碼
數據:
{
  "name": {
    "first": "Michael",
    "last": "Jackson"
  },
  "age": "RIP"
}

模板寫法:
* {{name.first}} {{name.last}}
* {{age}}

渲染效果:
* Michael Jackson
* RIP


OK,那就開始來解讀它的源碼吧:

首先先看下源碼中的前面多行代碼:

 代碼如下 複製代碼
var Object_toString = Object.prototype.toString;
    var isArray = Array.isArray || function (object) {
            return Object_toString.call(object) === '[object Array]';
        };

    function isFunction(object) {
        return typeof object === 'function';
    }

    function escapeRegExp(string) {
        return string.replace(/[-[]{}()*+?.,^$|#s]/g, "$&");
    }

    // Workaround for https://issues.apache.org/jira/browse/COUCHDB-577
    // See https://github.com/janl/mustache.js/issues/189
    var RegExp_test = RegExp.prototype.test;
    function testRegExp(re, string) {
        return RegExp_test.call(re, string);
    }

    var nonSpaceRe = /S/;
    function isWhitespace(string) {
        return !testRegExp(nonSpaceRe, string);
    }

    var entityMap = {
        "&": "&",
        "<": "&lt;",
        ">": "&gt;",
        '"': '&quot;',
        "'": '&#39;',
        "/": '&#x2F;'
    };

    function escapeHtml(string) {
        return String(string).replace(/[&<>"'/]/g, function (s) {
            return entityMap[s];
        });
    }

    var whiteRe = /s*/;    //匹配0個或以上空格
    var spaceRe = /s+/;    //匹配一個或以上空格
    var equalsRe = /s*=/;  //匹配0個或者以上空格再加等於號
    var curlyRe = /s*}/;  //匹配0個或者以上空格再加}符號
    var tagRe = /#|^|/|>|{|&|=|!/;  //匹配 #,^,/,>,{,&,=,!



這些都比較簡單,都是一些爲後面主函數準備的工具函數,包括

· toString和test函數的簡易封裝

· 判斷對象類型的方法

· 字符過濾正則表達式關鍵符號的方法

· 判斷字符爲空的方法

· 轉義字符映射表 和 通過映射表將html轉碼成非html的方法

· 一些簡單的正則。

一般來說mustache在js中的使用方法都是如下:

 代碼如下 複製代碼
var template = $('#template').html();
  Mustache.parse(template);   // optional, speeds up future uses
  var rendered = Mustache.render(template, {name: "Luke"});
  $('#target').html(rendered);



所以,我們接下來就看下parse的實現代碼,我們在源碼裏搜索parse,於是找到這一段

 代碼如下 複製代碼

mustache.parse = function (template, tags) {
        return defaultWriter.parse(template, tags);
    };



再通過找defaultWriter的原型Writer類後,很容易就可以找到該方法的核心所在,就是parseTemplate方法,這是一個解析器,不過在看這個方法之前,還得先看一個類:Scanner,顧名思義,就是掃描器,源碼如下

 代碼如下 複製代碼
/**
     * 簡單的字符串掃描器,用於掃描獲取模板中的模板標籤
     */
    function Scanner(string) {
        this.string = string;   //模板總字符串
        this.tail = string;     //模板剩餘待掃描字符串
        this.pos = 0;   //掃描索引,即表示當前掃描到第幾個字符串
    }

    /**
     * 如果模板被掃描完則返回true,否則返回false
     */
    Scanner.prototype.eos = function () {
        return this.tail === "";
    };

    /**
     * 掃描的下一批的字符串是否匹配re正則,如果不匹配或者match的index不爲0;
     * 即例如:在"abc{{"中掃描{{結果能獲取到匹配,但是index爲4,所以返回"";如果在"{{abc"中掃描{{能獲取到匹配,此時index爲0,即返回{{,同時更新掃描索引
     */
    Scanner.prototype.scan = function (re) {
        var match = this.tail.match(re);

        if (!match || match.index !== 0)
            return '';

        var string = match[0];

        this.tail = this.tail.substring(string.length);
        this.pos += string.length;

        return string;
    };

    /**
     * 掃描到符合re正則匹配的字符串爲止,將匹配之前的字符串返回,掃描索引設爲掃描到的位置
     */
    Scanner.prototype.scanUntil = function (re) {
        var index = this.tail.search(re), match;

        switch (index) {
            case -1:
                match = this.tail;
                this.tail = "";
                break;
            case 0:
                match = "";
                break;
            default:
                match = this.tail.substring(0, index);
                this.tail = this.tail.substring(index);
        }

        this.pos += match.length;
        return match;
    };



掃描器,就是用來掃描字符串,在mustache用於掃描模板代碼中的模板標籤。掃描器中就三個方法:

eos:判斷當前掃描剩餘字符串是否爲空,也就是用於判斷是否掃描完了

scan:僅掃描當前掃描索引的下一堆匹配正則的字符串,同時更新掃描索引,註釋裏我也舉了個例子

scanUntil:掃描到匹配正則爲止,同時更新掃描索引

看完掃描器,我們再回歸一下,去看一下解析器parseTemplate方法,模板的標記標籤默認爲"{{}}",雖然也可以自己改成其他,不過爲了統一,所以下文解讀的時候都默認爲{{}}:

 代碼如下 複製代碼
function parseTemplate(template, tags) {
        if (!template)
            return [];

        var sections = [];     // 用於臨時保存解析後的模板標籤對象
        var tokens = [];       // 保存所有解析後的對象
        var spaces = [];       // 保存空格對象在tokens裏的索引
        var hasTag = false;    
        var nonSpace = false;  


        // 去除保存在tokens裏的空格標記
        function stripSpace() {
            if (hasTag && !nonSpace) {
                while (spaces.length)
                    delete tokens[spaces.pop()];
            } else {
                spaces = [];
            }

            hasTag = false;
            nonSpace = false;
        }

        var openingTagRe, closingTagRe, closingCurlyRe;

        //將tag轉成正則,默認的tag爲{{和}},所以轉成匹配{{的正則,和匹配}}的正則,已經匹配}}}的正則(因爲mustache的解析中如果是{{{}}}裏的內容則被解析爲html代碼)
        function compileTags(tags) {
            if (typeof tags === 'string')
                tags = tags.split(spaceRe, 2);

            if (!isArray(tags) || tags.length !== 2)
                throw new Error('Invalid tags: ' + tags);

            openingTagRe = new RegExp(escapeRegExp(tags[0]) + 's*');
            closingTagRe = new RegExp('s*' + escapeRegExp(tags[1]));
            closingCurlyRe = new RegExp('s*' + escapeRegExp('}' + tags[1]));
        }

        compileTags(tags || mustache.tags);

        var scanner = new Scanner(template);

        var start, type, value, chr, token, openSection;
        while (!scanner.eos()) {
            start = scanner.pos;

            // Match any text between tags.
            // 開始掃描模板,掃描至{{時停止掃描,並且將此前掃描過的字符保存爲value
            value = scanner.scanUntil(openingTagRe);

            if (value) {
                //遍歷{{前的字符
                for (var i = 0, valueLength = value.length; i < valueLength; ++i) {
                    chr = value.charAt(i);

                    //如果當前字符爲空格,則用spaces數組記錄保存至tokens裏的索引
                    if (isWhitespace(chr)) {
                        spaces.push(tokens.length);
                    } else {
                        nonSpace = true;
                    }

                    tokens.push([ 'text', chr, start, start + 1 ]);

                    start += 1;

                    // 如果遇到換行符,則將前一行的空格去掉
                    if (chr === 'n')
                        stripSpace();
                }
            }

            // 判斷下一個字符串中是否有{[,同時更新掃描索引至{{後一位
            if (!scanner.scan(openingTagRe))
                break;

            hasTag = true;

            //掃描標籤類型,是{{#}}還是{{=}}還是其他
            type = scanner.scan(tagRe) || 'name';
            scanner.scan(whiteRe);

            //根據標籤類型獲取標籤裏的值,同時通過掃描器,刷新掃描索引
            if (type === '=') {
                value = scanner.scanUntil(equalsRe);

                //使掃描索引更新爲s*=後
                scanner.scan(equalsRe);

                //使掃描索引更新爲}}後,下面同理
                scanner.scanUntil(closingTagRe);
            } else if (type === '{') {
                value = scanner.scanUntil(closingCurlyRe);
                scanner.scan(curlyRe);
                scanner.scanUntil(closingTagRe);
                type = '&';
            } else {
                value = scanner.scanUntil(closingTagRe);
            }

            // 匹配模板閉合標籤即}},如果沒有匹配到則拋出異常,同時更新掃描索引至}}後一位,至此時即完成了一個模板標籤{{#tag}}的掃描
            if (!scanner.scan(closingTagRe))
                throw new Error('Unclosed tag at ' + scanner.pos);

            // 將模板標籤也保存至tokens數組中
            token = [ type, value, start, scanner.pos ];
            tokens.push(token);

            //如果type爲#或者^,也將tokens保存至sections
            if (type === '#' || type === '^') {
                sections.push(token);
            } else if (type === '/') {  //如果type爲/則說明當前掃描到的模板標籤爲{{/tag}},則判斷是否有{{#tag}}與其對應

                // 檢查模板標籤是否閉合,{{#}}是否與{{/}}對應,即臨時保存在sections最後的{{#tag}},是否跟當前掃描到的{{/tag}}的tagName相同
                // 具體原理:掃描第一個tag,sections爲[{{#tag}}],掃描第二個後sections爲[{{#tag}} , {{#tag2}}]以此類推掃描多個開始tag後,sections爲[{{#tag}} , {{#tag2}} ... {{#tag}}]
                // 所以接下來如果掃描到{{/tag}}則需跟sections的最後一個相對應才能算標籤閉合。同時比較後還需將sections的最後一個刪除,才能進行下一輪比較
                openSection = sections.pop();

                if (!openSection)
                    throw new Error('Unopened section "' + value + '" at ' + start);

                if (openSection[1] !== value)
                    throw new Error('Unclosed section "' + openSection[1] + '" at ' + start);
            } else if (type === 'name' || type === '{' || type === '&') {
                nonSpace = true;
            } else if (type === '=') {
                compileTags(value);
            }
        }

        // 保證sections裏沒有對象,如果有對象則說明標籤未閉合
        openSection = sections.pop();

        if (openSection)
            throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos);

        //在對tokens裏的數組對象進行篩選,進行數據的合併及剔除
        return nestTokens(squashTokens(tokens));
    }


解析器就是用於解析模板,將html標籤即內容與模板標籤分離,整個解析原理爲遍歷字符串,通過最前面的那幾個正則以及掃描器,將普通html和模板標籤{{#tagName}}{{/tagName}} {{^tagName}}掃描出來並且分離,將每一個{{#XX}}、{{^XX}}、{{XX}}、{{/XX}}還有普通不含模板標籤的html等全部抽象爲數組保存至tokens。

tokens的存儲方式爲:

解讀前端模板引擎Mustache.js源碼

  解讀前端模板引擎Mustache.js源碼




token[0]爲token的type,可能值爲:# ^ / & name text等分別表示{{#XX}}、{{^XX}}、{{/XX}}、{{&XX}}、{{XX}}、以及html文本等

token[1]爲token的內容,如果是模板標籤,則爲標籤名,如果爲html文本,則是html的文本內容

token[2],token[3]爲匹配開始位置和結束位置,後面將數據結構轉換成樹形結構的時候還會有token[4]和token[5]

具體的掃描方式爲以{{}}爲掃描依據,利用掃描器的scanUtil方法,掃描到{{後停止,通過scanner的scan方法匹配tagRe正則(/#|^|/|>| {|&|=|!/)從而判斷出{{後的字符是否爲模板關鍵字符,再用scanUtil方法掃描至}}停止,獲取獲取到的內容,此時就可以獲取到 tokens[0]、tokens[1]、tokens[2],再調用一下scan更新掃描索引,就可以獲取到token[3]。同理,下面的字符串也是如此掃描,直至最後一行return nestTokens(squashTokens(tokens))之前,掃描出來的結果爲,模板標籤爲一個token對象,如果是html文本,則每一個字符都作爲一個token對象,包括空格字符。這些數據全部按照掃描順序保存在tokens數組裏,不僅雜亂而且量大,所以最後一行代碼中的 squashTokens方法和nestTokens用來進行數據篩選以及整合。

首先來看下squashTokens方法,該方法主要是整合html文本,對模板標籤的token對象沒有進行處理,代碼很簡單,就是將連續的html文本token對象整合成一個。

 代碼如下 複製代碼
function squashTokens(tokens) {
        var squashedTokens = [];

        var token, lastToken;
        for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
            token = tokens[i];

            if (token) {
                if (token[0] === 'text' && lastToken && lastToken[0] === 'text') {
                    lastToken[1] += token[1];
                    lastToken[3] = token[3];
                } else {
                    squashedTokens.push(token);
                    lastToken = token;
                }
            }
        }

        return squashedTokens;
    }



整合完html文本的token對象後,就通過nestTokens進行進一步的整合,遍歷tokens數組,如果當前token爲{{#XX}}或者{{^XX}}都說明是模板標籤的開頭標籤,於是把它的第四個參數作爲收集器存爲collector進行下一輪判斷,如果當前token爲{{/}}則說明遍歷到了模板閉合標籤,取出其相對應的開頭模板標籤,再給予其第五個值爲閉合標籤的開始位置。如果是其他,則直接扔進當前的收集器中。如此遍歷完後,tokens裏的token對象就被整合成了樹形結構

 代碼如下 複製代碼
function nestTokens(tokens) {
        var nestedTokens = [];

        //collector是個收集器,用於收集當前標籤子元素的工具
        var collector = nestedTokens;
        var sections = [];

        var token, section;
        for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
            token = tokens[i];

            switch (token[0]) {
                case '#':
                case '^':
                    collector.push(token);
                    sections.push(token);   //存放模板標籤的開頭對象

                    collector = token[4] = [];  //此處可分解爲:token[4]=[];collector = token[4];即將collector指向當前token的第4個用於存放子對象的容器

                    break;
                case '/':
                    section = sections.pop();   //當發現閉合對象{{/XX}}時,取出與其相對應的開頭{{#XX}}或{{^XX}}
                    section[5] = token[2];
                    collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;  //如果sections未遍歷完,則說明還是有可能發現{{#XX}}開始標籤,所以將collector指向最後一個sections中的最後一個{{#XX}}
                    break;
                default:
                    collector.push(token);      //如果是普通標籤,扔進當前的collector中
            }
        }

        //最終返回的數組即爲樹形結構
        return nestedTokens;
    }


經過兩個方法的篩選和整合,最終出來的數據就是精簡的樹形結構數據:

解讀前端模板引擎Mustache.js源碼


至此,整個解析器的代碼就分析完了,然後我們來分析渲染器的代碼。

parseTemplate 將模板代碼解析爲樹形結構的tokens數組,按照平時寫mustache的習慣,用完parse後,就是直接用 xx.innerHTML = Mustache.render(template , obj),因爲此前會先調用parse解析,解析的時候會將解析結果緩存起來,所以當調用render的時候,就會先讀緩存,如果緩存裏沒有相關解析數據,再調用一下parse進行解析。

 代碼如下 複製代碼
Writer.prototype.render = function (template, view, partials) {
        var tokens = this.parse(template);

        //將傳進來的js對象實例化成context對象
        var context = (view instanceof Context) ? view : new Context(view);
        return this.renderTokens(tokens, context, partials, template);
    };


可見,進行最終解析的renderTokens函數之前,還要先把傳進來的需要渲染的對象數據進行處理一下,也就是把數據包裝成context對象。所以我們先看下context部分的代碼:

 代碼如下 複製代碼
function Context(view, parentContext) {
        this.view = view == null ? {} : view;
        this.cache = { '.': this.view };
        this.parent = parentContext;
    }

    /**
     * 實例化一個新的context對象,傳入當前context對象成爲新生成context對象的父對象屬性parent中
     */
    Context.prototype.push = function (view) {
        return new Context(view, this);
    };

    /**
     * 獲取name在js對象中的值
     */
    Context.prototype.lookup = function (name) {
        var cache = this.cache;

        var value;
        if (name in cache) {
            value = cache[name];
        } else {
            var context = this, names, index;

            while (context) {
                if (name.indexOf('.') > 0) {
                    value = context.view;
                    names = name.split('.');
                    index = 0;

                    while (value != null && index < names.length)
                        value = value[names[index++]];
                } else if (typeof context.view == 'object') {
                    value = context.view[name];
                }

                if (value != null)
                    break;

                context = context.parent;
            }

            cache[name] = value;
        }

        if (isFunction(value))
            value = value.call(this.view);

        console.log(value)
        return value;
    };


context 部分代碼也是很少,context是專門爲樹形結構提供的工廠類,context的構造函數中,this.cache = {'.':this.view}是把需要渲染的數據緩存起來,同時在後面的lookup方法中,把需要用到的屬性值從this.view中剝離到緩存的第一層來,也就是lookup方法中的cache[name] = value,方便後期查找時先在緩存裏找

context的push方法比較簡單,就是形成樹形關係,將新的數據傳進來封裝成新的context對象,並且將新的context對象的parent值指向原來的context對象。

context的lookup方法,就是獲取name在渲染對象中的值,我們一步一步來分析,先是判斷name是否在cache中的第一層,如果不在,才進行深度獲取。然後將進行一個while循環:

先是判斷name是否有.這個字符,如果有點的話,說明name的格式爲XXX.XX,也就是很典型的鍵值的形式。然後就將name通過.分離成一個數組names,通過while循環遍歷names數組,在需要渲染的數據中尋找以name爲鍵的值。

如果name沒有.這個字符,說明是一個單純的鍵,先判斷一下需要渲染的數據類型是否爲對象,如果是,就直接獲取name在渲染的數據裏的值。

通過兩層判斷,如果沒找到符合的值,則將當前context置爲context的父對象,再對其父對象進行尋找,直至找到value或者當前context無父對象爲止。如果找到了,將值緩存起來。

看完context類的代碼,就可以看渲染器的代碼了:

 代碼如下 複製代碼
Writer.prototype.renderTokens = function (tokens, context, partials, originalTemplate) {
        var buffer = '';

        var self = this;
        function subRender(template) {
            return self.render(template, context, partials);
        }

        var token, value;
        for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
            token = tokens[i];

            switch (token[0]) {
                case '#':
                    value = context.lookup(token[1]);   //獲取{{#XX}}中XX在傳進來的對象裏的值

                    if (!value)
                        continue;   //如果不存在則跳過

                    //如果爲數組,說明要複寫html,通過遞歸,獲取數組裏的渲染結果
                    if (isArray(value)) {
                        for (var j = 0, valueLength = value.length; j < valueLength; ++j) {
                            //獲取通過value渲染出的html
                            buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate);
                        }
                    } else if (typeof value === 'object' || typeof value === 'string') {
                        //如果value爲對象,則不用循環,根據value進入下一次遞歸
                        buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate);
                    } else if (isFunction(value)) {
                        //如果value是方法,則執行該方法,並且將返回值保存
                        if (typeof originalTemplate !== 'string')
                            throw new Error('Cannot use higher-order sections without the original template');

                        // Extract the portion of the original template that the section contains.
                        value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender);

                        if (value != null)
                            buffer += value;
                    } else {
                        buffer += this.renderTokens(token[4], context, partials, originalTemplate);
                    }

                    break;
                case '^':
                    //如果爲{{^XX}},則說明要當value不存在(null、undefine、0、'')或者爲空數組的時候才觸發渲染
                    value = context.lookup(token[1]);

                    // Use JavaScript's definition of falsy. Include empty arrays.
                    // See https://github.com/janl/mustache.js/issues/186
                    if (!value || (isArray(value) && value.length === 0))
                        buffer += this.renderTokens(token[4], context, partials, originalTemplate);

                    break;
                case '>':
                    //防止對象不存在
                    if (!partials)
                        continue;
                    //>即直接讀取該值,如果partials爲方法,則執行,否則獲取以token爲鍵的值
                    value = isFunction(partials) ? partials(token[1]) : partials[token[1]];

                    if (value != null)
                        buffer += this.renderTokens(this.parse(value), context, partials, value);

                    break;
                case '&':
                    //如果爲&,說明該屬性下顯示爲html,通過lookup方法獲取其值,然後疊加到buffer中
                    value = context.lookup(token[1]);

                    if (value != null)
                        buffer += value;

                    break;
                case 'name':
                    //如果爲name說明爲屬性值,不作爲html顯示,通過mustache.escape即escapeHtml方法將value中的html關鍵詞轉碼
                    value = context.lookup(token[1]);

                    if (value != null)
                        buffer += mustache.escape(value);

                    break;
                case 'text':
                    //如果爲text,則爲普通html代碼,直接疊加
                    buffer += token[1];
                    break;
            }
        }

        return buffer;
    };



原理還是比較簡單的,因爲tokens的樹形結構已經形成,渲染數據就只需要按照樹形結構的順序進行遍歷輸出就行了。

不過還是大概描述一下,buffer是用來存儲渲染後的數據,遍歷tokens數組,通過switch判斷當前token的類型:

如果是#,先獲取到{{#XX}}中的XX在渲染對象中的值value,如果沒有該值,直接跳過該次循環,如果有,則判斷value是否爲數組,如果爲數組,說明要複寫html,再遍歷value,通過遞歸獲取渲染後的html數據。如果value爲對象或者普通字符串,則不用循環輸出,直接獲取以value 爲參數渲染出的html,如果value爲方法,則執行該方法,並且將返回值作爲結果疊加到buffer中。如果是^,則當value不存在或者 value是數組且數組爲空的時候,才獲取渲染數據,其他判斷都是差不多。

通過這堆判斷以及遞歸調用,就可以把數據完成渲染出來了。

至此,Mustache的源碼也就解讀完了,Mustache的核心就是一個解析器加一個渲染器,以非常簡潔的代碼實現了一個強大的模板引擎。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章