手写vue3-compiler-dom — AST篇

2,328 阅读6分钟

Template的编译过程

我们都知道,在写vue描述dom结构的时候,可以写template,也可以写render函数,而template也最终会被编译成render函数用于渲染页面。template编译时会分为3个阶段:解析(parsing)、转换(transform)、生成(generate),如下图所示,在parse阶段,vue-compiler模板转换为AST,在transform阶段,对代码进行各种转换和标记,在generator阶段,再用new Functionwith的组合,将AST转换成最终render函数。

  -- 图片源自网络,侵删

什么是AST

AST的官方定义

在计算机科学中,抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。

通俗的讲就是AST用于描述语法的数据结构,有了这些描述信息,就可以用来进行一些我们想要的操作,例如:

  • 优化变更代码,改变代码结构使达到想要的结构
  • 代码语法的检查、代码风格的检查、代码的格式化、代码的高亮、代码错误提示、代码自动补全等等
  • 代码混淆压缩

我们可以通过AST Explorer查看代码的抽象语法树结构,例如我们编译器选择@vue/compiler-dom,在左边编辑区域输入代码,右边就会实时生成对应的AST树。

Vue3和Vue2模板编译对比

vue3的模板编译相对于vue2是重写了的,在vue2里面主要通过正则解析模板字符串,然后提取相应的信息组成语法树。但是在vue3里面,主要是递归解析字符串中的词法,少量的用到了正则来匹配目标字符串,进行词法分析,组装成语法树。 vue3一个重大更新就是template里面可以包含多个根节点了,也就是官方所说的Fragment概念。ast里面类似,无论是一个根节点还是多个根节点,都会被包在一个根对象里面,children就是它所有的子节点。

Template编译成AST代码实现

在上面template转成ast结果里根节点  我们看到有3个属性:

  • type : 节点类型
  • children:子节点
  • loc:位置信息和当前源码

children是一个数组对象,包含着标签语法的信息描述,通常有以下属性:

  • type : 节点类型
  • children:子节点
  • loc:位置信息
  • tag:标签名
  • isSelfClosing:是否自闭和标签
  • props:属性集合
  • isStatic:是否是静态属性
  • content:内容
  • 等...

我们就根据AST Explorer生成的结果,和对源码的分析,写一个简易版vue3的template转ast语法树的功能,目前只包含标签,插值表达式,文本,有兴趣的同学可以自己扩展。

下面我们会写一个baseCompile()用于编译模板成ast;用baseParse()解析模板字符串,标识节点的信息 行、列、偏移量等,并且每解析一段 就移除一部分,为空就解析完成了;用createParserContext()生成解析上下文,用于标识当前词法的信息;用getCursor()记录开始位置;用createRoot()返回一个根节点,它传入子节点和位置信息。代码如下:

// vue3源码里拷贝的节点类型
const NodeTypes = {
  ROOT: 1, // 根节点 fragment 解决多个根元素的问题
  ElEMENT: 2, // 元素 div, p
  TEXT: 3, // 文本
  SIMPLE_EXPRESSION: 4, // 简单表达式 <div>{{name}}</div>
  INTERPOLATION: 5, // 插值表达式
  ATTRIBUTE: 6, // 属性
  DIRECTIVE: 7, // 指令
  COMPOUND_EXPRESSION: 8, // 组合表达式 {{name}} hello world
  TEXT_CALL: 12, // createTextVnode()
  VNODE_CALL: 13, // createVnode()
}

// 编译器,用于将template转化ast,和对ast的优化
function baseCompile(template) {

  // 讲模板转换成ast语法树
  const ast = baseParse(template);

  // todo 这里会进行ast的transfer,用于对ast树标记和优化,后面处理
  // ...

  return ast;
}

// 词法解析函数,用于转ast
function baseParse(content) {
  // 标识节点的信息 行、列、偏移量...
  // 每解析一段 就移除一部分

  const context = createParserContext(content);
  const start = getCursor(context); // 记录开始位置
  return createRoot(parseChildren(context), getSelection(context, start));
}

// 创建根节点,把parseChildren的果包一层返回出去,产生上面{type,children,loc}那样的结构
function createRoot(children, loc) {
  return {
    type: NodeTypes.ROOT,
    children,
    loc,
  };
}

// 创建解析上下文, 用于描述代码结构的属性和解析时提供数据
function createParserContext(content) {
  return {
    line: 1, // 行
    column: 1, // 列
    offset: 0, // 偏移量
    source: content, // 这个source会被不停的移除,等待source为空的时候解析完毕
    originalSource: content, // 这个值是不会变的 记录你传入的内容
  };
}

