Vue3源码13: 从AST到render函数(transform与代码生成)

2022-09-27 14:30:49 浏览数 (1)

  • Vue3源码01 : 代码管理策略-monorepo
  • Vue3源码02: 项目构建流程和源码调试方法
  • Vue3源码03: Vue3响应式核心原理
  • Vue3源码04: Vue3响应式系统源码实现1/2
  • Vue3源码05 : Vue3响应式系统源码实现(2/2)
  • Vue3源码06: reactive、ref相关api源码实现
  • Vue3源码07: 故事要从createApp讲起
  • Vue3源码08: 虚拟Node到真实Node的路其实很长
  • Vue3源码09: 组件的渲染和更新流程
  • Vue3源码10: 名动江湖的diff算法
  • Vue3源码11: 编译优化之Block Tree 与 PatchFlags
  • Vue3源码12: 编译过程介绍及AST的生成过程分析

我们在上一篇文章中已经知道了从模版字符串到返回虚拟Node的render函数需要经历三个阶段:

  1. 模版字符串转化成AST;
  2. 模版字符串对应的AST转化成可以描述js代码的AST;
  3. 将可以描述js代码的AST转化成render函数。

而本文将要和大家分享第二个阶段和第三阶段的内容。先分析模版字符串对应的AST转化成可以描述js代码的AST,接着分析根据转化后的AST生成代码的过程。

AST转化

我们可能会很自然的想到,为什么不能直接由描述模版字符串的AST转化成render函数,而是有这样一个将模版字符串AST转化成描述js代码AST的中间环节?

其实我们在描述这个过程的时候已经蕴涵了答案。就像上一篇文章介绍的那样,AST是一个对象,这个对象可以用来描述模版字符串。上一篇文章的内容本质上就是在分析不断完善这个AST对象的过程,让其可以比较全面的描述模版字符串。但是无论怎么完善,当时还是只能描述模版字符串,不能描述一个函数。而我们整个编译工作的最终结果是返回一个函数,所以我们需要一个可以用来描述函数的AST,这也就是为什么需要第二个阶段(对原有的AST进行转换)的原因。

我们先来回顾下baseCompile函数的具体实现:

代码语言:javascript复制
// 代码片段1
export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {

  // 此处省略许多代码...
  const ast = isString(template) ? baseParse(template, options) : template
  // 此处省略许多代码...
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )
  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

我们从代码片段1中发现在生成描述模版字符串的AST之后,调用transform函数对其进行了转化。而在转化之后再调用generate函数生成代码。也就是说我们编译过程中所涉及的AST对象都是最开始用来描述模版字符串的对象。只不过后来对其进行了增强,让其不仅可以描述模版字符串同时还可以描述一个函数。

transform

从代码片段1中我们发现给transform传入了两个参数,一个参数是待转化的AST,另一个参数是一个对象,该对象上集成了很多方法,这些方法具有什么作用,在本文相应的环节会进行解释。我们来看看函数transform的具体实现:

代码语言:javascript复制
// 代码片段2
export function transform(root: RootNode, options: TransformOptions) {
  const context = createTransformContext(root, options)
  traverseNode(root, context)
  if (options.hoistStatic) {
    hoistStatic(root, context)
  }
  if (!options.ssr) {
    createRootCodegen(root, context)
  }
  // finalize meta information
  root.helpers = [...context.helpers.keys()]
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = context.imports
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached

  if (__COMPAT__) {
    root.filters = [...context.filters!]
  }
}

函数transfrom的逻辑很清晰,主要做了下面3件事情:

  1. 创建转换上下文;
  2. 调用函数traverseNode对AST进行转化;
  3. 对根节点进行处理。

如果这里不理解上面三件事情的具体含义,不用担心,我们后面逐一进行分析。

createTransformContext

函数createTransformContext的功能是创建一个上下文对象,具体代码实现如下:

代码语言:javascript复制
// 代码片段3
export function createTransformContext(
  root: RootNode,
  // 此处省略若干参数...
): TransformContext {
  const context: TransformContext = {
    // 此处省略若干属性...
    // 此处省略若干方法...
  }
  // 此处省略若干代码...
  return context
}

我们只需要知道,所谓的上下文,其实就是一个对象,这个对象用属性保存了很多转化环节相关的状态信息,比如正在转化哪个节点,用到了哪些创建节点的函数等等,同时也提供了很多能力,至于里面的属性各自代表什么含义,方法具备什么功能,我们暂时先忽略,在分析相关内容的时候再解释。

traverseNode

函数tranverseNode是整个AST转化环节最核心的方法,我们先来看其代码实现:

代码语言:javascript复制
// 代码片段4
export function traverseNode(
  node: RootNode | TemplateChildNode,
  context: TransformContext
) {
  context.currentNode = node
  const { nodeTransforms } = context
  const exitFns = []
  for (let i = 0; i < nodeTransforms.length; i  ) {
    const onExit = nodeTransforms[i](node, context)
    if (onExit) {
      if (isArray(onExit)) {
        exitFns.push(...onExit)
      } else {
        exitFns.push(onExit)
      }
    }
    // 此处省略若干代码...
  }

  switch (node.type) {
    case NodeTypes.COMMENT:
      // 此处省略若干代码...
      break
    case NodeTypes.INTERPOLATION:
      // 此处省略若干代码...
      break
    case NodeTypes.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
  }

  context.currentNode = node
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}

该函数有两个关键点值得我们注意,首先是nodeTransforms所代表的插件化架构;其次,是exitFns所代表的洋葱模型。

插件化架构

还记得我们在分析入口函数baseCompile的时候,当时我们忽略了传给transform函数的第二个参数。这个参数实际上包括了很多函数,也就是tranverseNode函数中的nodeTransforms。这个nodeTransforms包括哪些函数呢?我们回顾下baseCompile函数中的相关代码:

代码语言:javascript复制
// 代码片段5
import { transform, NodeTransform, DirectiveTransform } from './transform'
import { transformIf } from './transforms/vIf'
import { transformFor } from './transforms/vFor'
import { transformExpression } from './transforms/transformExpression'
import { transformSlotOutlet } from './transforms/transformSlotOutlet'
import { transformElement } from './transforms/transformElement'
import { transformOn } from './transforms/vOn'
import { transformBind } from './transforms/vBind'
import { trackSlotScopes, trackVForSlotScopes } from './transforms/vSlot'
import { transformText } from './transforms/transformText'
import { transformOnce } from './transforms/vOnce'
import { transformModel } from './transforms/vModel'
import { transformFilter } from './compat/transformFilter'
import { defaultOnError, createCompilerError, ErrorCodes } from './errors'
import { transformMemo } from './transforms/vMemo'

export function getBaseTransformPreset(
  prefixIdentifiers?: boolean
): TransformPreset {
  return [
    [
      transformOnce,
      transformIf,
      transformMemo,
      transformFor,
      ...(__COMPAT__ ? [transformFilter] : []),
      ...(!__BROWSER__ && prefixIdentifiers
        ? [
            // order is important
            trackVForSlotScopes,
            transformExpression
          ]
        : __BROWSER__ && __DEV__
        ? [transformExpression]
        : []),
      transformSlotOutlet,
      transformElement,
      trackSlotScopes,
      transformText
    ],
    {
      on: transformOn,
      bind: transformBind,
      model: transformModel
    }
  ]
}


const [nodeTransforms, directiveTransforms] = getBaseTransformPreset(prefixIdentifiers)


transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )

我们从代码片段5中可以很直观的看到,所谓的nodeTransforms实际上就是一系列函数。这些函数各自承担着对节点的某个特定部分的内容进行转化的功能。比如有专门解析标签的,有专门解析文本节点的,有专门解析v-if指令的等等。这样设计有什么好处呢,一个明显的好处是可扩展性会很强,这些转换函数各司其职,如果将来有新的内容类型需要解析,那直接添加一个处理函数即可。当然这样的结构代码可维护性也会大大增强。处理特定内容的函数各自独立互不干扰。

需要注意的是,nodeTransforms是一个数组,里面会存放很多转换函数,这些转换函数是有序的,不可以随意调换位置,比如对于if的处理优先级就比较高,因为如果条件不满足很可能有大部分内容都没必要进行转换。

洋葱模型

从代码片段4中我们可以发现,代码大致可以分为三个部分:

  1. 遍历nodeTransforms上的函数并依次执行,每个函数执行的返回结果都是一个函数,将这些返回的函数存放在一个数组中;
  2. 对子节点进行转化操作;
  3. 遍历第一步中数组中保存的函数并执行。

从这个过程中我们首先要明白这里的转化操作是从根节点深度遍历子节点,结合上面提到的代码片段4中的三个部分,我们可以这样理解,我们对节点进行转化的时候是从根节点出发进行处理,也就是说相当于对一棵树进行深度遍历,但是父节点的处理是依赖于子节点的,所以虽然是自顶向下进行遍历,但是实际处理过程却是只下而上进行处理。这也就是为什么要将父节点的处理函数存放在数组中,在子节点处理完成后再遍历执行这些函数。

transformElement

根据上文我们知道了对节点进行处理,就是通过一系列函数对节点的的各个部分的内容分别进行处理。鉴于这些函数很多内容也很庞杂,我们拿其中一个函数transformElement进行分析,理解对AST的转化过程。我们先来看看其代码实现:

代码语言:javascript复制
// 代码片段6
// generate a JavaScript AST for this element's codegen
export const transformElement: NodeTransform = (node, context) => {
  return function postTransformElement() {
    // 此处省略了绝大部分代码...
    node.codegenNode = createVNodeCall(
      context,
      vnodeTag,
      vnodeProps,
      vnodeChildren,
      vnodePatchFlag,
      vnodeDynamicProps,
      vnodeDirectives,
      !!shouldUseBlock,
      false /* disableTracking */,
      isComponent,
      node.loc
    )
  }
}

代码片段6省略了绝大部分代码,只留下了最后一行代码,调用createVNodeCall函数获取描述js代码的对象,并赋值给node.codegenNode。到了这里我们就可以很清楚的意识到,所谓的对AST进行转化,实际上就是给AST的codegenNode属性赋值,该属性的值就是用来描述js代码的。接下来我们继续深入到createVNodeCall函数中去。

createVNodeCall

代码语言:javascript复制
// 代码片段7
export function createVNodeCall(
  context: TransformContext | null,
  tag: VNodeCall['tag'],
  props?: VNodeCall['props'],
  children?: VNodeCall['children'],
  patchFlag?: VNodeCall['patchFlag'],
  dynamicProps?: VNodeCall['dynamicProps'],
  directives?: VNodeCall['directives'],
  isBlock: VNodeCall['isBlock'] = false,
  disableTracking: VNodeCall['disableTracking'] = false,
  isComponent: VNodeCall['isComponent'] = false,
  loc = locStub
): VNodeCall {
  if (context) {
    if (isBlock) {
      context.helper(OPEN_BLOCK)
      context.helper(getVNodeBlockHelper(context.inSSR, isComponent))
    } else {
      context.helper(getVNodeHelper(context.inSSR, isComponent))
    }
    if (directives) {
      context.helper(WITH_DIRECTIVES)
    }
  }

  return {
    type: NodeTypes.VNODE_CALL,
    tag,
    props,
    children,
    patchFlag,
    dynamicProps,
    directives,
    isBlock,
    disableTracking,
    isComponent,
    loc
  }
}

函数createVNodeCall的逻辑很清晰,最终结果是返回一个对象。这里的函数context.helper其实就是在统计生成这些代码需要导入哪些函数,在生成代码拼接字符串的时候会用到。至于openBlock的含义,我们在前面的关于编译优化的文章中已经分析过了。我们从这个返回的对象可以获取生成代码所需要的内容,回顾我们前面讲到的render函数其实就是一个返回虚拟Node的函数,那我们在生成这些代码的时候需要知道的是调用什么方法来创建虚拟Node,同时要知道该节点有什么样子的属性?以及有什么样的指令?是不是组件?是不是动态节点?是否需要优化?等等内容在函数createVNodeCall的返回结果对象中都有体现。

其实从这里也可以理解为什么要利用洋葱模型,因为在调用类似createVNodeCall这种创建codegenNode对象的时候,需要有children,而children只有解析完成之后才能获取其子节点的codegenNode

createRootCodegen

我们先来观察createRootCodegen的函数实现:

代码语言:javascript复制
// 代码片段8
function createRootCodegen(root: RootNode, context: TransformContext) {
  const { helper } = context
  const { children } = root
  if (children.length === 1) {
    const child = children[0]
    if (isSingleElementRoot(root, child) && child.codegenNode) {
      const codegenNode = child.codegenNode
      if (codegenNode.type === NodeTypes.VNODE_CALL) {
        makeBlock(codegenNode, context)
      }
      root.codegenNode = codegenNode
    } else {
      root.codegenNode = child
    }
  } else if (children.length > 1) {
    let patchFlag = PatchFlags.STABLE_FRAGMENT
    let patchFlagText = PatchFlagNames[PatchFlags.STABLE_FRAGMENT]
    // 此处省略若干代码...
    root.codegenNode = createVNodeCall(
      context,
      helper(FRAGMENT),
      undefined,
      root.children,
      patchFlag   (__DEV__ ? ` /* ${patchFlagText} */` : ``),
      undefined,
      undefined,
      true,
      undefined,
      false /* isComponent */
    )
  } else {
    // no children = noop. codegen will return null.
  }
}

所谓createRootCodegen,就是创建根节点的codegenNode对象。由于Vue3可以在模版中写多个根节点,所以需要处理成Fragment,这也就是为什么代码片段8中在children.length>1的时候会调用createVNodeCall创建codegenNode对象的原因。否则,就代表着只有一个根节点不需要额外处理,直接让根节点的codegenNode等于第一个子节点的根节点的codegenNode即可。

