当前位置: 首页 > 科技观察

说说如何用JavaScript实现模板引擎_0

时间:2023-03-21 13:22:22 科技观察

前言好久没造轮子了,不知不觉。一直想自己实现一个模板引擎,但是一直没有付诸实践。最近,我终于在空闲时间抽了它。我花了一些时间来写它。因为我们的项目大多使用swig或者nunjucks,所以想实现一个类似的模板引擎。至于为什么要做这样的事情?基本上每个从事前端工作的人都会有自己的框架梦,而??一个成熟的前端框架,模板编译能力是其中非常重要的一环,虽然目前市面上的框架大多如vue和angular,属于dombase,而swignunjucksejs都属于stringbase,但其实实现上都差不多。无非就是Template=parse=>Ast=render=>String。再者,作为一个模板引擎,个人觉得对提高自己的编码能力还是很有帮助的,尤其是在性能优化、正则化、字符分析等方面。在以后的业务需求中,如果有一些与解析字符串相关的需求,会更加得心应手。功能分析一个模板引擎,在我看来,由两个核心功能组成,一个是用来将模板语言解析成ast(抽象语法树)。另一种是将ast编译成html。先解释一下ast是什么,已知的可以忽略。抽象语法树(abstractsyntaxtree或简称AST),或语法树(syntaxtree),是源代码抽象语法结构的树状表示,特指一种编程语言的源代码。树上的每个节点代表源代码中的一个结构。语法之所以“抽象”,是因为这里的语法并不代表真正语法中出现的每一个细节。例如,嵌套括号隐含在树结构中,不以节点的形式呈现;而像if-condition-then这样的条件跳转语句可以用具有两个分支的节点来表示。在实现具体逻辑之前,首先要决定实现哪些标签功能。在我看来,for,ifelse,set,raw,基本变量输出。有了这些类型,模板引擎就基本够用了。除了标签,过滤功能也是必不可少的。构建AST需要将模板语言解析成一个个语法节点,比如下面的模板语言:

