怎样写一个模板引擎

备份自: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" %}
<a href="/dashboard">Dashboard</a>
{% end %}

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

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

# Hello, rainy!

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

template engine 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

参考

Tenpay donate button
Tenpay donate button
Alipay donate button
Alipay donate button

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容