手写js模板编译器

前言

目前现代化的前端框架都采用了MVVM架构,开发者只需要定义一些页面上的状态数据以及执行相关修改就可以达到渲染页面的目的,这样的壮举彻底解放了前端工程师的双手,不再像之前那样花费大量的时间陷入了dom操作的泥潭.

模板编译在实现MVVM的环节中发挥着重要的作用,只有通过了模板编译才能将数据状态和html字符串结合起来返回给浏览器渲染.本文将模板编译单独抽出来讲解其核心代码,可以窥探到底层到底采用了什么方式去实现的.需求如下:

 const data = {
      num: 1,
      price: [
        {
          value: 60,
        },
      ]
 };
 
 const template = '<div>{
   
   { -1*price[0].value && 12+(8*!6-num)*10+2 }}</div>';
 
 const compiler = new Parser(template);
  
 console.log(compiler.parse(data)); //<div>4</div>

假设有一段模板字符串template,请编写一个Parser类,将data作为状态参数传入实例方法,期待最终输出结果为<div>4</div>.

有很多同学认为这其实很简单,只需要将表达式里面的代码用with语句构建在一个函数块里面输出即可.

这种快捷的方式当然可以,因为市面上很多框架都采用这种方法去编译.但with有它的局限性,被with包裹的语句在执行过程会导致运算速度大大下降.由于受到了AngularJs源码的启发,本文将采用另外一种方法来编译表达式(完整代码贴在了最后).

原理讲解

{ {...}}里面的表达式是最需要关注的重点.表达式可以罗列出很多的运算符号,比如算术运算符'+','-','*','/','%'.

另外还有逻辑运算符也要支持,包含'&&','||','=='.除此之外还有括号和小数点'()','[]','.'的处理.

除了上述这些符号外,表达式里面的变量如何与data里面的状态相关联起来,还有变量如果和'()','[]','.'结合用又该如何处理.

从上面描述看,一开始就想一个万全的方案将所有情况都支持到挺困难的.我们换一种思路,由简入繁,一个符号一个符号的去做,先从最简单的需求开始.

字符串切割

在正式处理表达式之前我们需要做些准备工作,首先要把{ {...}}包裹的表达式从模板字符串中分离出来.例如有个待编译的模板如下:

  const template = "<div><p>{
   
   { name }}</p>{
   
   { age + 100 }}</div>";

当前的需求很清晰,从字符串中分离出{ {...}}里面的表达式,只有得到了所有的表达式才能开启下一阶段的编译工作.

表达式列表可以使用一个变量exp_list = []存储,另外还要定义一个变量fragments来存储非表达式的html字符串.因为表达式的计算结果要和html字符串拼接后才是返回给用户的字符串.

此刻需要编写一个切割字符串分离表达式的函数compile,期待输出结果如下.

  compile(template){
     ...
   
   exp_list = [{index:1,exp:"name"},{index:3,exp:"age + 100"}];
   
   fragments = ["<div><p>","","</p>","","</div>"];
   
  }

如果我们将返回的数据结构设计成上面的样子,接下来的工作就好办了.

exp_list存储的是模板字符串的表达式列表,它的index属性代表着当前表达式在fragments占位的索引,而exp是表达式的内容.

代码走到这里,思路就很清晰了.循环遍历exp_list的每一个表达式,计算出相应的结果并填充到fragments对应的位置,最后将fragments数组的内容使用join合成字符串就是最终返回的结果.

compile函数该如何编写呢,核心代码如下:

compile(exp){
    let i = 0, last = 0, tmp = '', record = false;
    while (i < exp.length) {
        if (exp[i] === '{' && exp[i + 1] === '{') {
            this.fragments.push(exp.slice(last, i), '');
            last = i;
            record = true; // 开始记录
            i += 2;
            continue;
        }
        else if (exp[i] === '}' && exp[i + 1] === '}') {
            this.exp_list.push({
                index: this.fragments.length - 1,
                exp: exp.slice(last + 2, i),
            });
            last = i + 2;
            record = false;
            tmp = '';
        }
        else if (record) {
            tmp += exp[i];
        }
        i++;
    }
   ...
}

