【Vue】源码—抽象语法树AST

281 阅读4分钟

github源码链接:抽象语法树AST

1.抽象语法树是什么?

抽象语法树是源代码的抽象语法结构的树状表现形式。通过抽象语法树可以让模板语法编译成HTML语法更简单。抽象语法树本质上就是一个JS对象。

2.抽象语法树与虚拟DOM节点的关系

抽象语法树的终点是:渲染函数(h函数)

渲染函数(h函数),它既是AST的产物,也是虚拟节点的起源。

抽象语法树不会进行diff算法并且抽象语法树不会直接生成虚拟节点,抽象语法树最终生成的是渲染函数。

3.实现抽象语法树AST

3.1 实现parse函数

用于识别开始以及结束标签。利用指针和栈的思想,使用正则表达式对此进行匹配标签。

实现思路

  1. 首先进行定义,识别开始标签、结束标签、文字的正则表达式
  2. 定义两个栈,栈1【存放标签】和栈2【存放内容】
  3. 准备一个指针,使用指针进行循环判断
  • 识别到一个开始的标签符号,那么就将这个标签进行入栈1,把空数组进行入栈2
  • 当识别到的字符是标签内的文字,那么就将栈2栈顶这项更改为文字
  • 当识别到结束标签,将进行双弹栈,并将内容栈的栈顶元素入栈2的children中

补充知识

  1. substring(): 用来截取字符串,根据参数的个数不同,方法含义也不同
  • substring(0, 2): 这个值含开头不含结尾,因此截取是截取两个字符,包含开始的下标数值,不含最终下标的数值
  • substring(2): 表示截掉前2个,得到后面的新字符串
  1. test(): 用于检测有一个字符串是否匹配某个模式,如果字符串有匹配的值返回true。否则返回false【一般于正则表达式结合】
  2. match(): 在字符串内检索指定的值,找到一个或多个正则表达式的匹配
  • 不使用全局匹配:
    • 第0项: 匹配到字符串
    • 第1项: groups: undedfined, 这表示当前的正则表达式没使用分组
    • 第2项: index: 表示首次匹配上的子串的起始下标
    • 第3项: input: 表示源字符串
    • 第4项: length: 表示匹配到的结果个数
  • 使用全局匹配g:
    • 匹配到字符串【以0,1,2,3数组形式表示】
    • length
import parseAttrsString from "./parseAttrsString";
// parse函数,主函数
export default function (templateStr) {
  // 指针
  let index = 0;
  // 剩余部分
  let rest = templateStr;
  // 开始正则
  let startRegExp = /^<([a-z]+[1-6]?)(\s[^<]+)?>/;
  // 结束正则
  let endRegExp = /^</([a-z]+[1-6]?)>/;
  // 抓取结束标记前的文字,中间文字正则
  // ^表示否,[.^<]表示开头必须不是<
  let wordRegExp = /^([^<]+)</([a-z]+[1-6]?)>/;
  // 标签栈
  let stack1 = [];
  // 字符栈,先用children占位,防止弹栈
  let stack2 = [{'children': []}];
  // 完善标签内属性
  while(index < templateStr.length - 1) {
    // 剩余部分
    rest = templateStr.substring(index);
    // 识别遍历到的这个字符,是不是一个开始标签
    if(startRegExp.test(rest)) {
      // []?表示可能有可能没有
      // 获取开始标签
      let tag = rest.match(startRegExp)[1];
      // 获取属性字符串
      let attrsString = rest.match(startRegExp)[2];
      console.log(tag);
      // 将开始标记推入栈中
      stack1.push(tag);
      // 字符栈入栈,用children和tag占位
      // 识别标签内的attrs
      stack2.push({'tag': tag, 'children': [], 'attrs': parseAttrsString(attrsString), type: 1});
      // 得到attrs字符串的总长度
      const attrsStringLength = attrsString != null ? attrsString.length : 0;
      // 指针移动标签的长度+2再加attrString的长度,为什么要加2呢?因为<>也占两位
      index += tag.length + 2 + attrsStringLength;
    } else if(endRegExp.test(rest)) {
      // 识别遍历到的这个字符,是不是一个结束标签,并将开始标签进行弹栈
      let tag = rest.match(endRegExp)[1];
      console.log('结束',tag);、
      // 标签栈弹栈
      let pop_tag = stack1.pop();
      // 判断开始标签与结束标签是否闭合
      if(tag == pop_tag) {
        let pop_arr = stack2.pop();
        // 判断字符栈是否还存在工作栈,如果是则并入到上一个工作栈
        if(stack2.length > 0) {
          stack2[stack2.length - 1].children.push(pop_arr);
        }
      } else {
        throw new Error('标签没有封闭');
      }
      // 指针移动标签的长度+3,为什么要加3呢?因为</>也占3位
      index += tag.length + 3;
      // console.log(stack1, JSON.stringify(stack2));
    } else if(wordRegExp.test(rest)) {
      // 若当前字符检测到文字
      let word = rest.match(wordRegExp)[1];
      // 判断获取到的文字是否全为空
      if(!/^\s+$/.test(word)) {
        // 如果不是,则入栈,工作栈为字符栈的栈顶那一项
        stack2[stack2.length - 1].children.push({'text': word, 'type': 3});
      }
      // 指针步进为文字长度
      index += word.length;
    } else {
      // 默认情况下指针自增
      index++;
    }
  }
  // 由于事先布置好占位数组,故字符栈现在存有一项,即总的数据,因此返回该项children
  return stack2[0].children[0];
}

