来吧,造个模板引擎轮子——开篇

来吧战个痛,造个模板引擎轮子。

是的,我又给自己挖了个新坑,这次虽然代码已经写了一两个月了,但是写码之余,意犹未尽。

我知道在MVVM代表着先进生产力的发展要求,并且也越来越流行的今天,字符串模板已经越来越势微。而且这玩意其实技术含量并不高,是“技巧 > 技术”的类型。不过手痒得很,于是还是造了一个。

这次挖这个坑就是要为我的模板引擎轮子写一个开发心得流水账,暂时不知道会写多少篇,说不定两三篇,说不定一篇就变有生之年系列……

内容方面其实很枯燥,因为我不喜欢写一些高大上的设计思路这类的东西都特么边写边删边想的哪有什么设计,对于工作之外的玩意,我就喜欢写了删删了写,说好听点这叫好读书,不求甚解(掩面逃)。

哦,先丢个GitHub,继续做一个不负责任的男人,无测试、无DEMO、无主页的三无项目……为了尝鲜、玩票外加装逼,我用的是TypeScript来写,其实和JS也没啥差别。

目标设定

语法风格

说到模板引擎,很多人都会想到PHP当中的Smarty,这也许是前端用得最多的模板引擎了。Smarty代表了一种以“定界符”为核心的模板引擎语法,例如:

1
2
3
{{if $xxx == 'yyy'}}
Oh yeah....
{{/if}}

这种语法风格,一直延续至今,广为人们所接受。我觉得这种风格之所以大家很容易接受,一个重要的原因就是它跟ASP/JSP/PHP本身所提供的语言内嵌功能是如此的相似,甚至还有node-cgi这么好玩的东西!!例如PHP:

1
2
3
<? if ($xxx == 'yyy') { ?>
Oh yeah...
<? } ?>

到了JS这边,众多流行的模板引擎,比如EJShandlebarsmustuche等,以及国内的(我觉得的)上乘之作ETPLartTemplate,都是沿用了定界符的语法风格。

这本身不是什么问题,用定界符可以很方便的从源代码当中区分出哪些是HTML,哪些是模板逻辑,这想必是极好的。但对于我来说,我却觉得不够“”,用哈尔的话说就是

说到骚的话,Jade其实是挺骚的一个模板引擎,但是我并不喜欢它。一个是因为我不喜欢用缩进来表示代码层级的语言(一黑一大片),我喜欢大括号!一个是在过去尝试用Jade的过程中,对于内联逻辑表达式实在是太头疼了……比如在Smarty当中:

1
2
3
{{foreach from=$list item=it}}
<li class="list-item{{if $it.active}} active{{/if}}">{{$it.name}}</li>
{{/foreach}}

的那段关于类名active的内联逻辑,这种丑到没朋友,但是简单粗暴能够解决问题让我不加班的代码,我竟然没研究出来Jade里怎么实现。虽然事情已经过去两年了,但这还是给我留下了巨大的心理阴影,我深信这一定是我太愚蠢了,Jade一定是可以做到的,但是我心塞了,心累了,没兴趣研究了。

后来当我回想起大学的时候用微软大法C#和ASP.NET MVC的时候,在发展到某个版本的时候,微软大法推出了一款Razor模板引擎(他们管叫“Render Engine”)当时我好像还不大喜欢它呢,其实只是因为我当时的项目已经到后期了不可能再换模板。它的语法非常的简洁,对于逻辑代码块,它使用

1
2
3
@{
code block
}

的格式,相当于直接内联C#代码,简单粗暴I'm lovin' it啊……而对于变量输出,则更加简单了:

1
Hello! My name is <strong>@name</strong>. I'm @age year's old.

嗯……骚骚骚,骚不住!

功能

要作为一个能用的模板引擎(我没有点名黑doT,其实我说的是早期的doT,简直除了快以外……),必须有完备的功能。但功能、性能、体积必然是一个难以取舍的平衡。考虑到我的轮子定位偏node.js端,体积可以放最后一位(当然为了证明我偏执狂还装逼我还是会尽量把它写小一点)。功能方面,至少对于我而言,需要以下必选功能:

  • 变量输出
  • 循环
  • 条件
  • 函数定义/调用

以及以下可选功能:

  • 变量输出filter
  • block定义与重写
  • 模板继承(配合上面这个食用)
  • include

对于前5点来说,其实都是很容易实现的需求。

其中函数这一块,在Smarty当中有一件很让人头疼的事情,那就是它的模板变量作用域。JS的闭包可以方便的在任何函数里访问当前词法上下文里的局部变量,这是非常方便的,而在PHP里则不行。Smarty自己设计了一个较为复杂的变量作用域系统,感觉吧,不是很好用,我反正没怎么闹得很明白。但在我的轮子里面我要让模板函数可以直接像JS一样,使用函数外层作用域当中的变量。

而对于后面3点,因为涉及到跨模板文件,这在浏览器端实现起来会很难受,比如artTemplate就用#id来搞的,我觉得吧,还是有点鸡肋。

考虑到我以娱乐为主,同时前面也说了,因为MVVM/React这类的东西存在,纯字符串模板引擎在浏览器端存在感越来越弱,那么这样的需求我就只实现在node.js端了,fs.readFileSync走起啊还犹豫什么……

基本思路

模板语言简单地说其实就是一种DSL,模板引擎的作用就是解析、执行DSL。毫无疑问如果使用“解释执行”的办法,这多半是比较慢的。所以能见人的模板引擎一般都用的“编译执行”的办法,比如Smarty,它在第一次执行的时候把模板编译成PHP文件,后续再使用这个模板的时候就省了这一步,直到模板文件时间戳改变。

早年的JS模板引擎其实是没有编译功能的,因为很多人相信Eval is Evil,但前辈们经过实践出真知,通过new Function或者eval将模板编译成一个JS函数,在模板重复执行的过程中毫无疑问比每次都解析是快太多了,于是JS模板引擎的工作流程就成了:

  • 解析模板文件,将其构造成一段JS代码(内含大量字符串拼接)
  • 将上面代码通过new Function或者eval构造成一个render函数
  • 返回render函数,对其传入data参数即可返回一段渲染后的字符串

例如EJS的用法

1
2
var render = ejs.compile(str, options); // 编译后的render函数
var html = render(data); // 渲染结果

如何解析模板文件是第一步,对于使用定界符的模板语言,在不考虑bad case的情况下其实非常简单,就是直接通过按定界符进行split就能把模板语言和“自然语言”区分开来,对于模板语言进行进一步解析,对于“自然语言”则直接构造成字符串拼接就完事儿了。

但对于我的轮子而言,因为它的语法很骚,直接用定界符分割是很不靠谱的事情。所以我选择了使用手写(伪)Parser的办法来解析,解析速度上应该是会慢一些,不过也就几十毫秒的事儿了……几十毫秒在浏览器端会是一个挺严重的事情,可能会掉几帧,但在服务端则可以通过缓存编译结果来让它只在启动时发生一次,影响不大。如果在浏览器端应用会成问题,那么可以采用类似artTemplate的预编译法,不过Done is better than perfect,这些都是后话了。


下一期节目当中就开始进入正题,写一下如何手写Parser将上述语法和功能需求构建成为目标代码。