来吧,造个模板引擎轮子——模板文件解析

上篇,这一篇将介绍JS模板引擎是如何区分出“自然语言”和“模板语言”、以及如何进一步解析“模板语言”,最终我们需要拿到详细的模板语句结构。

这里说的“自然语言”就是只要生成的目标文件,比如HTML/XML;“模板语言”就是……模板语言了。

定界符式的语法

区分模板语言与自然语言

对于定界符式的语法,因为定界符通常都是一些犄角旮旯的符号,比如<%/%>这些符号是很难出现在正常的代码里面的。于是这种类型的模板语言,要从自然语言里面区分出来模板语言比较容易,这里介绍一种简单粗暴但实用的办法。以下面这一段代码为例。

1
2
3
4
5
6
7
<div class="list">
<% if (list.length > 0) %>
<% foreach item in list %>
<p>Hello, <%= item.name %></p>
<% /foreach %>
<% /if %>
</div>

首先用左定界符,这里就是<%,对输入代码进行一次split,可以得到如下的结果(为了“美化”文章我把无用的空格先去了)

1
2
3
4
5
6
<div class="list">
if (list.length > 0) %>
foreach item in list %> <p>Hello,
= item.name %></p>
/foreach %>
/if %></div>

对于上面每一段,查看它是否包含右定界符%>

  • 如果不包含右定界符,那么将这段话以自然语言处理。
    一般来说,它只会是整个源代码的第一段,否则你就会发现它其实是一个找不到与之匹配的空头(跟股票神马的没关系)。
  • 如果包含右定界符,将这段话以它拆分,左边一段是一条模板语言,右边一段以自然语言处理。

经过上面步骤,刚才那些就成为了下面的结果(为了“美化”文章,我给模板语言都加了定界符,这样看起来不会太违和)。

1
2
3
4
5
6
7
8
9
自然 <div class="list">
模板 <% if (list.length > 0) %>
模板 <% foreach item in list %>
自然 <p>Hello,
模板 <%= item.name %>
自然 </p>
模板 <% /foreach %>
模板 <% /if %>
自然 </div>

注意,这里并没有考虑模板语法嵌套的问题,比如在Smarty里,其实是可以这样写的

1
2
3
{% if $list[{% $key %}] == '1' %}
<div class="first"></div>
{% /if %}

注意第一行,在一组模板标签内,又嵌套了一组,用我们上面的那种处理逻辑,遇到这样的代码是会跪的。首先还是先按左定界符拆

1
2
3
4
(空)
if $list[
$key %}] == '1' %}<div class="first"></div>
/if %}

可以发现,第2行是不包含右定界符的,也许我们会把它当做自然语言,但事实上它并不是。而第3行中,有两个右定界符。

这种语法就要复杂一些,我想了一个简单的办法,不知道可不可行。就是对于包含右定界符的段,如果超过1个右定界符,则对应往前“捞回”一段,重新标记成模板语言。但这样依然有一个难处,就是在捞回之后,左边的if $list[与右边的] == '1'这两句话它们其实分别不是一句完整合法的模板语句,于是处理过程中必须把嵌套层级关系保留下来,在后面构建语法树的时候还会用得上(其实这也是Smarty为什么又大又慢的一个重要原因,逃)

解析模板语言的语法

这个环节包含两个步骤,一个是处理每一条模板语句自身的内容,一个是处理他们的匹配关系。

回到刚才的例子,这次先忽略那些自然语言,只看其中的模板语句

1
2
3
4
5
模板 <% if (list.length > 0) %>
模板 <% foreach item in list %>
模板 <%= item.name %>
模板 <% /foreach %>
模板 <% /if %>

先是对于每一条语句本身,需要进行一定的解析,这个阶段暂时只需要对它定性就可以,比如上面几条分别是

1
2
3
4
5
if代码块open
foreach代码块open
变量输出
foreach代码块close
if代码块close

当中不难发现,if/ifforeach/foreach是有匹配关系的,而<%= item.name %>是没有匹配关系的。这样我们需要对刚才输出的语句列表进行一次解析,构造一棵“语法树”。这里之所以要加引号,是因为我们并不需要真正建立起来语法树,至于为什么,也许要下一篇才能具体讲明白,这里就先卖个关子了。

