本文同步更新在: ,在 github 看文章显示效果会更好一些。
不知不觉就很长时间没造过什么轮子了,以前一直想自己实现一个模板引擎,只是没付诸于行动,最近终于在业余时间里抽了点时间写了一下。
因为我们的项目用的是 nunjucks ,于是就想实现一个类似的模板引擎。当然只是写着练手而已,也没想着实现全部功能。
一个模板引擎,在我看来,就是由两块核心功能组成,一个是用来将模板语言解析为 ast(抽象语法树)。还有一个就是将 ast 再编译成 html。
先说明一下 ast 是什么,已知的可以忽略。
在实现具体逻辑之前,先决定要实现哪几种 tag 的功能,在我看来,
,
- for
,
- if else
,
- set
还有就是基本的变量输出,有了这几种,模板引擎基本上也就够用了。除了 tag,还有就是 filter 功能也是必须的。
- raw
我们需要把模板语言解析成一个又一个的语法节点,比如下面这段模板语言:
- <div>
- {% if test > 1 %}
- {{ test }}
- {% endif %}
- </div>
很明显,div 将会被解析为一个文本节点,然后接着是一个块级节点 if ,然后 if 节点下又有一个变量子节点,再之后有是一个 的文本节点,用 json 来表示这个模板解析成的 ast 就可以表示为:
- [
- {
- type: 1,
- text: '<div>'
- },
- {
- type: 2,
- tag: 'if',
- item: 'test > 1',
- children: [{
- type: 3,
- item: 'test'
- }]
- },
- {
- type: 1,
- text: '</div>'
- }
- ]
基本上就分成三种类型了,一种是普通文本节点,一种是块级节点,一种是变量节点。那么实现的话,就只需要找到各个节点的文本,并且抽象成对象即可。一般来说找节点都是根据模板语法来找,比如上面的块级节点以及变量节点的开始肯定是
或者
- {%
,那么就可以从这两个关键字符下手:
- {{
- ...
- const matches = str.match(/{{|{%/);
- const isBlock = matches[0] === '{%';
- const endIndex = matches.index;
- ...
通过上面一段代码,就可以获取到处于文本最前面的
或者
- {{
位置了。
- {%
既然获取到了第一个非文本类节点的位置,那么该节点位置以前的,就都是文本节点了,因此就已经可以得到第一个节点,也就是上面的
了。
- <div>
获取到 div 文本节点后,我们也可以知道获取到的第一个关键字符是
,也就是上面的
- {%
是我们要的索引,记得要更新剩余的字符,直接通过 slice 更新即可:
- endIndex
- // 2 是 {% 的长度
- str = str.slice(endIndex + 2);
而此时我们就可以知道匹配到的当前关键字符是
,那么他的闭合处就肯定是
- {%
,因此就可以再通过
- %}
- const expression = str.slice(0, str.indexOf('%}'))
因为 if 是个块级节点,那么继续往下匹配的时候,在遇到
之前的所有节点,都是属于 if 节点的子节点,所以我们在创建节点时要给它一个
- {% endif %}
数组属性,用来保存子节点。
- children
紧接着再重复上面的操作,获取下一个
以及
- {%
的位置,跟上面的逻辑差不多,获取到
- {{
的位置后再判断
- {{
的位置,就可以创建第三个节点,test 的变量节点,并且 push 到 if 节点的子节点列表中。
- }}
创建完变量节点后继续重复上述操作,就能够获取到
这个闭合节点,当遇到该节点之后的节点,就不能保存到 if 节点的子节点列表中了。紧接着就又是一个文本节点。
- {% endif %}
相对比较完整的实现如下:
- const root = [];
- let parent;
- function parse(str){
- const matches = str.match(/{{|{%/);
- const isBlock = matches[0] === '{%';
- const endIndex = matches.index;
- const chars = str.slice(0, matches ? endIndex : str.length);
- if(chars.length) {
- ...创建文本节点
- }
- if(!matches) return;
- str = str.slice(endIndex + 2);
- const leftStart = matches[0];
- const rightEnd = isBlock ? '%}' : '}}';
- const rightEndIndex = str.indexOf(rightEnd);
- const expression = str.slice(0, rightEndIndex)
- if(isBlock) {
- ...创建块级节点 el
- parent = el;
- } else {
- ...创建变量节点 el
- }
- (parent ? parent.children : root).push(el);
- parse(str.slice(rightEndIndex + 2));
- }
创建好 ast 后,要渲染 html 的时候,就只需要遍历语法树,根据节点类型做出不同的处理即可。
比如,如果是文本节点,就直接
即可。如果是
- html += el.text
节点,则判断表达式,比如上面的
- if
,有两种办法可以实现表达式的计算,一种就是
- test > 1
,还有一种就是
- eval
了,eval 会有安全性问题,因此就不考虑了,而是使用
- new Function
的方式来实现。变量节点的计算也一样,用
- new Function
来实现。
- new Function
封装后具体实现如下:
- function computedExpression(obj, expression) {
- const methodBody = `
- return ($ {
- expression
- })`;
- const funcString = obj ? `with(__obj__) {
- $ {
- methodBody
- }
- }`: methodBody;
- const func = new Function('__obj__', funcString);
- try {
- let result = func(obj);
- return (result === undefined || result === null) ? '': result;
- } catch(e) {
- return '';
- }
- }
使用 with ,可以让在 function 中执行的语句关联对象,比如
- with({
- a: '123'
- }) {
- console.log(a); // 123
- }
虽然 with 不推荐在编写代码的时候使用,因为会让 js 引擎无法对代码进行优化,但是却很适合用来做这种模板编译,会方便很多。包括 vue 中的 render function 也是用 with 包裹起来的。不过 nunjucks 是没有用 with 的,它是自己来解析表达式的,因此在 nunjucks 的模板语法中,需要遵循它的规范,比如最简单的条件表达式,如果用 with 的话,直接写
,但是在 nunjucks 中却要写成
- {{ test ? 'good' : 'bad' }}
。
- {{ 'good' if test else 'bad' }}
anyway,各有各的好吧。
实现上面功能后,组件就已经具备基本的模板渲染能力,不过在用模板引擎的时候,还有一个很常用的功能就是 filter 。一般来说 filter 的使用方式都是这这样
,这个的实现也说一下,这一块的实现我参考了 vue 的解析的方式,还是蛮有意思的。
- {{ test | filter1 | filter2 }}
还是举个例子:
- {
- {
- test | filter1 | filter2
- }
- }
在构建 AST 的时候,就可以获取到其中的
,然后我们可以很简单的就获取到 filter1 和 filter2 这两个字符串。起初我的实现方式,是把这些 filter 字符串扔进 ast 节点的 filters 数组中,在渲染的时候再一个一个拿出来处理。
- test | filter1 | filter2
不过后来又觉得为了性能考虑,能够在 AST 阶段就能做完的工作就不要放到渲染阶段了。因此就改成 vue 的方法组合方式。也就是把上面字符串变成:
- _$f('filter2', _$f('filter1', test))
预先用个方法包裹起来,在渲染的时候,就不需要再通过循环去获取 filter 并且执行了。具体实现如下:
- const filterRE = /(?:\|\s*\w+\s*)+$/;
- const filterSplitRE = /\s*\|\s*/;
- function processFilter(expr, escape) {
- let result = expr;
- const matches = expr.match(filterRE);
- if (matches) {
- const arr = matches[0].trim().split(filterSplitRE);
- result = expr.slice(0, matches.index);
- // add filter method wrapping
- utils.forEach(arr, name = >{
- if (!name) {
- return;
- }
- // do not escape if has safe filter
- if (name === 'safe') {
- escape = false;
- return;
- }
- result = `_$f('${name}', $ {
- result
- })`;
- });
- }
- return escape ? `_$f('escape', $ {
- result
- })`: result;
- }
上面还有一个就是对 safe 的处理,如果有 safe 这个 filter ,就不做 escape 了。完成这个之后,有 filter 的 variable 都会变成
这种形式了。因此,此前的 computedExpression 方法也要做一些改造了。
- _$f('filter2', _$f('filter1', test))
- function processFilter(filterName, str) {
- const filter = filters[filterName] || globalFilters[filterName];
- if (!filter) {
- throw new Error(`unknown filter $ {
- filterName
- }`);
- }
- return filter(str);
- }
- function computedExpression(obj, expression) {
- const methodBody = `
- return ($ {
- expression
- })`;
- const funcString = obj ? `with(_$o) {
- $ {
- methodBody
- }
- }`: methodBody;
- const func = new Function('_$o', '_$f', funcString);
- try {
- const result = func(obj, processFilter);
- return (result === undefined || result === null) ? '': result;
- } catch(e) {
- // only catch the not defined error
- if (e.message.indexOf('is not defined') >= 0) {
- return '';
- } else {
- throw e;
- }
- }
- }
其实也是很简单,就是在 new Function 的时候,多传入一个获取 filter 的方法即可,然后有 filter 的 variable 就能被正常识别解析了。
至此,AST 构建、AST 到 html 的转换、Filter 的实现,都已经基本讲解完成。
贴一下自己实现的一个模板引擎轮子:
算是实现了大部分模板引擎该有的功能,欢迎各路豪杰 star 。
来源: http://www.cnblogs.com/axes/p/6542498.html