怎么用JavaScript实现一个模板引擎
这篇文章给大家分享的是有关怎么用JavaScript实现一个模板引擎的内容。小编觉得挺实用的,因此分享给大家做个参考,一起跟随小编过来看看吧。
功能分析
一个模板引擎,在我看来,就是由两块核心功能组成,一个是用来将模板语言解析为 ast(抽象语法树)。还有一个就是将 ast 再编译成 html。
先说明一下 ast 是什么,已知的可以忽略。
抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。之所以说语法是"抽象"的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于if-condition-then这样的条件跳转语句,可以使用带有两个分支的节点来表示。
在实现具体逻辑之前,先决定要实现哪几种 tag 的功能,在我看来,for
,if else
,set
,raw
还有就是基本的变量输出,有了这几种,模板引擎基本上也就够用了。除了 tag,还有就是 filter 功能也是必须的。
构建 AST
我们需要把模板语言解析成一个又一个的语法节点,比如下面这段模板语言:
{% if test > 1 %} {{ test }} {% endif %}
很明显,div 将会被解析为一个文本节点,然后接着是一个块级节点 if ,然后 if 节点下又有一个变量子节点,再之后有是一个 的文本节点,用 json 来表示这个模板解析成的 ast 就可以表示为:
[ {type: 1, text: ''}, {type: 2, tag: 'if', item: 'test > 1', children: [{ type: 3, item: 'test'}] }, {type: 1, text: ''}]
基本上就分成三种类型了,一种是普通文本节点,一种是块级节点,一种是变量节点。那么实现的话,就只需要找到各个节点的文本,并且抽象成对象即可。一般来说找节点都是根据模板语法来找,比如上面的块级节点以及变量节点的开始肯定是{%
或者{{
,那么就可以从这两个关键字符下手:
...const matches = str.match(/{{|{%/);const isBlock = matches[0] === '{%';const endIndex = matches.index;...
通过上面一段代码,就可以获取到处于文本最前面的{{
或者{%
位置了。
既然获取到了***个非文本类节点的位置,那么该节点位置以前的,就都是文本节点了,因此就已经可以得到***个节点,也就是上面的 获取到 div 文本节点后,我们也可以知道获取到的***个关键字符是 而此时我们就可以知道匹配到的当前关键字符是 获取到 因为 if 是个块级节点,那么继续往下匹配的时候,在遇到 紧接着再重复上面的操作,获取下一个 创建完变量节点后继续重复上述操作,就能够获取到 相对比较完整的实现如下: 当然,具体实现起来还是有其他东西要考虑的,比如一个文本是 创建好 ast 后,要渲染 html 的时候,就只需要遍历语法树,根据节点类型做出不同的处理即可。 比如,如果是文本节点,就直接 封装后具体实现如下: 使用 with ,可以让在 function 中执行的语句关联对象,比如 虽然 with 不推荐在编写代码的时候使用,因为会让 js 引擎无法对代码进行优化,但是却很适合用来做这种模板编译,会方便很多。包括 vue 中的 render function 也是用 with 包裹起来的。不过 nunjucks 是没有用 with 的,它是自己来解析表达式的,因此在 nunjucks 的模板语法中,需要遵循它的规范,比如最简单的条件表达式,如果用 with 的话,直接写 anyway,各有各的好吧。 在将 ast 转换成 html 的时候,有一个很常见的场景就是多级作用域,比如在一个 for 循环中再嵌套一个 for 循环。而如何在做这个作用域分割,其实也是很简单,就是通过递归。 比如我的对一个 ast 树的处理方法命名为: 那么 processAst 就可以这么实现: 就简单通过一个递归,就可以把作用域一直传递下去了。 实现上面功能后,组件就已经具备基本的模板渲染能力,不过在用模板引擎的时候,还有一个很常用的功能就是 filter 。一般来说 filter 的使用方式都是这这样 还是举个例子: 在构建 AST 的时候,就可以获取到其中的 不过后来又觉得为了性能考虑,能够在 AST 阶段就能做完的工作就不要放到渲染阶段了。因此就改成 vue 的方法组合方式。也就是把上面字符串变成: 预先用个方法包裹起来,在渲染的时候,就不需要再通过循环去获取 filter 并且执行了。具体实现如下: 上面还有一个就是对 safe 的处理,如果有 safe 这个 filter ,就不做 escape 了。完成这个之后,有 filter 的 variable 都会变成 其实也是很简单,就是在 new Function 的时候,多传入一个获取 filter 的方法即可,然后有 filter 的 variable 就能被正常识别解析了。 感谢各位的阅读!关于"怎么用JavaScript实现一个模板引擎"这篇文章就分享到这里了,希望以上内容可以对大家有一定的帮助,让大家可以学到更多知识,如果觉得文章不错,可以把它分享出去让更多的人看到吧!{%
,也就是上面的endIndex
是我们要的索引,记得要更新剩余的字符,直接通过 slice 更新即可:// 2 是 {% 的长度str = str.slice(endIndex + 2);
{%
,那么他的闭合处就肯定是%}
,因此就可以再通过const expression = str.slice(0, str.indexOf('%}'))
if test > 1
这个字符串了。然后我们再通过正则/^if\s+([\s\S]+)$/
匹配,就可以知道这个字符串是 if 的标签,同时可以获得test > 1
这一个捕获组,然后就可以创建我们的第二个节点,if 的块级节点了。{% endif %}
之前的所有节点,都是属于 if 节点的子节点,所以我们在创建节点时要给它一个children
数组属性,用来保存子节点。{%
以及{{
的位置,跟上面的逻辑差不多,获取到{{
的位置后再判断}}
的位置,就可以创建第三个节点,test 的变量节点,并且 push 到 if 节点的子节点列表中。{% endif %}
这个闭合节点,当遇到该节点之后的节点,就不能保存到 if 节点的子节点列表中了。紧接着就又是一个文本节点。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) { ...创建块级节点 elparent = el; } else { ...创建变量节点 el } (parent ? parent.children : root).push(el); parse(str.slice(rightEndIndex + 2));}
{% {{ test }}
,就要考虑到{%的干扰等。还有比如 else 还有 elseif 节点的处理,这两个是需要关联到 if 标签上的,这个也是需要特殊处理的。不过大概逻辑基本上就是以上。组合 html
html += el.text
即可。如果是if
节点,则判断表达式,比如上面的test > 1
,有两种办法可以实现表达式的计算,一种就是eval
,还有一种就是new Function
了,eval 会有安全性问题,因此就不考虑了,而是使用new Function
的方式来实现。变量节点的计算也一样,用new Function
来实现。function computed_Expression(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({ a: '123' }) {console.log(a); // 123}
{{ test ? 'good' : 'bad' }}
,但是在 nunjucks 中却要写成�{{ 'good' if test else 'bad' }}
。实现多级作用域
processAst(ast, scope)
,再比如最初的 scope 是{ list: [ { subs: [1, 2, 3] }, { subs: [4, 5, 6] } ] }
function processAst(ast, scope) { ...if(ast.for) {const list = scope[ast.item]; // ast.item 自然就是列表的 key ,比如上面的 listlist.forEach(item => { processAst(ast.children, Object.assign({}, scope, { [ast.key]: item, // ast.key 则是 for key in list 中的 key})) }) } ...}
Filter 功能实现
{{ test | filter1 | filter2 }}
,这个的实现也说一下,这一块的实现我参考了 vue 的解析的方式,还是蛮有意思的。{{ test | filter1 | filter2 }}
test | filter1 | filter2
,然后我们可以很简单的就获取到 filter1 和 filter2 这两个字符串。起初我的实现方式,是把这些 filter 字符串扔进 ast 节点的 filters 数组中,在渲染的时候再一个一个拿出来处理。_$f('filter2', _$f('filter1', test))
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 wrappingutils.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;}
_$f('filter2', _$f('filter1', test))
这种形式了。因此,此前的 computedExpression 方法也要做一些改造了。function processFilter(filterName, str) { const filter = filters[filterName] || globalFilters[filterName]; if (!filter) {throw new Error(`unknown filter ${filterName}`); } return filter(str);}function computed_Expression(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 errorif (e.message.indexOf('is not defined') >= 0) { return ''; } else { throw e; } }}