exp对应着待编译的模板字符串template,通过对其字符串遍历,判端是否匹配{ { }}.如果碰到了{ { 左侧之前的内容就归类到静态的html字符串里,放到fragments中存储起来.如果碰到了}},说明当前碰到的这个表达式已经遍历结束了,可以获取表达式字符串存储到exp_list.

表达式解析

通过上一阶段已经成功获取了表达式列表exp_list,现在只需要对这个列表遍历取出每一个表达式并计算出结果就大功告成了.

假设从模板字符串{ { 100 + age + 10}}中取出的的表达式为 "100 + age + 10" ,那这个表达式该如何计算?

假设 data = { age:10 } ,明显上面的表达式不能直接计算。因为age是我们在data对象中定义的状态,如果表达式能转化成100 + data.age +10 就能得到结果 120 了.

现在问题又来了,我怎么知道取出来的表达式 "100 + age + 10" ,哪些字符是挂载在data的状态,哪些又不是呢?

仔细观察上面的表达式可以寻找到一些规律.任何一个表达式起到关键衔接作用的是运算符号,符号负责将所有数据衔接在了一起.比如上面的+号,+左边或者右边就是两个要相加的元素.那么就可以推断加号两侧的元素可能是常量100,也可能是状态age.

发现了符号衔接的规律,我们就可以将字符串按照符号做切割.这样的目的就是为了让符号和元素分离开,并将元素精确的归类为常量或者状态.

比如上面的表达式 "100 + age + 10",期待编写一个parseExpression函数,执行后返回结果result如下:

  parseExpression(expression) {
     ...
    result = [
       {
          type: 'state',
          exp:"100",
          isConstant:true
       },
       {
          type: 'symbal',
          exp:"+"
       }
       {
          type: 'state',
          exp:"age",
          isConstant:false
       },
       {
          type: 'symbal',
          exp:"+"
       },
       {
          type: 'state',
          exp:"10",
          isConstant:true
       },
    ] 
  }

type:'symbal'代表着当前这条数据是个运算符号,而type:'state'代表当前这条数据是个状态变量.另外添加一个属性isConstant标识当前数据是一个数字常量100还是需要转化的状态变量data[age].

如果拿到了类似上面的数据结构,那表达式 "100 + age + 10" 的值就能轻松算出来了.将result循环遍历,依次取出每一个元素和符号,如果元素是数字常量直接参与计算,如果元素是状态就利用data拿到数据再参与符号运算.

parseExpression函数的作用就是处理表达式字符串,将数据结构转换成上述result的样子返回回去.parseExpression核心代码如下,expression对应着表达式字符串.

parseExpression(expression) {
        const data = [];
        let i = 0, tmp = '', last = 0;
        while (i < expression.length) {
            const c = expression[i];
            if (this.symbal.includes(c)) {
                let exp = expression.slice(last, i), isConstant = false;
                if (this.isConstant(exp)) {
                    //是否为数字常量
                    isConstant = true;
                }
                //它是一个字符
                data.push({
                    type: 'state',
                    exp,
                    isConstant,
                });
                data.push({
                    type: 'symbal',
                    exp: c,
                });
                last = i + 1;
            }
            i++;
        }
        ...
        return data;
    }

上面的案例只用到了符号+,但实际的表达式符号还包含有-,*,/,%,[,],(,),&&,||,!等.目前将这些符号都定义在一个数组symbal中,同理都可以按照+的逻辑将符号和元素分类,再将元素区分为常量和状态变量.

符号解析

如果一个表达式全都是加法,转换成上述的数据结构后遍历循环就能算出相应的结果.但假如表达式是 10 + age * 2.依照上述的解析的步骤先将数据结构转化为以下格式:

 result = [
     {
          type: 'state',
          exp:"10",
          isConstant:true
     },
     {
          type: 'symbal',
          exp:"+"
     },
     {
          type: 'state',
          exp:"age",
          isConstant:false
     },
     {
          type: 'symbal',
          exp:"*"
     },
     {
          type: 'state',
          exp:"2",
          isConstant:true
     }
 ]