代码生成

有了上文分析过的codegenNode对象,接下来的代码生成实际上就是一个拼接字符串的过程。我们来看看代码生成相关的函数generate

代码语言:javascript复制
// 代码片段9
export function generate(
  ast: RootNode,
  options: CodegenOptions & {
    onContextCreated?: (context: CodegenContext) => void
  } = {}
): CodegenResult {
  const context = createCodegenContext(ast, options)
  // 省略若干代码生成过程相关的代码...
  return {
    ast,
    code: context.code,
    preamble: isSetupInlined ? preambleContext.code : ``,
    // SourceMapGenerator does have toJSON() method but it's not in the types
    map: context.map ? (context.map as any).toJSON() : undefined
  }
}

在省略大量代码后,我们可以认为函数generate做了三件事情:

  1. 创建contextcontext上包括了若干字符串拼接相关的方法;
  2. 对AST的codegenNode对象进行深度递归,并利用context提供的方法拼接相关字符串;
  3. 返回结果对象,包括code属性,code属性值就是生成的render函数代码字符串。

createCodegenContext

函数createCodegenContext的代码实现如下:

代码语言:javascript复制
// 代码片段10
function createCodegenContext(
  ast: RootNode,
  {
    // 此处省略若干参数...
  }: CodegenOptions
): CodegenContext {
  const context: CodegenContext = {
    // 此处省略若干属性...
    helper(key) {
      return `_${helperNameMap[key]}`
    },
    push(code, node) {
      context.code  = code
      // 此处省略若干属性...
    },
    indent() {
      newline(  context.indentLevel)
    },
    deindent(withoutNewLine = false) {
      if (withoutNewLine) {
        --context.indentLevel
      } else {
        newline(--context.indentLevel)
      }
    },
    newline() {
      newline(context.indentLevel)
    }
  }
  // 此处省略若干属性...
  return context
}

在省略大量代码后,我们可以清晰的看到,context对象包括的重要的方法:

  1. 代码缩进相关的indentdeindent两个函数;
  2. 代码拼接函数push
  3. helper函数主要用于获取创建节点时候用到的具体函数。

genNode

函数genNode就是根据AST的属性codegenNode的值生成字符串的过程:

代码语言:javascript复制
// 代码片段11
function genNode(node: CodegenNode | symbol | string, context: CodegenContext) {
  if (isString(node)) {
    context.push(node)
    return
  }
  if (isSymbol(node)) {
    context.push(context.helper(node))
    return
  }
  switch (node.type) {
    case NodeTypes.ELEMENT:
    case NodeTypes.IF:
    case NodeTypes.FOR:
      // 省略若干代码...
      genNode(node.codegenNode!, context)
      break
    case NodeTypes.TEXT:
      genText(node, context)
      break
    case NodeTypes.SIMPLE_EXPRESSION:
      genExpression(node, context)
      break
    case NodeTypes.INTERPOLATION:
      genInterpolation(node, context)
      break
    case NodeTypes.TEXT_CALL:
      genNode(node.codegenNode, context)
      break
    case NodeTypes.COMPOUND_EXPRESSION:
      genCompoundExpression(node, context)
      break
    case NodeTypes.COMMENT:
      genComment(node, context)
      break
    case NodeTypes.VNODE_CALL:
      genVNodeCall(node, context)
      break

    case NodeTypes.JS_CALL_EXPRESSION:
      genCallExpression(node, context)
      break
    case NodeTypes.JS_OBJECT_EXPRESSION:
      genObjectExpression(node, context)
      break
    case NodeTypes.JS_ARRAY_EXPRESSION:
      genArrayExpression(node, context)
      break
    case NodeTypes.JS_FUNCTION_EXPRESSION:
      genFunctionExpression(node, context)
      break
    case NodeTypes.JS_CONDITIONAL_EXPRESSION:
      genConditionalExpression(node, context)
      break
    case NodeTypes.JS_CACHE_EXPRESSION:
      genCacheExpression(node, context)
      break
    case NodeTypes.JS_BLOCK_STATEMENT:
      genNodeList(node.body, context, true, false)
      break
    // 省略若干代码...
  }
}

从代码片段11不难看出,生成代码需要根据不同的节点类型单独进行处理,因为不同类型的节点的代码结构上不相同的。至于各个函数内部,都是调用context对象提供的方法对字符串进行拼接。

学习方法

大家可以在网址https://vue-next-template-explorer.netlify.app/上直观感受到模版字符串和对应的render函数。在debug的过程中对照这里的render函数,相信大家可以快速的深入理解代码生成的过程。

0 人点赞