怎样写一个模板引擎

模板引擎是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" %}
<a href="/dashboard">Dashboard</a>
{% end %}

对文本的渲染就是返回文本本身;变量和组块的渲染依赖于我们赋予变量名的值和约定的组块语法规则(如条件、循环等)。要将字符串当做变量进行求值,首先想到的是eval方法:

name = "rainy"
print("Hello, " + eval("name") + "!")

# Hello, rainy!

许多编程语言中的eval方法用于将字符串转化成表达式进行求值,完成类似编译器本身的工作,而实质上模板引擎更像是一个针对于模板的编译器。我们知道编译器一般采用抽象语法树(AST)这种树形结构来对程序源码进行表征,如果我们将模板看作是源码,同样可以将其表征为抽象语法树,例如上面的模板文件可以表示为:

template engine 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" %}
<a href="/dashboard">Dashboard</a>
{% end %}"""

TOK_REGEX.split(content)

# OUTPUT =>
['Hello, ',
 '{{name}}',
 '\n',
 '{% if role == "admin" %}',
 '\n<a href="/dashboard">Dashboard</a>\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("<%").join("\t")
                    .replace(/\t=(.*?)%>/g, "',$1,'")
                    .split("\t").join("');")
                    .split("%>").join("p.push('")
                 + "');}return p.join('');");

    return data ? fn( data ) : fn;
  };
})();

console.log(tmpl("Hello, <%=name%>!", {name: "rainy"}));
// OUTPUT
"Hello, rainy!"

在这段15行不到的(微型)模板引擎中,首先还是根据约定的分隔符将模板分割:

var str = "Hello, <%=name%>!";
str = str.split("<%").join("\t");       //=> '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("<%").join("\t")
  .replace(/\t=(.*?)%>/g, "',$1,'")
  .split("\t").join("');")
  .split("%>").join("p.push('")
  .replace(/\r/g, "\\'")           // 置换回单引号

总结

表面上看来模板引擎复杂的地方是抽象语法树的构建和操作,但实际上其核心问题在于变量名和值的区分,也就是程序和数据的区分。而有趣的是,在Lisp语言中,“数据即程序、程序即数据”,它们之间并无本质差异,有兴趣可以展开阅读一下这篇文章:The Nature of Lisp。模板引擎非常实用,从实用性出发深入探索,一不小心拓展到其它领域,这才是programming最大的乐趣所在:D

参考

BitCoin donate button Tenpay donate button Alipay donate button