表达式包含了乘法之后就不能直接遍历 result 数组计算出结果了,因为按照运算规则,表达式 10 + age * 2 应该先算乘法再算加法.

为了让乘法优先于加法执行,我们可以先对result做一层乘法的处理,将age*2转化成一个整体再做加法运算就能保证优先级了.我们期待乘法处理后result的数据结构能转换成下面的这个样子.

 result = [
     {
          type: 'state',
          exp:"10",
          isConstant:true
     },
     {
          type: 'symbal',
          exp:"+"
     },
     {
          type: 'state',
          catagory:"*",
          left:"age",
          right:"2"
          getValue:(scope)=>{
             ...
             //返回 age *2 的值
          }
     }
 ]

这样的数据结构又变成了熟悉的加法,通过遍历result数组拿到符号和元素值就能得到相加的和.result的第三个元素通过调用getValue函数就能拿到age * 2的乘积.

接下来看一下上面这一层的乘法处理是如何实现的.

  function exec(result){
     for (let i = 0; i < result.length; i++) {
      if (result[i].type === 'symbal' && result[i].exp === "*") {
        //删除三个元素
        const tmp = result.splice(i - 1, 3);
        // 组合新元素
        const left = tmp[0];
        const right = tmp[2];
        const new_item = {
          type: 'state',
          catagory: tmp[1].exp, 
          getValue: (data) => { // data对应着状态数据
            const lv = data[left]; // left就是'age'
            const rv = right; // right就是2 
            return lv * rv;
          },
        };
        //插入新元素
        result.splice(i - 1, 0, new_item);
        //修正索引
        i -= 1;
      }
    } 	 
  }

通过对result数组遍历,判端当前元素的符号是不是*,如果匹配上了就要取出*两侧参与乘法运算的元素leftright.

左侧元素,*,右侧元素result 数组中移除,然后组合成一个新的元素new_item再插回相应的位置.new_itemgetValue就对应着age * 2的乘积.

上面getValue是简化后的版本,实际上要先对leftright做一下常量的判定.如果是数字常量直接提取出来计算,如果是状态就要调用data获取状态的值再参与计算.

同理,表达式里面只包含+*只是很简单的情况,实际上符号还包含很多,比如(),[],&&等.但不管是哪一种符号,它们都有着不同的优先级计算顺序,正如上面的乘法优先于加法.同样的道理,小括号会优先于乘法的计算,而加法又会优先于逻辑符号(比如&&)的运算.

符号虽然很多,但它们做的事情和*是一样的,即先把优先级高的符号和元素转化成一个整体的新元素new_item,该元素会预留一个getValue函数用于计算整体的值.等到这些优先级高的运算符全转换完毕了,数组就只剩下简单的运算符了,通过遍历取值就能轻松得到最终想要的结果.

如果将所有运算符的优先级进行排序,最终对result数组处理的程序就形如以下函数:

 function optimize(result){
 	
    // 第一步 处理小括号

    result = this.bracketHanlder(result);

    // 第二步 处理 '[' 和 ']' 和 '.'

    this.squreBracketHanlder(result);

    // 第三步 处理 "!"

    result = this.exclamationHandler(result);

    // 第四步 处理 "*","/","%"

    this.superiorClac(result);

    // 第五步 处理 "+" 和 "-"

    this.basicClac(result);

    // 第六步 处理 "&&", "||" 和 "=="

    this.logicClac(result);

    return result;
 
 }


小括号是所有运算符中优先级最高的,因此要放置在第一步处理.

假设现有表达式 10 * (age + 2),对应解析出来的result数据结构如下.

const result = [
	 {
          type: 'state',
          exp:"10",
          isConstant:true
     },
     {
          type: 'symbal',
          exp:"*"
     },
     {
          type: 'symbal',
          exp:"("
     },
     {
          type: 'state',
          exp:"age",
          isConstant:false
     },
     {
          type: 'symbal',
          exp:"+"
     },
     {
          type: 'state',
          exp:"2",
          isConstant:true
     },
     {
          type: 'symbal',
          exp:")"
     }
]

如果通过 bracketHanlder(result) 函数的处理,将数据结构转化成以下形式就好办了.

