Vue3模版编译原理

模版编译流程

Vue3模版编译就是把template字符串编译成渲染函数

// template
<div><p>{{LH_R}}</p></div>

// render
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("p", null, _toDisplayString(_ctx.LH_R), 1 /* TEXT */)
  ]))
}

我会按照编译流程分3步分析

  1. parse:将模版字符串转换成模版AST
  2. transform:将模版AST转换为用于描述渲染函数的AST
  3. generate:根据AST生成渲染函数
    export function baseCompile(
      template: string | RootNode,
      options: CompilerOptions = {}
    ): CodegenResult {
      // ...
      const ast = isString(template) ? baseParse(template, options) : template
    
      const [nodeTransforms, directiveTransforms] = getBaseTransformPreset(
        prefixIdentifiers
      )
      transform(
        ast,
        extend({}, options, {
          prefixIdentifiers,
          nodeTransforms: [
            ...nodeTransforms,
            ...(options.nodeTransforms || []) // user transforms
          ],
          directiveTransforms: extend(
            {},
            directiveTransforms,
            options.directiveTransforms || {} // user transforms
          )
        })
      )
    
      return generate(
        ast,
        extend({}, options, {
          prefixIdentifiers
        })
      )
    }

parse

  • parse对模版字符串进行遍历,然后循环判断开始标签和结束标签把字符串分割成一个个token,存在一个token列表,然后扫描token列表并维护一个开始标签栈,每当扫描一个开始标签节点,就将其压入栈顶,栈顶的节点始终作为下一个扫描的节点的父节点。这样,当所有Token扫描完成后,即可构建成一颗树形AST

  • 以下是简化版parseChildren源码,是parse的主入口

    function parseChildren(
      context: ParserContext,
      mode: TextModes,
      ancestors: ElementNode[] // 节点栈结构,用于维护节点嵌套关系
    ): TemplateChildNode[] {
      // 获取父节点
      const parent = last(ancestors)
      const ns = parent ? parent.ns : Namespaces.HTML
      const nodes: TemplateChildNode[] = [] // 存储解析出来的AST子节点
    
      // 遇到闭合标签结束解析
      while (!isEnd(context, mode, ancestors)) {
        // 切割处理的模版字符串
        const s = context.source
        let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined
    
        if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
          if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
            // 解析插值表达式{{}}
            node = parseInterpolation(context, mode)
          } else if (mode === TextModes.DATA && s[0] === '<') {
            if (s[1] === '!') {
              // 解析注释节点和文档声明...
            } else if (s[1] === '/') {
              if (s[2] === '>') {
                // 针对自闭合标签,前进三个字符
                advanceBy(context, 3)
                continue
              } else if (/[a-z]/i.test(s[2])) {
                // 解析结束标签
                parseTag(context, TagType.End, parent)
                continue
              } else {
                // 如果不符合上述情况,就作为伪注释解析
                node = parseBogusComment(context)
              }
            } else if (/[a-z]/i.test(s[1])) {
              // 解析html开始标签,获得解析到的AST节点
              node = parseElement(context, ancestors)
            }
          }
        }
        if (!node) {
          // 普通文本节点
          node = parseText(context, mode)
        }
    
        // 如果节点是数组,就遍历添加到nodes中
        if (isArray(node)) {
          for (let i = 0; i < node.length; i++) {
            pushNode(nodes, node[i])
          }
        } else {
          pushNode(nodes, node)
        }
      }
      return nodes
    }
  • 就拿<div><p>LH_R</p></div>流程举例

  1. div开始标签入栈,context.source = <p>LH_R</p></div>,ancestors = [div]
  2. p开始标签入栈,context.source = LH_R</p></div>,ancestors = [div, p]
  3. 解析文本LH_R
  4. 解析p结束标签,p标签出栈
  5. 解析div结束标签,div标签出栈
  6. 栈空,模版解析完毕

transform

  • transform采用深度优先的方式对AST进行遍历,在遍历过程中,对节点的操作与转换采用插件化架构,都封装为独立的函数,然后转换函数通过context.nodeTransforms来注册
  • 转换过程是优先转换子节点,因为有的父节点的转换依赖子节点
  • 以下是AST遍历traverseNode核心源码
    /* 
      遍历AST节点树,通过node转换器对当前节点进行node转换
      子节点全部遍历完成后执行对应指令的onExit回调退出转换
    */
    export function traverseNode(
      node: RootNode | TemplateChildNode,
      context: TransformContext
    ) {
      // 记录当前正在遍历的节点
      context.currentNode = node
    
      /* 
        nodeTransforms:transformElement、transformExpression、transformText...
        transformElement:负责整个节点层面的转换
        transformExpression:负责节点中表达式的转化
        transformText:负责节点中文本的转换
      */
      const { nodeTransforms } = context
      const exitFns = []
      // 依次调用转换工具
      for (let i = 0; i < nodeTransforms.length; i++) {
        /* 
          转换器只负责生成onExit回调,onExit函数才是执行转换主逻辑的地方,为什么要推到栈中先不执行呢?
          因为要等到子节点都转换完成挂载gencodeNode后,也就是深度遍历完成后
          再执行当前节点栈中的onExit,这样保证了子节点的表达式全部生成完毕
        */
        const onExit = nodeTransforms[i](node, context)
        if (onExit) {
          if (isArray(onExit)) {
            // v-if、v-for为结构化指令,其onExit是数组形式
            exitFns.push(...onExit)
          } else {
            exitFns.push(onExit)
          }
        }
        if (!context.currentNode) {
          // node was removed 节点被移除
          return
        } else {
          // node may have been replaced
          // 因为在转换的过程中节点可能被替换,恢复到之前的节点
          node = context.currentNode
        }
      }
    
      switch (node.type) {
        case NodeTypes.COMMENT:
          if (!context.ssr) {
            // inject import for the Comment symbol, which is needed for creating
            // comment nodes with `createVNode`
            // 需要导入createComment辅助函数
            context.helper(CREATE_COMMENT)
          }
          break
        case NodeTypes.INTERPOLATION:
          // no need to traverse, but we need to inject toString helper
          if (!context.ssr) {
            context.helper(TO_DISPLAY_STRING)
          }
          break
    
        // for container types, further traverse downwards
        case NodeTypes.IF:
          // 对v-if生成的节点束进行遍历
          for (let i = 0; i < node.branches.length; i++) {
            traverseNode(node.branches[i], context)
          }
          break
        case NodeTypes.IF_BRANCH:
        case NodeTypes.FOR:
        case NodeTypes.ELEMENT:
        case NodeTypes.ROOT:
          // 遍历子节点
          traverseChildren(node, context)
          break
      }
      // 当前节点树遍历完成,依次执行栈中的指令退出回调onExit
      context.currentNode = node
      let i = exitFns.length
      while (i--) {
        exitFns[i]()
      }
    }

generate

generate生成代码大致分为3步

  1. 创建代码生成上下文,因为该上下文对象是用于维护代码生成过程中程序的运行状态,如:
  • code:最终生成的渲染函数
  • push:拼接代码
  • indent:代码缩进
  • deindent:减少代码缩进
  1. 生成渲染函数的前置预设部分
    • module模式下:genModulePreamble()
    • function模式下:genFunctionPreamble
    • 还有一些函数名,参数,作用域…
  2. 生成渲染函数
    • 通过调用genNode,然后在genNode内部通过switch语句来匹配不同类型的节点,并调用对应的生成器函数

参考资料