// 游标 获取起始位置 行,列,偏移位置
function getCursor(context) {
  let { line, column, offset } = context;
  return { line, column, offset };
}

// 获取信息对应的开始、结束和当前内容
function getSelection(context, start, end) {
  end = end || getCursor(context);
  return {
    start,
    end,
    source: context.originalSource.slice(start.offset, end.offset),
  };
}

// 是不是解析完毕 :碰到结束标签 || context.source = '' 都会判定解析完成
function isEnd(context) { 
    const source = context.source;
    if(source.startsWith('</')){
        return true
    }
    return !source
}

// 核心方法:解析词法生成ast的节点,这里只写一个简单的版本,只解析文本,插值表达式,标签,要解析其他的类型可以在这里面加逻辑
function parseChildren(context) {
  // 根据内容做不同的处理
  const nodes = [];
  while (!isEnd(context)) {
    const s = context.source; // 当前上下文中的内容  <  abc  {{}}
    let node;
    if (s[0] == "<") {
      // 处理标签 
    } else if (s.startsWith("{{")) {
      // 处理表达式
    } else {
      // 处理文本
    }
    nodes.push(node);
    // 演示逐字解析过程,后面用advance进位函数代替
    context.source = context.source.slice(1)
    console.log(s); // 打印剩余的字符串
  }
  return nodes.filter(Boolean); // 过滤null值
}

下面我们来执行baseCompile()看一下结果

baseCompile(`<div>{{ greeting }}World!</div>`)
// 输出结果 =>
// div>{{ greeting }}World!</div>
// iv>{{ greeting }}World!</div>
// v>{{ greeting }}World!</div>
// >{{ greeting }}World!</div>
// {{ greeting }}World!</div>
// { greeting }}World!</div>
// greeting }}World!</div>
// greeting }}World!</div>
// reeting }}World!</div>
// eeting }}World!</div>
// eting }}World!</div>
// ting }}World!</div>
// ing }}World!</div>
// ng }}World!</div>
// g }}World!</div>
//  }}World!</div>
// }}World!</div>
// }World!</div>
// World!</div>
// orld!</div>
// rld!</div>
// ld!</div>
// d!</div>
// !</div>
// </div>

上面的步骤没有问题,那么下面就继续写解析标签的方法:parseElement(),解析表达式的方法:parseInterpolation(),解析文本的方法:parseText()

// 通过当前上下文解析标签元素
function parseElement(context) {
  // 1.解析标签名 
  let ele = parseTag(context); // <div></div>

  // 2.TODO 处理指令,动/静态属性,事件等,这里不做展开了
  // 例如 ele = parseDirective(context) ...

  // 有子节点就递归处理子节点
  const children = parseChildren(context); // 如果遇到结束标签就直接跳出

  if (context.source.startsWith('</')) {
    parseTag(context); // 解析关闭标签时 同时会移除关闭信息并且更新偏移量
  }
  ele.children = children;
  ele.loc = getSelection(context, ele.loc.start)
  return ele;
}

// 解析插值表达式
function parseInterpolation(context) { // }}  {{name}}
  const start = getCursor(context); // 获取表达式的start位置
  const closeIndex = context.source.indexOf('}}', '{{') // 找到'}}'位置索引,从'{{'开始找

  advanceBy(context, 2); // 前进2个字符
  const innerStart = getCursor(context);
  const innerEnd = getCursor(context); // 这个end稍后我们会改
  const rawContentLength = closeIndex - 2;// 拿到{{ 内容 }}  包含空格的

  const preTrimContent = parseTextData(context, rawContentLength)
  const content = preTrimContent.trim(); // 去掉前后空格  "  name  " => "name"
  const startOffset = preTrimContent.indexOf(content); // {{  name  }}
  if (startOffset > 0) { // 有前面空格
    advancePositionWithMutation(innerStart, preTrimContent, startOffset)
  }
  // 更新innerEnd
  const endOffset = content.length + startOffset
  advancePositionWithMutation(innerEnd, preTrimContent, endOffset)
  advanceBy(context, 2);
  return {
    type: NodeTypes.INTERPOLATION,
    content: {
      type: NodeTypes.SIMPLE_EXPRESSION,
      isStatic: false,
      loc: getSelection(context, innerStart, innerEnd),
      content
    },
    loc: getSelection(context, start)
  }
}

