本文首發於個人部落格: 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.name
和this.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);
}複製程式碼
參考