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 = { "&": "&", "<": "<", ">": ">", '"': '"', "'": ''', "/": '/' }; 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的存儲方式為:
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; }
經過兩個方法的篩選和整合,最終出來的數據就是精簡的樹形結構數據:
至此,整個解析器的代碼就分析完了,然后我們來分析渲染器的代碼。
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的核心就是一個解析器加一個渲染器,以非常簡潔的代碼實現了一個強大的模板引擎。