20行程式碼實現JavaScript模板引擎

icyfish發表於2017-07-12

本文首發於個人部落格: icyfish.me

正文

刷朋友圈看到了一個不錯的題目, 於是Google了一下, 找到一篇文章: JavaScript template engine in just 20 lines, 並不是逐字逐句翻譯, 因此算是翻譯+筆記吧.

var TemplateEngine = function(tpl, data) {
    // magic here ...
}
var template = '<p>Hello, my name is <%name%>. I\'m <%age%> years old.</p>';
console.log(TemplateEngine(template, {
    name: "Krasimir",
    age: 29
}));複製程式碼

現在我們要實現TemplateEngine函式, 由上可知, 該函式的兩個引數為模板及資料. 執行上述程式碼後會出現以下結果:

<p>Hello, my name is Krasimir. I'm 29 years old.</p>複製程式碼

首先我們必須要獲取模板中的動態變化部分, 之後將用二個引數中的真實資料替換動態變化部分的內容, 可以使用正規表示式實現.

var re = /<%([^%>]+)?%>/g;複製程式碼

上面的表示式會提取所有以<%為開頭, %>為結尾的部分內容, 末尾的g(global)表示匹配所有項. 然後使用RegExp.prototype.exec()方法, 將所有匹配的字串存進一個陣列中.

var re = /<%([^%>]+)?%>/g;
var match = re.exec(tpl);複製程式碼

輸出match得到這樣的結果:

[
    "<%name%>",
    " name ", 
    index: 21,
    input: 
    "<p>Hello, my name is <%name%>. I\'m <%age%> years old.</p>"
]複製程式碼

我們提取出了資料, 但是隻得到一個陣列元素, 我們需要處理的是所有匹配項, 因此使用while迴圈實現:

var re = /<%([^%>]+)?%>/g, match;
while(match = re.exec(tpl)) {
    console.log(match);
}複製程式碼

執行上述程式碼之後會發現<%name%><%age%>都被提取出來了.

接下來要用真實的資料取代佔位符. 最簡單的方式是使用String.prototype.replace()方法實現:

var TemplateEngine = function(tpl, data) {
    var re = /<%([^%>]+)?%>/g, match;
    while(match = re.exec(tpl)) {
        tpl = tpl.replace(match[0], data[match[1]])
    }
    return tpl;
}複製程式碼

對於文章開頭的例子, 因為只是簡單的物件, 使用當前的方式(data["property"])就能夠完成任務, 但是實際上會遇到更復雜的多層巢狀物件, 比如:

{
    name: "Krasimir Tsonev",
    profile: { age: 29 }
}複製程式碼

將函式的第二個引數改成上述形式之後, 使用以上的方法就沒有辦法解決問題了, 因為當我們輸入<%profile.age%>時, 得到的資料是["profile.age"], 其值為undefined. 此時replace()方法不再適用. 如果對於在<%%>之間的內容, 將其看成JavaScript程式碼, 可以直接執行並返回值, 那就比較好了, 比如:

var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>';複製程式碼

使用new Function()語法, 建構函式:

var fn = new Function("arg", "console.log(arg + 1);");
fn(2); // outputs 3複製程式碼

fn函式接受一個引數, 其函式體為console.log(arg + 1), 上述的程式碼相當於:

var fn = function(arg) {
    console.log(arg + 1);
}
fn(2); // outputs 3複製程式碼

現在我們知道了可以通過上述方式由字串構造出一個簡單的函式. 不過在實現我們的需求時, 還需要花點時間思考如何構建我們所需的函式體. 該函式的功能是返回編譯後的模板. 開始試試看如何實現:

return 
"<p>Hello, my name is " + 
this.name + 
". I\'m " + 
this.profile.age + 
" years old.</p>";複製程式碼

將模板分離為由文字和JavaScript程式碼組成的部分. 利用簡單的合併就可以獲得預期的結果. 不過該方法還是無法100%符合我們的要求. 因為如果<%%>之間的內容不是簡單的變數, 而是其他更復雜的比如迴圈語句, 就無法獲得預期結果, 例如:

var template = 
'My skills:' + 
'<%for(var index in this.skills) {%>' + 
'<a href=""><%this.skills[index]%></a>' +
'<%}%>';複製程式碼

如果使用簡單的合併, 結果是這樣的:

return
'My skills:' + 
for(var index in this.skills) { +
'<a href="">' + 
this.skills[index] +
'</a>' +
}複製程式碼