const result = [
	 {
          type: 'state',
          exp:"10",
          isConstant:true
     },
     {
          type: 'symbal',
          exp:"*"
     },
     {
          type: 'expression',
          exp:"age+2",
          getValue:(data)=>{
             ...
             //能够得到age+2的值
          }
     }
]

bracketHanlder函数的处理逻辑如下.

   /**
   * 处理小括号
   */
  bracketHanlder(result) {
      
      ... //省略
	  
      const exp = result.slice(start + 1, end).reduce((cur, next) => {
        return cur + next.exp;
      }, '');    // exp = "age+2"	

      result.push({
        type: 'expression',
        exp,
        getValue: (data) => {
          return this.parseExpression(exp)(data);
        },
      });
	
      ... //省略
     
  }

startend分别对应着'('')'result数组中的索引,'exp'就是小括号包裹的表达式.

小括号和其他运算符有点区别,因为小括号里面可以写任意符号,它本身就是一个表达式.

那其实想要解析小括号里面的内容,可以把里面包裹的语句将上面的流程再走一遍,代码中只需要递归调用一下parseExpression函数,把小括号包裹的内容当成一个表达式处理即可.

parseExpression执行完最终会返回一个解析函数,此时只要把状态值传给它,该函数就能返回表达式的值.

接下来看一下中括号和小数点的处理.[]前面可以是一个状态比如array[3],也有可能还是一个中括号比如多维数组array[0][1],而中括号里面包裹的部分则是一个表达式array[1+age*2],只要是表达式,处理方式就和小括号一样.

小数点的处理逻辑相对简单一些,它只需要关注左侧元素和右侧元素即可.如果左侧元素是一个数字,那么类似这种情况100.11,整体要当做小数来处理.如果左侧是一个状态ob.score,那整体要当做一个对象来处理.

另外[].没有确定的优先级顺序,例如存在这样的模板{ { list[0].students[0].name }}.中括号有可能优先于小数点计算,也有可能放到小数点后面计算.

接下来看一下中括号和小数点处理的核心逻辑.

  /**
   *  处理中括号 [] 和 .
   */
  squreBracketHanlder(result){
      //遍历result
           ...
      if(result[i].exp === ']'){ // 当前中括号已经遍历结束了     
          //删除元素
          //start_index 对应着 "[" 的索引
          const extra = result.splice(start_index-1,i-start_index+2); //将中括号包裹的内容和左侧元素一起删掉
          //添加新元素,组合成一个新的整体
          const left = extra[0].getValue?extra[0].getValue:extra[0].exp; // 获取中括号左边的元素
          const right = extra.slice(2,extra.length-1).reduce((cur, next) => { // 获取中括号包裹的表达式
            return cur + next.exp;
          }, '');
          result.splice(start_index-1,0,{
            type:"state",
            category:"array",
            getValue:(data)=>{
              if(typeof left === "function"){ //可能是多维数组
                return left(data)[this.deepParse(right)(data)];
              }
              else{ // 中括号左边是一个状态,例如array[0],left = "array",right = 0
                return data[left][this.deepParse(right)(data)];
              }
            }
          })
          
          // 修正索引
          i = start_index - 2;
          ...
      }else if(result[i].exp === '.'){
         // 小数点的处理逻辑和乘法一样,都是只需要关注左右元素转化成整体即可
      }    
    }
  }

squreBracketHanlder的工作流程可以简述如下,它们的核心思想都和乘法一样,把元素和符号组合成一个整体.

比如表达式{ { list[0].students[0].name }},函数squreBracketHanlder会先处理list[0]将它转化成一个整体item1,由于删除了元素要修改一下循环索引值,再继续遍历.

再将item1.students转化成item2,修改索引继续遍历.

item2又和[0]转化成item3,修改索引继续遍历.

最后item3.name转化成了最后一个元素item4再参与其他运算.

item4有一个getValue函数会返回整个表达式list[0].students[0].name的值.

最后剩下的运算符%,/,&&等都和*的处理逻辑一样,只需要关注符号两侧的元素,转化成一个整体新元素即可.

源码

完整代码

猜你喜欢

转载自blog.csdn.net/brokenkay/article/details/114198027
今日推荐