[譯] 只有 20 行的 JavaScript 模板引擎

Jrain發表於2018-12-10

寫於 2016.06.13

原文連結:JavaScript template engine in just 20 lines

(譯者吐槽:只收藏不點贊都是耍流氓)

前言

我仍舊在為我的JS前處理器AbsurdJS進行開發工作。它原本是一個CSS前處理器,但之後它擴充套件成為了CSS/HTML前處理器,很快它將支援JS到CSS/HTML的轉換。它就像一個模板引擎一樣能夠生成HTML程式碼,也就是說它能夠用資料填充模板當中的標識片段。

因此,我希望去寫一個可以滿足我當前需求的模板引擎。AbsurdJS主要作為NodeJS的模組使用,但同時它也可以在客戶端使用。為了這個目的,我無法使用市面上已經存在的模板引擎,因為它們幾乎全都依賴於NodeJS,並且難以在瀏覽器中使用。我需要一個更小,純JS寫成的模板引擎。我瀏覽了這篇由John Resig寫的部落格,似乎這正是我需要的東西。我把當中的程式碼稍作修改,並且濃縮到了20行。

這段程式碼的執行原理非常有趣,我將在這篇文章中一步一步為大家展示John的wonderful idea。

1、提取標識片段

這是我們在開始的時候將要獲得的東西:

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
}));
複製程式碼

一個簡單的函式,傳入模板資料作為引數,正如你所想象的,我們想要得到以下的結果:

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

我們要做的第一件事就是獲取模板中的標識片段<%...%>,然後用傳入引擎中的資料去填充它們。我決定用正規表示式去完成這些功能。正則不是我的強項,所以大家將就一下,如果有更好的正則也歡迎向我提出。

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

我們將會匹配所有以<%開頭以%>結尾的程式碼塊,末尾的g(global)表示我們將匹配多個。有許多的方法能夠用於匹配正則,但是我們只需要一個能夠裝載字串的陣列就夠了,這正是exec所做的工作:

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

在控制檯console.log(match)可以看到:

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

我們取得了正確的匹配結果,但正如你所看到的,只匹配到了一個標識片段<%name%>,所以我們需要一個while迴圈去取得所有的標識片段。

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

執行,發現所有的標識片段已經被我們獲取到了。

2、資料填充與邏輯處理

在獲取了標識片段以後,我們就要對它們進行資料的填充。使用.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 = {
    name: "Krasimir Tsonev",
    age: 29
}
複製程式碼

OK,正常執行。但很明顯這並不足夠,我們當前的資料結構非常簡單,但實際開發中我們將面臨更復雜的資料結構:

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

出現錯誤的原因,是當我們在模板中輸入<%profile.age%>的時候,我們得到的data["profile.age"]是undefined的。顯然.replace方法是行不通的,我們需要一些別的方法把真正的JS程式碼插入到<%和%>當中,就像以下栗子:

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

這看似不可能完成?John使用了new Function,即通過字串去建立一個函式的方法去完成這個功能。舉個栗子:

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

fn是個真正的函式,它包含一個引數,其函式體為console.log(arg + 1)。以上程式碼等價於下列程式碼:

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

通過new Function,我們得以通過字串去建立一個函式,這正是我們所需要的。在建立這麼一個函式之前,我們需要去構造這個它的函式體。該函式體應當返回一個最終拼接好了的模板。沿用前文的模板字串,想象一下這個函式應當返回的結果:

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

顯然,我們把模板分成了文字和JS程式碼。正如上述程式碼,我們使用了簡單的字串拼接的方式去獲取最終結果,但是這個方法無法100%實現我們的需求,因為之後我們還要處理諸如迴圈之類的JS邏輯,像這樣:

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>' +
}
複製程式碼

理所當然這會報錯。這也是我決定參照John的文章去寫邏輯的原因——我把所有的字串都push到一個陣列中,在最後才把它們拼接起來:

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表示我們正處於模板的哪個位置,我們需要它去遍歷所有的字串,跳過填充資料的片段。另外,add函式的任務是把字串插入到code變數中,作為構建函式體的過程方法。這裡有一個棘手的地方,我們需要跳過識別符號<%%>,否則當中的JS指令碼將會失效。如果我們直接執行上述程式碼,結果將會是下面的情況:

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;
}
複製程式碼

標識片段中的內容將通過一個boolean值進行控制。現在我們得到了一個正確的函式體:

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("");
複製程式碼

接下來我們要做的就是生成這個函式並且執行它。在這個模板引擎的末尾,我們用以下程式碼去代替直接返回一個tpl物件:

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

我們甚至不需要向函式傳遞任何的引數,因為apply方法已經為我們完整了這一步工作。它自動設定了作用域,這也是為什麼this.name可以執行,this指向了我們的data。

3、程式碼優化

大致上已經完成了。最後一件事情,我們需要支援更多複雜的表示式,像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。仔細觀察,通過code變數我們可以找出問題所在:

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迴圈的程式碼不應該被push到陣列當中,而是直接放在指令碼里面。為了解決這個問題,在把程式碼push到code變數之前我們需要多一步的判斷:

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, |開頭,那它們將會直接新增到函式體中;如果不是,則會被push到code變數中。下面是修改後的結果:

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);
}
複製程式碼

優化後的程式碼甚至少於15行。

後記(譯者注)

這是我第一次完整地翻譯文章,語句多有錯漏還請多多諒解,今後將繼續努力,爭取把更多優質的文章翻譯分享。

由於對前端的框架、模板引擎一類的工具特別感興趣,非常希望能夠學習當中的原理,於是乎找了個相對簡單的模板引擎開刀進行研究,google後看到了這篇文章覺得非常優秀,一步步講解生動且深入,程式碼經過本人測試均能正確得到文章描述的結果。

模板引擎有多種設計思路,本文僅僅為其中的一種,其效能等引數還有待測試和提高,僅供學習使用。 謝謝大家~

相關文章