簡介
模板引擎,其實就是一個根據模板和資料輸出結果的一個工具。
我們要開發一個將模板檔案轉換成我們實際要使用的內容的工具,這個工具就是模板引擎。我們把模板檔案裡的內容當成字串傳入到模板引擎中,然後模板引擎根據一定語法對該字串進行解析處理,然後返回一個函式,之後我們在執行函式時把資料傳輸進去,即可拿到根據模板和資料得到的新字串。最後我們想怎麼處理該字串就看需求了,如果用於前端模板生成的話,則可以用dom的innerHTML這個屬性來追加內容。
目前前端的模板引擎多得數不勝數,語法特性也花樣百出,用行內的話來說,我們要實現的是一種基於字串的模板引擎。
簡要概述流程如下:
1 |
模板 ----> 輸入到模板引擎 ----> 生成函式 ----> 把資料當成引數,執行該函式 ----> 輸出結果 |
優劣
- 此模板引擎可用於任意一端,前端後端即插即用,不侷限於生成內容的語法,只要生成內容為字串文字即可。比如在合併Sprite圖工具中要根據圖片大小位置生成對應的css定位檔案,我們也可以用該引擎生成而不需要另外再寫一套引擎。
- 此模板引擎對於資料的更改,需要重新渲染一遍模板,所以在初次渲染和之後的模板更新需要耗費同樣的資源。
- 應用於前端時,此模板引擎依賴於innerHTML,存在注入問題。
需求
而此次,我們希望實現一個基於字串的模板引擎。提供的使用方式儘可能簡單,比如類似如下的方式:
1 2 3 4 5 6 7 8 9 10 |
// 前端 var html = window.parse('<div>${content}</div>', { content: 'june' }); // 後端 const parse = require('tpl'); var html = parse('<div>${content}</div>', { content: 'june' }); |
並且希望至少提供以下四種語法:
條件判斷
1 2 3 4 5 6 7 |
{if condition1} // code1 {elseif condition2} // code2 {else} // code3 {/if} |
陣列遍歷
1 2 3 4 |
{list array as item} // code // PS:裡面注入了一個變數item_index,指向item在遍歷過程中的序號 {/list} |
變數定義
1 |
{var var1 = 1} |
插值
1 2 3 4 5 |
// 直接插值 ${var1} // 使用過濾器插值的方式 ${var1|filter1|filter2:var2, var3} |
開工
STEP 1
按照前面定下的需求,我們先實現一個對外的介面,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
'use strict'; var __PARSE__ = (function() { /** * 預設的過濾器 */ const defaultFilter = { // some code }; /* * 解析模板 */ let doParseTemplate(content, data, filter) { // some code }; return function(content, data, filter) { try { data = data||{}; filter = Object.assign({}, defaultFilter, filter); // 解析模板生成程式碼生成器 let f = doParseTemplate(content, data, filter); return f(data, filter); } catch(ex) { return ex.stack; } }; })(); if(typeof module !== 'undefined' && typeof exports === 'object') { module.exports = __PARSE__; } else { window.parse = __PARSE__; } |
此處,f即是我們生成的函式,而生成該函式的函式我命名為doParseTemplate,接收三個引數,content是我們輸入的模板檔案的字串內容,data是我們要傳入的資料,而filter即為模板中可傳入的過濾器。目前doParseTemplate這個函式還未實現,接下來就來實現此函式。
STEP 2
為了生成一個可用的函式,我們要通過new Function(‘DATA’, ‘FILTER’, content);這樣的方法來構造一個函式,其中content即是函式體的字串內容。
我們先設定要生成的函式f的結構如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function(DATA, FILTER) { try { var OUT = []; // 處理變數 // some code // 處理過濾器 // some code // 處理內容 // other code return OUT.join(''); } catch(e) { throw new Error('parse template error!'); } } |
事實上,註釋中處理變數、處理過濾器和處理內容這部分是由外部傳入決定的,所以只有這部分是可變的,其餘的程式碼都是固定的。為此我們可以使用陣列來存放相關的內容,然後在可變部分留一個佔位符,在解析到處理變數、處理過濾器和處理內容部分時再把語句塞進去即可。程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let doParseTemplate = function(content, data, filter) { content = content.replace(/\\t/g, ' ').replace(/\\n/g, '\\\\n').replace(/\\r/g, '\\\\r'); // 初始化模板生成器結構 var struct = [ 'try { var OUT = [];', '', //放置模板生成器佔位符 'return OUT.join(\\'\\'); } catch(e) { throw new Error("parse template error!"); }' ]; // some code return new Function('DATA', 'FILTER', struct.join('')); } |
現在固定結構有了,接下來我們要處理模板相關的內容,即在放置生成器佔位符的那個位置上追加內容。首先,我們要先把輸入的變數和過濾器處理好,即在佔位符的位置加入諸如var a = 1;這樣的內容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
doParseTemplate = function(content, data) { content = content.replace(/\\t/g, ' ').replace(/\\n/g, '\\\\n').replace(/\\r/g, '\\\\r'); // 初始化模板生成器結構 let out = []; let struct = [ 'try { var OUT = [];', '', //放置模板生成器佔位符 'return OUT.join(\\'\\'); } catch(e) { throw e; }' ]; // 初始化模板變數 let vars = []; Object.keys(data).forEach((name) => { vars.push(`var ${name} = DATA['${name}'];`); }); out.push(vars.join('')); // 初始化過濾器 let filters = ['var FILTERS = {};']; Object.keys(filter).forEach((name) => { if(typeof filter[name] === 'function') { filters.push(`FILTERS['${name}'] = FILTER['${name}'];`); } }); out.push(filters.join('')); // some code for parse content // 合併內容 struct[1] = out.join(''); return new Function('DATA', 'FILTER', struct.join('')); } |
如上,在處理變數和過濾器時需要的值直接從傳入的DATA和FILTER變數裡獲取,需要注意的點就是過濾器我們單獨存在一個FILTERS物件裡面去,主要是為了防止傳入的FILTER物件變化帶來的一些不必要的影響。之後我們要對模板內容進行解析,鑑於程式碼越來越長,接下來直接貼上面註釋some code for parse content裡面的內容,其他部分暫且省略。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
// 解析模板內容 let beg = 0; // 解析文段起始位置 let stmbeg = 0; // 表示式起始位置 let stmend = 0; // 表示式結束位置 let len = content.length; let preCode = ''; // 表示式前的程式碼 let endCode = ''; // 最後一段程式碼 let stmJs = ''; // 表示式 while(beg < len) { /* 開始符 */ stmbeg = content.indexOf('{', beg); while(content.charAt(stmbeg - 1) === '\\\\') { // 遇到轉義的情況 stmbeg = content.indexOf('{', stmbeg + 1); } if(stmbeg === -1) { // 到達最後一段程式碼 endCode = content.substr(beg); out.push('OUT.push(\\'' + endCode + '\\');'); break; } /* 結束符 */ stmend = content.indexOf('}', stmbeg); while(content.charAt(stmend - 1) === '\\\\') { // 遇到轉義的情況 stmend = content.indexOf('}', stmend + 1); } if(stmend === -1) { // 沒有結束符 break; } // 開始符之前程式碼 preCode = content.substring(beg, stmbeg); if(content.charAt(stmbeg - 1) === '$') { // 針對變數取值 out.push(`OUT.push(\\'${preCode.substr(0, preCode.length-1)}\\');`); stmJs = content.substring(stmbeg + 1, stmend); // 處理過濾器 let tmp = ''; stmJs.split('|').forEach((item, index) => { if(index === 0) { // 變數,強制轉碼 tmp = item; } else { // 過濾器 let farr = item.split(':'); tmp = `FILTERS['${farr[0]}'](${tmp}`; if(farr[1]) { // 帶變數的過濾器 farr[1].split(',').forEach((fitem) => { tmp = `${tmp}, ${fitem}`; }); } tmp = `${tmp})`; // 追加結尾 } }); out.push(`OUT.push((${tmp}).toString());`); } else { // 針對js語句 out.push(`OUT.push(\\'${preCode}\\');`); stmJs = content.substring(stmbeg + 1, stmend); out.push(transStm(stmJs)); } beg = stmend + 1; } |
對於模板內容的解析,因為語法相對簡單,此處直接使用while迴圈遍歷。在我們上面定義的語法中,有關結構相關的語法都用{和}來包圍,插值則是${和},因此針對這兩種語法需要分開處理。整個流程的判斷如下:
- 搜尋語句開始符{;
- 判斷{前面是否有轉義符\;
- 搜尋語句結束符};
- 判斷}前面是否有轉義符\;
- 判斷{前面是否帶有取值符號$;
- 提取語句內容,即{和}裡面的內容;
- 將語句之前,即{或${之前未放入快取區的內容放入快取區;
- 解析語句,並把解析結果放入快取區;
- 迴圈上述1-8的過程,直到搜尋不到語句開始符{,則判斷為結尾,把剩下的內容放入快取區;
- 把目前快取區的的內容存到需要輸出的陣列中。
以上提到的快取區,即是上面程式碼中的out陣列。當遍歷完模板內容後,把快取區合併成一個字串,然後追加到佔位符末尾。其中關於語句的解析用到的函式transStm目前接下來將要實現。
STEP 3
transStm函式實現比較簡單,因為我們需求中設定的語法也不復雜。程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 |
/* * 轉換模板語句 */ let transStm = function(stmJs) { stmJs = stmJs.trim(); for(let item of regmap) { if(item.reg.test(stmJs)) { return (typeof item.val === 'function') ? stmJs.replace(item.reg, item.val) : item.val; } } }; |
如上,其實只是把語句中的內容逐一用正則去匹配,當匹配到屬於某種規則的語句,則針對性處理並返回結果。比如我有一個語句{if a > 1},然後正則去匹配,會匹配出是條件判斷中的if語句,然後會處理成js程式碼if(a > 1) {並返回。而語句{/if}則會處理成}並返回。因此如下程式碼:
1 |
{if a > 1}.css{margin: 0;}{/if} |
會處理成:
1 2 3 |
if(a > 1) { out.push('.css{margin: 0;}'); // 此處是輸出模板內容 } |
其中關於語法匹配的正則和返回處理如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
/* * 語法正則 */ const regmap = [ // if語句開始 {reg: /^if\\s+(.+)/i, val: (all, condition) => {return `if(${condition}) {`;}}, // elseif 語句開始 {reg: /^elseif\\s+(.+)/i, val: (all, condition) => {return `} else if(${condition}) {`}}, // else語句結束 {reg: /^else/i, val: '} else {'}, // if語句結束 {reg: /^\\/\\s*if/i, val: '}'}, // list語句開始 {reg: /^list\\s+([\\S]+)\\s+as\\s+([\\S]+)/i, val: (all, arr, item) => {return `for(var __INDEX__=0;__INDEX__<${arr}.length;__INDEX__++) {var ${item}=${arr}[__INDEX__];var ${item}_index=__INDEX__;`;}}, // list語句結束 {reg: /^\\/\\s*list/i, val: '}'}, // var 語句 {reg: /^var\\s+(.+)/i, val: (all, expr) => {return `var ${expr};`;}} ]; |
其中reg欄位是正規表示式,若匹配成功,則執行或直接返回val欄位的值。
STEP 4
如果有仔細看前面貼出來的程式碼,發現上面有用到一個變數defaultFilter,這是用來定義模板引擎需要自帶的過濾器的。常用ejs的朋友們估計就會清楚,ejs裡就自帶了很多很實用的過濾器,我在下面例子就貼出一個常用的過濾器方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
/** * 預設的過濾器 */ const defaultFilter = { // 防注入用 escape: (str) => { // 防注入轉碼對映表 var escapeMap = { '<': '<', '>': '>', '&': '&', ' ': ' ', '"': '"', "'": ''', '\\n': '<br/>', '\\r': '' }; return str.replace(/\\<|\\>|\\&|\\r|\\n|\\s|\\'|\\"/g, (one) => { return escapeMap[one]; }); } }; |
用法很簡單,當我們有一個變數a,內容為<div style=”color: red;”>red</div>時,因為我們經常將模板引擎生成的內容直接用innerHTML塞進節點之中,而假如我們像${a}這種方式直接使用這個變數的時候,在頁面中就只會顯示一個紅色的red。
為了防止此類注入的情況發生,我在上面實現了一個叫escape的過濾器,將使用方式改為${a|escape}就可以進行特殊符號的轉義,在頁面上直接顯示變數a的內容<div style=”color: red;”>red</div>。
尾聲
至此,一個完整的基於字串的模板引擎就完成了,上面的程式碼使用了es6語法的部分特性來編寫,如果需要相容的話可以使用babel來將程式碼轉成es5語法,在做一下壓縮混淆的話,實際的程式碼不足3k。
前面也提到過,基於字串的模板引擎最大的好處在於語法自由,你可以做到完全不需要關心模板的型別,你可以寫一個css檔案的模板,也可以寫一個html檔案的模板,只要有對應的模板就會有相應的輸出,並且前後端可以共用。
如果你想要看完整的程式碼的話,請戳這裡。