模版编译流程

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

// template<div><p>{{LH_R}}</p></div>// renderimport { 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:缩小代码缩进
    • ...
  2. 生成渲染函数的前置预设局部

    • module模式下:genModulePreamble()
    • function模式下:genFunctionPreamble
    • 还有一些函数名,参数,作用域...
  3. 生成渲染函数

    • 通过调用genNode,而后在genNode外部通过switch语句来匹配不同类型的节点,并调用对应的生成器函数

参考资料

  • Vue3 模板编译原理
  • 《Vue.js设计与实现》