// 解析文本
function parseText(context) {
  // 1.先做文本处理
  const endTokens = ["<", "{{"]; // 2种情况:hello</div> 或者 hello {{name}} 就说明文本区结束了
  let endIndex = context.source.length; // 文本的整个长度
  // 假设法 需要先假设 遇到 <  是结尾  在拿到遇到{{  去比较那个 在前 就是到哪
  for (let i = 0; i < endTokens.length; i++) {
    const index = context.source.indexOf(endTokens[i], 1);
    // 如果找到了索引并且 小于总长度
    if (index !== -1 && endIndex > index) {
      endIndex = index;
    }
  }
  // 有了文本的结束位置 我就可以更新行列信息
  let start = getCursor(context);
  const content = parseTextData(context, endIndex);
  return {
    type: NodeTypes.TEXT,
    content,
    loc: getSelection(context, start),
  };
}

// 解析标签
function parseTag(context) {
  // 获取开始位置信息
  const start = getCursor(context); //<div/>
  // 最基本的元字符匹配标签元素
  const match = /^<\/?([a-z][^ \t\r\n/>]*)/.exec(context.source);
  const tag = match[1];
  // 
  advanceBy(context, match[0].length);
  advanceSpaces(context);

  const isSelfClosing = context.source.startsWith('/>');
  advanceBy(context, isSelfClosing ? 2 : 1);
  return {
    type: NodeTypes.ElEMENT,
    tag,
    isSelfClosing,
    loc: getSelection(context, start)
  }
}

// 解析文本节点,并且返回目标文本
function parseTextData(context, endIndex) {
  const rawText = context.source.slice(0, endIndex);
  advanceBy(context, endIndex); // 在context.source中把文本内容删除掉
  return rawText;
}

// 前进,删除context.source中已解析的字符串
function advanceBy(context, endIndex) {
  let s = context.source;// 原内容
  // 计算出一个新的结束位置
  advancePositionWithMutation(context, s, endIndex); // 根据内容和结束索引来修改上下文的信息
  context.source = s.slice(endIndex); // 截取内容
}

// 通过传上下文,内容,结束位置信息,处理更新后的上下文中的行,列,和偏移量信息
function advancePositionWithMutation(context, s, endIndex) {
  let linesCount = 0; // 行数
  let linePos = -1;  // 行的左偏移量
  for (let i = 0; i < endIndex; i++) {
    if (s.charCodeAt(i) == 10) { // 遇到换行就加一行
      linesCount++;
      linePos = i; // 换行后第一个字符的位置 
    }
  }
  context.offset += endIndex; // 更新偏移量
  context.line += linesCount; // 更新行数
  context.column = linePos == -1 ? context.column + endIndex : endIndex - linePos // 更新列数
}

// 前进空格
function advanceSpaces(context) {
  const match = /^[ \t\r\n]+/.exec(context.source);
  if (match) {
    advanceBy(context, match[0].length)
  }
}

验证:

const ast = baseCompile(
`<div>
  <p>{{ greeting }} World!</p>
</div>`
)
console.log(ast);

解析结果:

{
  "type": 1,
  "children": [
    {
      "type": 2,
      "tag": "div",
      "isSelfClosing": false,
      "loc": {
        "start": {
          "line": 1,
          "column": 1,
          "offset": 0
        },
        "end": {
          "line": 3,
          "column": 8,
          "offset": 36
        },
        "source": "<div>\n  <p>{{ name }} 文本</p>\n </div>"
      },
      "children": [
        {
          "type": 2,
          "tag": "p",
          "isSelfClosing": false,
          "loc": {
            "start": {
              "line": 2,
              "column": 3,
              "offset": 8
            },
            "end": {
              "line": 2,
              "column": 23,
              "offset": 28
            },
            "source": "<p>{{ name }} 文本</p>"
          },
          "children": [
            {
              "type": 5,
              "content": {
                "type": 4,
                "isStatic": false,
                "loc": {
                  "start": {
                    "line": 2,
                    "column": 9,
                    "offset": 14
                  },
                  "end": {
                    "line": 2,
                    "column": 13,
                    "offset": 18
                  },
                  "source": "name"
                }
              },
              "loc": {
                "start": {
                  "line": 2,
                  "column": 6,
                  "offset": 11
                },
                "end": {
                  "line": 2,
                  "column": 16,
                  "offset": 21
                },
                "source": "{{ name }}"
              }
            },
            {
              "type": 3,
              "content": " 文本",
              "loc": {
                "start": {
                  "line": 2,
                  "column": 16,
                  "offset": 21
                },
                "end": {
                  "line": 2,
                  "column": 19,
                  "offset": 24
                },
                "source": " 文本"
              }
            }
          ]
        }
      ]
    }
  ],
  "loc": {
    "start": {
      "line": 1,
      "column": 1,
      "offset": 0
    },
    "end": {
      "line": 3,
      "column": 8,
      "offset": 36
    },
    "source": "<div>\n  <p>{{ name }} 文本</p>\n </div>"
  }
}

源码地址:gitHub
下期预告:手写vue3-complier-dom —— Transform篇