那么我们先只看对应关系,这个很简单,只需要对上面应用一次括号匹配就行了,当然这里匹配的不是括号,而是if/ifforeach/foreach这种对应的语句结构。括号匹配的算法是入门级的,就不赘述了。不过过程中可能需要一些特殊处理,比如if/elseif/else这样的组合并不是简单的对称地匹配就能解决的,但这并不难,事实上到了实际如果你真的要造个轮子你一定会有办法的(逃

raze-tpl非定界符式的语法

因为我的raze-tpl轮子采用的是类似于微软大法的Razor语法,这种语法非常“骚”,下面我们把上述模板代码转换成对应的raze-tpl语法

1
2
3
4
5
6
7
<div class="list">
@if (list.length > 0) {
@foreach (item in list) {
<p>Hello, @(item.name)</p>
}
}
</div>

可以看到没有明显的定界符了,起码是干净了不少的。当然,如果你把@(在raze-tpl暂时把它叫做leading char)当做定界符,再套一下上面的那种算法,似乎……

1
2
3
4
<div class="list">
if (list.length > 0) {
foreach (item in list) {<p>Hello,
(item.name)</p> } }</div>

对于其中的每一段,需要再进行一下对应的语法检测一下它是不是满足某种模板语句类型,才能确定它是不是模板语句,例如

1
2
3
4
自然语言            <div class="list">
@if条件代码块open if (list.length > 0) {
@foreach代码块open foreach (item in list) {<p>Hello,
@() 变量输出代码块</div>

这还没有结束,因为上面的那些模板语句当中,明显还夹杂了自然语言,于是乎依然需要对它进行进一步分析,也就是说,上面的“解析模板语言的语法”这一步是提前了。

这样做头会很晕,于是我用了一个听起来比较笨,但是实现更简单,可控性也更高的方法:逐字parse

回到源代码

1
2
3
4
5
6
7
<div class="list">
@if (list.length > 0) {
@foreach (item in list) {
<p>Hello, @(item.name)</p>
}
}
</div>

以字符为单位,对上面的代码进行遍历

  1. 在遇到@(leading char)之前,一切内容都当自然语言
  2. 遇到@,进行如下判断
    3.1. 它的后续是否是转义字符,如@@转义成@
    3.2. 它的后续是否是括号,如@(item.name)中的(
    3.3. 它的后续是否是raze-tpl语法关键字,如@if@foreach
  3. 在上一步当中,根据判断出的类型,把源代码交给对应的处理器(Handler),处理代码块
  4. Handler处理结束后,遍历的游标已经被Handler挪到代码块结束点,把遍历状态改回自然语言状态
  5. 继续遍历,直到遇到下一个@

不难发现一个重要的点:步骤3、4是递归的

以上面的代码为例

1
2
3
4
5
6
7
8
9
10
<div class="list"> 自然语言
@if代码块 递归
@foreach代码块 递归
<p>Hello, 自然语言
@(item.name) 变量输出 不需要递归
但需要解析这一条语句,结束后遍历游标移动到反括号`)`
</p> 自然语言
} <- foreach代码块所对应的反花括号,foreach代码块结束,遍历游标移动到此
} <- if代码块所对应的反花括号,if代码块结束,遍历游标移动到此
</div> 自然语言

由于每一种类型、每一种模板语法关键字都有自己对应的语法结构和逻辑,因此在上面的递归过程中同时完成了上述“匹配、自身解析、遍历(递归)”这3件事情。这个过程大体上说就是编译原理当中的递归下降分析法的思路。

因为需要在高阶代码(JS)层面进行字符串遍历,而用split的办法仅仅需要在低阶(引擎内置函数)层面上操作,这种办法运行速度肯定是会比split要慢的。但如前作考虑,在服务端使用的时候,可以缓存编译结果,编译过程是一次性的,影响不大;在浏览器端使用的时候,如果当真遇到编译影响了运行速度和体验,可以考虑使用art-template类似的模板预编译方式。总之编译这件事情,速度上并没有太重要,也许在某些大工程里编译速度很重要,但对我们这些模板它也不可能会太大。

而编译器的小小性能损失却换来了灵活性还有这么骚的语法,还是很好玩的!

小结

这篇文章讲了对于两种风格的模板语法,如何对它进行“词法分析”和“语法分析”,这里加引号,还是因为模板语言是一种混合语言,对它进行处理的时候与直接造一个某种语言的编译器还是有很大的区别的,尽管思路还是源于编译原理。

不过事实上这种规模的parser,手写比机写(通过parser generator生成)相比反而还要容易些。而且手写的高可控也更方便我们进行下一步工作,也就是下一篇文章要写的内容——生成render函数。