作者:Yusheng 的部落格
網址:http://blog.rainy.im/2015/07/29/how-to-write-a-template-engine/
模板引擎是Web開發中通常用於動態生成網頁的工具,例如PHP常用的Smarty、Python的Jinja、Node的Jade等。本文透過Python(Approach: Building a toy template engine in Python)和Js(JavaScript Micro-Templating)的兩個簡單模板引擎專案學習怎樣寫一個模板引擎。
一般模板由下麵三部分組成:
- 文字
- 變數
- 組塊
通常變數和程式碼組塊由特定的分隔符標識,如:
Hello, {{name}}!
{% if role == “admin” %}
{% end %}
對文字的渲染就是傳迴文字本身;變數和組塊的渲染依賴於我們賦予變數名的值和約定的組塊語法規則(如條件、迴圈等)。要將字串當做變數進行求值,首先想到的是eval方法:
name = “rainy”
print(“Hello, ” + eval(“name”) + “!”)
# Hello, rainy!
許多程式語言中的eval方法用於將字串轉化成運算式進行求值,完成類似編譯器本身的工作,而實質上模板引擎更像是一個針對於模板的編譯器。我們知道編譯器一般採用抽象語法樹(AST)這種樹形結構來對程式原始碼進行表徵,如果我們將模板看作是原始碼,同樣可以將其表徵為抽象語法樹,例如上面的模板檔案可以表示為:
要將模板檔案變成上圖所示的AST結構,首先需要按照分隔符劃分,例如在Python中:
import re
VAR_TOKEN_START = ‘{{‘
VAR_TOKEN_END = ‘}}’
BLOCK_TOKEN_START = ‘{%’
BLOCK_TOKEN_END = ‘%}’
TOK_REGEX = re.compile(r”(%s.*?%s|%s.*?%s)” % (
VAR_TOKEN_START,
VAR_TOKEN_END,
BLOCK_TOKEN_START,
BLOCK_TOKEN_END
))
content = “””Hello, {{name}}!
{% if role == “admin” %}
{% end %}”””
TOK_REGEX.split(content)
# OUTPUT =>
[‘Hello, ‘,
‘{{name}}’,
‘\n’,
‘{% if role == “admin” %}’,
‘\nDashboard\n’,
‘{% end %}’,
”]
構建成AST之後對每一節點逐一進行渲染(render),例如對變數的渲染可以用下麵的方法:
def resolve(name, context):
for tok in name.split(‘.’):
context = context[tok]
return context
class VarTmpl():
def __init__(self, var):
self.var = var
def render(self, **kwargs):
return resolve(self.var, kwargs)
tmpl = VarTmpl(“name”)
tmpl.render(name = “rainy”) #=> rainy
tmpl.render(name = “python”) #=> python
對組塊的渲染稍微複雜一些但原理上類似於eval:
role = ‘user’
eval(‘role == “admin”‘)
# OUTPUT
False
只不過所有組塊的語法和求值規則需要重新定義,有興趣可以檢視原始碼。下麵再來看基於Js的一種解決方案。
從上文可以看出,模板引擎的核心在於區分字串和運算式,而運算式本身又是以字串的形式呈現。為了實現字串與運算式之間的切換,上面Python的版本採用eval(或者更專業點的:ast.literal_eval)。當然Js中也有與之類似的eval方法,但Js還有另外一個非常靈活的特性,在定義一個函式時,可以用下麵兩種方式:
var Tmpl = function(context){
with(context){
console.log(name);
}
}
Tmpl({name: “rainy”}); //=> rainy
var raw = “name”;
var Tmpl = new Function(“context”,
“with(context){console.log(“+
raw+
“);}”);
Tmpl({name: “rainy”}); //=> rainy
Tmpl({name: “js”}); //=> js
也就是說我們可以透過new Function()的方法實現字串向運算式的轉化,結合上文提到的分割-求值-重組的步驟,我們再來看John Resig的簡化版本:
(function(){
this.tmpl = function tmpl(str, data){
var fn = new Function(“obj”, “var p=[];”+
“with(obj){p.push(‘” +
str
.replace(/[\r\t\n]/g, ” “)
// 去掉了單引號處理部分,簡化版本中模板檔案中暫時不能出現單引號;
.split(”
.replace(/\t=(.*?)%>/g, “‘,$1,'”)
.split(“\t”).join(“‘);”)
.split(“%>”).join(“p.push(‘”)
+ “‘);}return p.join(”);”);
return data ? fn( data ) : fn;
};
})();
console.log(tmpl(“Hello, !”, {name: “rainy”}));
// OUTPUT
“Hello, rainy!”
在這段15行不到的(微型)模板引擎中,首先還是根據約定的分隔符將模板分割:
var str = “Hello, !”;
str = str.split(” ‘Hello, \t=name%>!’
str = str.replace(/\t=(.*?)%>/g, “‘,$1,'”);
//=> ‘Hello, \’,name,\’!’
註意這一行是在new Function()的定義中,相當於:
function fn(str, data){
var p = [];
with(data){
p.push(‘Hello, ‘,name,’!’);
// p === [‘Hello, ‘, name, ‘!’];
};
}
而在with(data){}作用範圍內,name === data.name,因此得到:
p === [‘Hello, ‘, ‘rainy’, ‘!’];
p.join(”) === “Hello, rainy!”;
以上就是這一微型模板引擎的核心部分,如果需要處理單引號的問題,可以在str處理過程中加上:
str
.replace(/[\r\t\n]/g, ” “)
.replace(/’/g, “\r”) // 全部單引號替換為\r
.split(”
.replace(/\t=(.*?)%>/g, “‘,$1,'”)
.split(“\t”).join(“‘);”)
.split(“%>”).join(“p.push(‘”)
.replace(/\r/g, “\\'”) // 置換回單引號
總結
錶面上看來模板引擎複雜的地方是抽象語法樹的構建和操作,但實際上其核心問題在於變數名和值的區分,也就是程式和資料的區分。而有趣的是,在Lisp語言中,“資料即程式、程式即資料”,它們之間並無本質差異,有興趣可以展開閱讀一下這篇文章:The Nature of Lisp。模板引擎非常實用,從實用性出發深入探索,一不小心拓展到其它領域,這才是programming最大的樂趣所在:D
參考
- Approach: Building a toy template engine in Python
- JavaScript Micro-Templating
- Blitz templates, template engine extension for PHP
- Blitz-featured