這樣的話會產生錯誤, for(var index in this.skills) {無法正常執行, 因此採用另一種方式, 不要將所有內容新增到陣列中, 而只將所需的內容新增, 最後合併陣列:

var r = [];
r.push('My skills:'); 
for(var index in this.skills) {
r.push('<a href="">');
r.push(this.skills[index]);
r.push('</a>');
}
return r.join('');複製程式碼

因此接下來的步驟是在構造的函式體中根據情況新增各行程式碼, 之前我們已從模板中提取出一些相關的資訊: 佔位符的內容以及它們所處的位置. 那麼, 再定義一個輔助的變數(cursor)就能夠實現我們想要得到的結果.

var TemplateEngine = function(tpl, data) {
    var re = /<%([^%>]+)?%>/g,
        code = 'var r=[];\n',
        cursor = 0, 
        match;
    var add = function(line) {
        code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
    }
    while(match = re.exec(tpl)) {
        add(tpl.slice(cursor, match.index));
        add(match[1]);
        cursor = match.index + match[0].length;
    }
    add(tpl.substr(cursor, tpl.length - cursor));
    code += 'return r.join("");'; // <-- return the result
    console.log(code);
    return tpl;
}
var template = '<p>Hello, my name is <%this.name%>. I\'m <%this.profile.age%> years old.</p>';
console.log(TemplateEngine(template, {
    name: "Krasimir Tsonev",
    profile: { age: 29 }
}));複製程式碼

code變數的值為我們自己構造的函式的函式體, 函式體中首先定義了一個空陣列. 可以通過cursor變數儲存<%this.name%>這種形式的內容之後的文字處於模板中的位置索引值. 然後我們又建立了add函式, 利用這個函式可以新增各行程式碼到code變數中. 這之後我們會遇到一個棘手的問題, 需要利用轉義解決雙引號"的問題:

var r=[];
r.push("<p>Hello, my name is ");
r.push("this.name");
r.push(". I'm ");
r.push("this.profile.age");
return r.join("");複製程式碼

this.namethis.profile.age不應該被雙引號引起. 可以這樣改進add函式來解決這個問題:

var add = function(line, js) {
    js? code += 'r.push(' + line + ');\n' :
        code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}
var match;
while(match = re.exec(tpl)) {
    add(tpl.slice(cursor, match.index));
    add(match[1], true); // <-- say that this is actually valid js
    cursor = match.index + match[0].length;
}複製程式碼

如果佔位符的內容為JS程式碼, 則將其與布林值true一同傳入add函式, 這樣就可以得到我們預期的結果:

var r=[];
r.push("<p>Hello, my name is ");
r.push(this.name);
r.push(". I'm ");
r.push(this.profile.age);
return r.join("");複製程式碼

然後我們需要做的就是建立這個函式並執行. 在TemplateEngine函式中不返回tpl, 而是返回我們動態建立的函式:

return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);複製程式碼

不要在函式中直接傳入引數, 利用apply方法呼叫該函式並傳入引數. 這樣才會建立正確的作用域, this.name才可正確執行, 此時this指向data物件.

最後我們還想在其中實現一些複雜的操作, 例如if/else宣告以及迴圈:

var template = 
'My skills:' + 
'<%for(var index in this.skills) {%>' + 
'<a href="#"><%this.skills[index]%></a>' +
'<%}%>';
console.log(TemplateEngine(template, {
    skills: ["js", "html", "css"]
}));複製程式碼

不過現在會丟擲錯誤Uncaught SyntaxError: Unexpected token for, 通過除錯可以發現問題:

var r=[];
r.push("My skills:");
r.push(for(var index in this.skills) {);
r.push("<a href=\"\">");
r.push(this.skills[index]);
r.push("</a>");
r.push(});
r.push("");
return r.join("");複製程式碼

包含for迴圈的那行程式碼不應該被新增到陣列中, 於是我們這樣進行改進:

var re = /<%([^%>]+)?%>/g,
    reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g,
    code = 'var r=[];\n',
    cursor = 0;
var add = function(line, js) {
    js? code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n' :
        code += 'r.push("' + line.replace(/"/g, '\\"') + '");\n';
}複製程式碼

上述程式碼新增了一個新的正規表示式, 如果JS程式碼以if, for, else, switch, case, break, { , }這些內容為起始值, 則直接新增該行程式碼, 不新增到陣列中. 那麼最後的結果就是:

var r=[];
r.push("My skills:");
for(var index in this.skills) {
r.push("<a href=\"#\">");
r.push(this.skills[index]);
r.push("</a>");
}
r.push("");
return r.join("");複製程式碼

這樣的話, 所有的內容都被正確編譯.

My skills:<a href="#">js</a><a href="#">html</a><a href="#">css</a>複製程式碼

最後的改進使函式功能更強大, 改進之後我們可以直接在模板裡新增複雜邏輯:

var template = 
'My skills:' + 
'<%if(this.showSkills) {%>' +
    '<%for(var index in this.skills) {%>' + 
    '<a href="#"><%this.skills[index]%></a>' +
    '<%}%>' +
'<%} else {%>' +
    '<p>none</p>' +
'<%}%>';
console.log(TemplateEngine(template, {
    skills: ["js", "html", "css"],
    showSkills: true
}));複製程式碼

新增了一些優化項的最終版本程式碼就類似如下這樣:

var TemplateEngine = function(html, options) {
    var re = /<%([^%>]+)?%>/g, reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g, code = 'var r=[];\n', cursor = 0, match;
    var add = function(line, js) {
        js? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
            (code += line != '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : '');
        return add;
    }
    while(match = re.exec(html)) {
        add(html.slice(cursor, match.index))(match[1], true);
        cursor = match.index + match[0].length;
    }
    add(html.substr(cursor, html.length - cursor));
    code += 'return r.join("");';
    return new Function(code.replace(/[\r\t\n]/g, '')).apply(options);
}複製程式碼

參考

相關文章