{%iftest>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:'
'}]基本分为三种,一种是普通文本节点,一种是块级节点,一种是变量节点。那么如果实现的话,只需要找到每个节点的文本,抽象成一个对象即可。一般来说,查找节点是基于模板语法。比如上面的块级节点和变量节点必须以{%或{{开头,那么可以从这两个关键字符开始:...constmatches=str.match(/{{|{%/);constisBlock=matches[0]==='{%';constendIndex=matches.index;...通过上面的代码,可以得到文本最前面的{{或{%的位置。既然得到了第一个非文本节点的位置,那么这个位置之前的节点都是文本节点,所以已经可以得到第一个节点,也就是上面的
。得到div文本节点后,我们也可以知道最后得到的关键字符是{%,也就是上面的endIndex就是我们要的索引,记得更新剩下的字符,直接通过slice更新就可以了://2是{%str=str.slice(endIndex+2);的长度而此时我们可以知道当前匹配到的关键字符是{%,那么它的闭包一定是%},那么我们就可以通过constexpression=str.slice(0,str.indexOf)得到stringiftest>1('%}'))。然后我们通过正则模式/^if\s+([\s\S]+)$/来匹配,就可以知道这个字符串是if的标签,同时我们可以得到test的捕获组>1,然后我们就可以创建我们的if的第二个节点是块级节点。因为if是块级节点,继续往下匹配的时候,遇到{%endif%}之前的所有节点都是if节点的子节点,所以我们需要在创建节点的时候给它一个children数组属性,用来存放子节点。然后重复上面的操作,得到下一个{%和{{的位置,和上面的逻辑类似。得到{{的位置再判断}}的位置后,就可以创建第三个节点,test节点的变量,push到if节点的子节点列表中。创建变量节点后,继续重复上述操作,即可得到闭合节点{%endif%}。遇到这个节点之后的节点,不能保存到if节点的子节点列表中。接下来是另一个文本节点。比较完整的实现如下:constroot=[];letparent;functionparse(str){constmatches=str.match(/{{|{%/);constisBlock=matches[0]==='{%';constendIndex=matches.index;constchars=str.slice(0,matches?endIndex:str.length);if(chars.length){...创建文本节点}if(!matches)return;str=str.slice(endIndex+2);constleftStart=matches[0];constrightEnd=isBlock?'%}':'}}';constrightEndIndex=str.indexOf(rightEnd);constexpression=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创建ast后,在渲染html时,只需要遍历语法树,根据节点类型做不同的处理即可。比如如果是文本节点,就html+=el.text。如果是if节点,则判断表达式,比如上面的test>1,表达式的计算有两种实现方式,一种是eval,一种是newFunction,eval会有安全问题,所以只是不考虑,而是使用newFunction方法来实现。变量节点的计算也是一样的,都是用newFunction实现的。封装后的具体实现如下:functioncomputedExpression(obj,expression){constmethodBody=`return(${expression})`;constfuncString=obj?`with(__obj__){${methodBody}}`:方法体;constfunc=newFunction('__obj__',funcString);尝试{让结果=func(obj);返回(结果===未定义||结果===空)?'':结果;}catch(e){返回'';}}使用with,可以将函数中执行的语句与对象关联起来,比如with({a:'123'}){console.log(a);//123}虽然写代码的时候不建议使用with,因为会让js引擎无法优化代码,但是非常适合这种模板编译,会方便很多。包括vue中的render函数也是用with包裹的。但是nunjucks没有使用with,它自己解析表达式,所以在nunjucks的模板语法中,需要按照它的规范,比如最简单的条件表达式,如果使用with,直接写{{test?'good':'bad'}},但在nunjucks中它必须写成?{{'good'iftestelse'bad'}}。反正各有各的办法。实现多级作用域在ast转html的时候,一个很常见的场景就是多级作用域,比如在一个for循环里面嵌套一个for循环。而这个作用域划分怎么做其实很简单,就是通过递归。比如我处理一棵ast树的方法命名为:processAst(ast,scope),原来scope为{list:[{subs:[1,2,3]},{subs:[4,5,6]}]}然后processAst可以这样实现:functionprocessAst(ast,scope){...if(ast.for){constlist=scope[ast.item];//ast.item自然是list的key,比如上面的listlist.forEach(item=>{processAst(ast.children,Object.assign({},scope,{[ast.key]:item,//ast.keyisforkeyinlistkey}))})}...}可以通过简单的递归一直传递作用域。Filter功能实现以上功能实现后,组件已经具备了基本的模板渲染能力,但是在使用模板引擎时,还有一个非常常用的功能就是filter。一般来说,过滤器的使用方式是这样的{{test|过滤器1|过滤器2}}。我也说一下这个的实现。这个实现我参考了vue的解析方法,挺有意思的。另一个例子:{{测试|过滤器1|filter2}}构造AST时,可以得到test|过滤器1|filter2,然后我们就可以轻松得到filter1和filter2这两个字符串。一开始我的实现方式是把这些filter字符串丢到ast节点的filters数组中,渲染的时候一个个取出来。但是后来觉得出于性能的考虑,AST阶段可以做的工作不应该放在渲染阶段。所以改成vue的方法组合方式。也就是将上面的字符串改成:_$f('filter2',_$f('filter1',test))并提前用方法包裹起来。渲染时,不需要通过循环获取滤镜和Executed。具体实现如下:constfilterRE=/(?:\|\s*\w+\s*)+$/;constfilterSplitRE=/\s*\|\s*/;functionprocessFilter(expr,escape){让结果=expr;constmatches=expr.match(filterRE);如果(匹配){constarr=matches[0].trim().split(filterSplitRE);结果=expr.slice(0,matches.index);//添加过滤器方法包装utils.forEach(arr,name=>{if(!name){return;}//如果有安全过滤器则不转义if(name==='safe'){escape=false;return;}结果=`_$f('${name}',${result})`;});}返回逃生?`_$f('escape',${result})`:result;}一个是safe的处理。如果有safe的过滤器,就不会进行越狱。完成此操作后,带有过滤器的变量将采用_$f('filter2',_$f('filter1',test))的形式。所以之前的computedExpression方法也需要修改一下。函数processFilter(filterName,str){constfilter=filters[filterName]||全局过滤器[过滤器名称];如果(!filter){thrownewError(`未知过滤器${filterName}`);}returnfilter(str);}functioncomputedExpression(obj,expression){constmethodBody=`return(${expression})`;constfuncString=obj?`with(_$o){${methodBody}}`:方法体;constfunc=newFunction('_$o','_$f',funcString);try{constresult=func(obj,processFilter);返回(结果===未定义||结果===空)?'':结果;}catch(e){//只捕获未定义的错误if(e.message.indexOf('isnotdefined')>=0){return'';}else{扔e;}}}其实很简单,就是在新建一个Function的时候,只要多传入一个获取filter的方法,就可以正常识别和解析带有filter的变量了。至此,AST的构造、AST转html、多级作用域和Filter实现基本讲解完毕。贴一个自己实现的模板引擎轮子:https://github.com/whxaxes/mus算是实现了模板引擎应该具备的大部分功能。欢迎各界大侠光临。