Node.js模板引擎的深入探讨
每次当我想用 node.js 来写一个 web 相关项目的时候。我总是会陷入无比的纠结。原因是 JavaScript 生态圈里的模板引擎实在太多了,但那么多却实在找不出一个接近完美的,所谓完美的概念就是功能丰富,书写简单,前后端可共用等一些属性。尽管能够在 Template Chooser 按功能进行挑选。但挑选的结果再用来对照还是各有各的问题。
所以干脆就一些模板引擎进行略微深入的分析,希望通过对照总结出哪种更值得去使用。
第一轮排除
在上次node模板引擎简单比較的文章里。事实上已经有个简单的筛选了。总结成规则应该是这种:
-
首先避免使用须要对 HTML 进行转换的 Jade 之类。
对于这类须要翻译才干使用的语言工具我是坚决的抵制,比方恶心的CoffeeScript。原因是这根本不是一个必要的过程。并且创造一种浏览器默认不支持的语言来表达首先提高了学习成本。特别是假设在团队中合作那就有必要让每一个人都学会并配置上适合的开发工具。其次还要再翻译回来,说的难听点实在是脱裤子放屁,即使有自己主动化工具。但带来便捷上的收益抵只是徒增的成本。
-
第二类是原生或者完整语法的 EJS 之类
这类引擎本身没有什么太大的问题,对于学习成本来说 EJS 是很低的,会 JS 就会写,可是相比較于后来的 mustache 轻逻辑系列,那么必定认为在模板中写完整的 JavaScript 语法实在太麻烦。并且在模板中。核心是模板,而不是编程语言。所以表达方式应该更偏重于模板。
-
其次针对模板的功能进行考虑
事实上这应该是作为首要考虑的原因,由于功能才是模板引擎的核心,可是实现一个模板引擎在如今来看并非什么太困难的事情。所以大部分功能都差点儿相同,那么主要考虑的就是差异部分。
比方模板继承和引用是我看中的一个必要功能。能够非常大程度上提高复用性。另外可能考虑的是输出变量表达式或者管道过滤器之类辅助功能。有这些会方便非常多。
-
最后才是性能的考虑
由于上面说到模板并非太复杂的东西,性能上面一般不用特别关注。由于大多数引擎都会带有预编译的功能,当一个模板预编译成简单的拼接函数,一般是不会有太大的性能压力。那么这一条考察的基本就是能否预编译。
最后在 Template Chooser 上依据条件选择下来,就剩这些了:
基本上就剩下 mustache 系了尽管之前也接触了一些的模板引擎,传统的比方 PHP 的 Smarty。Java 的 Velocity,甚至曾经公司里跨平台的火麒麟等,但我还是承认看过一次 mustache 就对这个系列产生了偏好,那么接下来就具体分析一下他的一些特性。
mustache类引擎特性分析
使用 #
作为统一判定符号
长处是简洁, if
/ for
都能够通吃。但缺点是依赖于必选变量,非常难将推断条件其扩展成表达式。另外 for
的索引变量名也无法设置。
当 #xxx
是一个 Object 类型值时。是应该依照 if
存在推断还是依照 for-in
遍历?眼下是推断存在并创建下级作用域,这就导致无法使用
Object 类型作为 Map 进行 for-in
遍历。
另一点,假设是在进行一个 list 的循环时,无法定义循环项和索引的变量名。非常难利用到索引这个特殊变量。只是在 GRMustache 的实现中有增强,能够使用 @index
特殊变量,但这不是一个比較好的解决方式。
#
作用域效果
#
模块生成了子级作用域,使模块内的子级变量得到简写。缺点是设计渲染引擎的时候可能对于scope的情况考虑起来比較复杂,比方不同层次的同名变量,有可能会导致性能问题。
模板符号
除了变量输出,控制语句的双括号有点多余了,能够考虑降低一层,由于内部还是一个符号,造成代码冲突的几率事实上非常低。另外模板语句的结束符名称事实上也能够省略掉,正如语法分析过程中的关闭括号。演示样例:
{#test}
* {{variable}}
{/}
可能原始设计以检測到双括号作为打开解析引擎的标志,假设改为单括号,后面碰到非模板的字符须要回朔一位。
分支语句
因为条件推断都合并到了 #
符号的语句中。而推断因为要简化多种形式。导致不能使用表达式。而 mustache 仅仅设计了 ^
用来取反的一种表达方式,实际上和 #
都没有不论什么关联,在表达能力上就比較不足,比方想用 else
if
就非常麻烦。
类似 switch/case
的多条件分支就更难实现,尽管用的也不多,可是还是会有一定机会。
模板复用
在 mustache 里仅仅有一种,就是引入模板片段,类似于其它引擎的 include。符号是{{>partial}}
。
并且这指定的是模板名,在后端程序中一般是直接寻找文件名称,但还须要自己映射。
另外除了Handlebars其它也不支持继承形式的模板复用。所以我之前写了MustLayout这个npm包来在express中预处理这两个缺陷。
变量
变量在 mustache 中很easy,差点儿仅仅有模板替换的功能。
而在其它引擎中,能够对暂时变量赋值,输出能够使用表达式,或者管道过滤器等便捷的方法。
对照其它模板引擎的突出特性
暂时变量赋值
如 liquid/Smarty/Swig 等中,能够在渲染模板时创建暂时变量,在某些情况下有一定的便利性。
比方在不同模板里引用一个模板片段,该片段中的某个变量名是固定的。但在不同地方引用的时候变量名不同,此时能够在引用之前声明一个统一的变量,帮助统一引用。
这个特性一定程度上也能够由函数模板来完毕。
变量过滤器
在 liquid/Smarty/Swig/Etpl 等中,能够通过类似 *nix shell 的管道模式,对要输出的变量进行很多其它处理,比方日期格式化。编码转义等功能。
{{ aDate | date(‘Y-m-d') }}
Swig的块继承
在继承 block 块时能够使用父模板中已定义的部分。方便的追加很多其它内容,比方 CSS 和 JS 的引用部分:
{% block head %}
{% parent %}
<link rel="stylesheet" href="custom.css">
{% endblock %}
Dust.js的继承
Dust.js 的继承方式看起来比較诡异,是使用一个正常理解应该是 include 的方式来实现的。并且符号也是从 mustache 系继承过来的 {>parent}
,而只在之后定义
block 区块,对父模板进行覆盖来实现。从实现的角度看这是一个比較取巧的方式,由于假设不过声明 layout 。那么声明语句究竟放在模板的哪里比較合适?假设声明两次是否会造成问题?而通过引入的话就比較直白了,无论如何这是必须写的且只会写一次。我是要用父模板。先拿进来。之后的 block 部分实际上是重名再次定义的赋值过程。
issue里甚至有人提到这样的写法应该使用开闭标签。让 {>parent}…{/parent}
之间包括其block的内容,也有道理,可是写起来是略有复杂,不够直白。
Etpl的引用带入
在 include 一个模板片段时代入一个自己定义的块。以覆盖片段中的部分内容。这给 block 除了向上继承以外很多其它的一种灵活性。
<!-- import: main -->
<!-- block: main -->
<div class="list">list</div>
<div class="pager">pager</div>
<!-- /block -->
<!-- /import -->
Etpl的扩展转换引擎
在 Etpl 中称为过滤器,眼下用例是将 Markdown 格式的模板内容转换成HTML,有一定价值。但不一定是必须功能,能够考虑作为扩展实现。
<!-- filter: markdown(${useExtra}, true) -->
## markdown document
This is the content, also I can use `${variables}`
<!-- /filter -->
期待 mustache 增强的特性
对照了那么多,事实上说对 mustache 最基本的偏好还是来自于模板语言表达的间接性,而对于他最核心的轻逻辑来说。有点太轻。尽管我不须要完整的原生语言控制,但轻的难以表达了就还是须要权衡。
终于我把我期待的模板引擎的样子描绘出来。看看是不是有人和我一样。
最基本的变量还是使用双括号,而控制语句使用但括号+特殊字符,同一时候关闭能够为自结束。而且不须要写相应的关闭标签名。
变量输出
使用双括号在模板中输出变量:
{{ variable }}
{{ nested.element }}
{{ array[index] }}
{{ object[key] }}
输出能够使用带运算符的简单表达式:
{{ ok ? 1 : 0 }}
{{ ok || 'none' }}
{{ index * (x + 3) }}
能够使用过滤器管道:
{{ variable | escapeHTML }}
{{ today | date:'Y-m-d' }}
{{ group | max }}
默认不进行 HTML 转义。这样能够支持很多其它情景,而不是 HTML 专属。相反使用三括号才进行默认转义:
{{{ content }}}
能够使用等号 =
进行暂时变量赋值,但赋值使用专门的 $
符号语句且须要自关闭符号:
{$ x = y * 5 /}
{$ obj = {a: 1, b: []} /}
变量作用域没有发现太大的必要性。并且可能造成性能问题,临时取消。
条件分支
尽管 mustache 的 #
功能非常强大。但表达能力略有欠缺且easy造成歧义,所以我还是把条件分支单独拿出来。
if
语法用问号开头表达,和条件表达式一样有疑问的意思:
{? expression }
true
{/}
{? !condition }
false
{/}
else
语法借用原来的 ^
符号,且不再能够单独使用这个取反符号:
{? expression}
true
{^}
false
{/}
else if
类型的多条件继续使用 ^
符号进行额外推断:
{? case1 }
1
{^ case2 }
2
{^}
-1
{/}
临时没想到怎样简洁的表达对同一条件的 switch/case
表达,先用 else
if
结构取代。
循环迭代
普通的 for
循环继续使用 #
,但添加迭代条目和索引暂时变量声明:
{# list:item@index }
<li>{{ index }}: {{ item }}</li>
{/}
循环能够针对普通数组。也能够针对 Object 类型的对象:
{# map:value@key }
<li>{{ key }}: {{ value }}</li>
{/}
能够联合取反符号 ^
使用。输出没有元素项时的内容:
{# []:item }
{{ item }}
{^}
none items :(
{/}
模板复用
内嵌模板片段:
{> partialName /}
模板名称能够在 API 中分情况实现。比方在后端 node.js 环境中,模板名直接相应相对路径进行文件读取;而在前端假设是使用 <script type="text/template">
方式加载的,能够在相应标签属性,通过
DOM 选择器读取。
能够考虑引入 etpl 的片段替换扩展:
{> partialName }
{+ blockName }My Title{/}
{/}
继承上级模板能够考虑引入 Swig 的父级块引用:
<!-- parent -->
{+ scripts}
<script type="text/javascript" src="lib.js"></script>
{/}
<!-- target -->
{< parent /}
{+ scripts }
{+/}<!-- 内嵌上级的块 -->
<script type="text/javascript" src="main.js"></script>
{/}
这样的声明式写法比較easy理解。而假设要实现简单,能够学习 dust.js 直接利用片段插入扩展的方法。
引擎规则
这样设计的符号体系,让引擎能够从最简单的规则出发,并减少冲突的可能性。
- 默认进入文本状态,当遇到第一个開始括号+控制符号
/\{([\{\$\?\/\+#^<>}])/
时。进入控制状态; - 依据
RegExp.$1
的符号推断进入何种控制流程。比方是{
则直接准备输出; - 不论什么 非 变量输出
{
控制流程必须有相应的关闭结束标签{/}
或者自关闭.../}
,仅仅有取反条件操作{^}
例外; - 取反操作符
{^}
必须嵌套在条件语句和循环语句中使用; - 仅仅要模板声明了继承
{< parent /}
,则会忽略不论什么{+ block }...{/}
标签包围之外的内容。 - 继承和嵌入片段都能够递归;
前后端共用模板的一些问题
问题一:后端能够用文件名称取代模板名,但前端没有。
所以要在前端生成模板名。
- 用script标签发送到前端,解决模板名又能合并。缺点是占用(污染)了前端的id,可能会产生冲突。
(事实上也能够用其它元素属性来标识,反正都是使用querySelector类的查询)
- 加入一个模板名。并交给前端解析。
缺点是添加了一种模板结构和命名,设计不好的话可能非常难得到认可。
假设生成后,前后端模板名不一致,也会导致无法复用。比方须要include的时候,后端默认是文件路径。但前端一般的模板名称不会带有斜杠 /
字符。
问题二:在后端 render 完某个 path 的页面后,前端接管并使用 ajax 方式,此时使用 History API 在 route 到下一个 path 的时候,调用 view 的相应模板怎样推断使用那一层 layout?
问题三:后端和前端在模板中使用的变量名或者层次往往不一致,假设仅作为片段问题到不大。但假设是整页渲染,可能须要额外的针对性处理。
API设计
var Mustplus = Class({ config: { ifStart: ['{?', '}'], ifEnd: '{/}', elseWord: ['{^', '}'], forExp: /\{#(\w+)(?:\:(\w+))?(?
:@(\w+))\}?/, forEnd: '{/}' // ... }, templates: {}, compiled: {}, constructor: function (options) { this.read = browser ? this.readDOM : this.readFile; }, readDOM: function (name) { return $('script[type=text/template][name=' + name + ']').html(); }, readFile: function (file) { return fs.readFileSync(path.join(this.base, file)); }, // 继承扩展 extend: function (name) { }, include: function (template) { return template.match(includeRE) ? template.replace(includeRE, function (name) { return this.include(this.read(name)); }) : template; }, resolve: function (name) { return this.include(this.extend(name)); }, compile: function (name) { var content = this.resolve(name); var stream = this.parse(content); var fn = ''; while(token = stream.next()) { switch(token.type) { case 'text': default: fn += text; } } return new Function('data', fn); }, cache: function (name) { return this.compiled[name] || (this.compiled[name] = this.compile(name)); }, render: function (name, data) { return this.cache(name)(data); } }); var engine = new Mustplus({/*if config*/}); var template = engine.compile('xxx'); template(data); // or engine.render('xxx', data);
最后
眼下市面上最接近我想法的应该就是 dust.js 了。难怪 LinkedIn 的project团队 通过对照最后选择的也是 dust.js 。当然如我期待的话还有能够改进的地方,YY总是要有的。万一哪天顺手就实现了呢?