3.2 实现parseAttrsString函数

将attrsString解析成attrs对象数组

实现思路

首先遍历字符串,遇到引号后将其isStr属性设置成true。此时在引号内遇到空格则不需要管。当遇到下一个引号时设置isStr为false。此时在遇见引号就将前面的字符串截取出来放入到数组中。

最后将数组里面的内容利用map进行拆分。

补充知识

  1. trim(): 该方法用于删除字符串的头尾空白符,空白符包括:空格、制表符tab、换行符等其他空白符
export default function parseAttrsString(attrsString) {
  // 如果字符串不存在,变为数组返回
  if(attrsString == undefined) return [];
  // 判断检测到的空格是否包含在引号内
  let isYinhao = false;
  // 指针
  let point = 0;
  // 结果数组
  let result = [];
  // 遍历attrsString,而不是你想的用split()这种暴力方法
  for(let i = 0; i < attrsString.length; i++) {
    let char = attrsString[i];
    if(char == '"') {
      // 遇到引号,改标记
      isYinhao = !isYinhao;
    }else if(char == ' ' && !isYinhao) {
      // 遇若当前字符是空格,但不在双引号之内,则截取字符串;例:class="aa" id="cc"—class属性与id属性之间的空格
      // 排除属性之外的空格字符
      // 判断截取到的字符是否全为空
      if(!/^\s*$/.test(attrsString.substring(point, i).trim())) {
        // 如果不是,则并入结果数组
        result.push(attrsString.substring(point, i).trim());
        // 移动指针
        point = i;
      }
    }
  }
  // 循环结束之后,由于指针移动的比i慢,最后还会剩下一个属性未被并入
  // 清除字符串前后空格后,将其并入结果数组
  result.push(attrsString.substring(point).trim());
  // 下面的代码功能是,将["k=v","k=v"]变为[{name:k, value:v},{name:k, value:v}];
  // 在数据结构的情况下,可以采用递归,并且是映射递归
  result = result.map(item => {
    // 根据等号进行拆分和捕获
    const itemMatch = item.match(/^(.+)="(.+)"$/);
    return {
      name:itemMatch[1],
      value:itemMatch[2]
    }
  });
  return result;
}

4. 总结

4.1 为什么需要AST?

Vue是一个渐进式的SPA(单页面)框架,视图无感更新和数据实时响应以及页面性能是必须要充电优化的地方,尤其是视图更新,想要做到以最小的性能损耗达到实时更新的目的,就必须借助虚拟节点和diff的手段。而不是每次改变数据就操作节点。因此为了实现虚拟节点,我们就需要先实现AST。

4.2为什么AST不直接diff?

  • 因为有时候只是改动data数据,并没有修改template模板语句。
  • 其次,虚拟节点搭配diff所产生的性能损耗是最小的,这就是为什么每次改变data数据时重新执行render函数,生成新的虚拟节点,然后diff比对、渲染。