从编译过程,理解 Vue3 静态节点提高(源码分析)

前言

静态节点提高是「Vue3」针对 VNode 更新过程性能问题而提出的一个优化点。众所周知,在大型应用场景下,「Vue2.x」的 patchVNode 过程,即 diff 过程是很是缓慢的,这是一个十分使人头疼的问题。javascript

虽然,对于面试常问的 diff 过程在必定程度上是减小了对 DOM 的直接操做。可是,这个减小是有必定成本的。由于,若是是复杂应用,那么就会存在父子关系很是复杂的 VNode,而这也就是 diff 的痛点,它会不断地递归调用 patchVNode,不断堆叠而成的几毫秒,最终就会形成 VNode 更新缓慢。前端

也所以,这也是为何咱们所看到的大型应用诸如阿里云之类的采用的是基于「React」的技术栈的缘由之一。因此,「Vue3」也是痛改前非,重写了整个 Compiler 过程,提出了静态提高、靶向更新等优化点,来提升 patchVNode 过程。vue

那么,回到今天的正题,咱们从源码角度看看在整个编译过程「Vue3」静态节点提高到底是何许人也java

什么是 patchFlag

因为,在 compile 过程的 transfrom 阶段会说起 AST Element 上的 patchFlag 属性。因此,在正式认识 complie 以前,咱们先搞清楚一个概念,什么是 patchFlagnode

patchFlagcomplier 时的 transform 阶段解析 AST Element 打上的优化标识。而且,顾名思义 patchFlagpatch 一词表示着它会为 runtime 时的 patchVNode 提供依据,从而实现靶向更新 VNode 的效果。所以,这样一来一往,也就是耳熟能详的 Vue3 巧妙结合 runtimecompiler 实现靶向更新和静态提高。面试

而在源码中 patchFlag 被定义为一个数字枚举类型,每个枚举值对应的标识意义会是这样:后端

而且,值得一提的是总体上 patchFlag 的分为两大类:函数

  • patchFlag 的值大于 0 时,表明所对应的元素在 patchVNode 时或 render 时是能够被优化生成或更新的。
  • patchFlag 的值小于 0 时,表明所对应的元素在 patchVNode 时,是须要被 full diff,即进行递归遍历 VNode tree 的比较更新过程。
其实,还有两类特殊的 flagshapeFlagslotFlag,这里我就不对此展开,有兴趣的同窗能够自行去了解。

Compile 编译过程

对比 Vue2.x 编译过程

了解过「Vue2.x」源码的同窗,我想应该都知道在「Vue2.x」中的 Compile 过程会是这样:源码分析

  • parse 编译模板生成原始 AST。
  • optimize 优化原始 AST,标记 AST Element 为静态根节点或静态节点。
  • generate 根据优化后的 AST,生成可执行代码,例如 _c_l 之类的。

而在「Vue3」中,总体的 Compile 过程仍然是三个阶段,可是不一样于「Vue2.x」的是,第二个阶段换成了正常编译器都会存在的阶段 transform。因此,它看起来会是这样:post


在源码中,它对应的伪代码会是这样:

export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  ...
  const ast = isString(template) ? baseParse(template, options) : template
  ...
  transform(
    ast,
    extend({}, options, {....})
  )

  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

那么,我想这个时候你们可能会问为何会是 transform?它的职责是什么?

经过简单的对比「Vue2.x」编译过程的第二阶段的 optimize,很明显,transform 并非无米之炊,它仍然有着优化原始 AST 的做用,而具体职责会表如今:

  • 对全部 AST Element 新增 codegen 属性来帮助 generate 更准确地生成最优的可执行代码。
  • 对静态 AST Element 新增 hoists 属性来实现静态节点的单首创建
  • ...

此外,transform 还标识了诸如 isBlockhelpers 等属性,来生成最优的可执行代码,这里咱们就不细谈,有兴趣的同窗能够自行了解。

baseParse 构建原始抽象语法树(AST)

baseParse 顾名思义起着解析的做用,它的表现和「Vue2.x」的 parse 相同,都是解析模板 tempalte 生成原始 AST

假设,此时咱们有一个这样的模板 template

<div><div>hi vue3</div><div>{{msg}}</div></div>

那么,它在通过 baseParse 处理后生成的 AST 看起来会是这样:

{
  cached: 0,
  children: [{…}],
  codegenNode: undefined,
  components: [],
  directives: [],
  helpers: [],
  hoists: [],
  imports: [],
  loc: {start: {…}, end: {…}, source: "<div><div>hi vue3</div><div>{{msg}}</div></div>"},
  temps: 0,
  type: 0
}

若是,了解过「Vue2.x」编译过程的同窗应该对于上面这颗 AST 的大部分属性不会陌生。AST 的本质是经过用对象来描述「DSL」(特殊领域语言),例如:

  • children 中存放的就是最外层 div 的后代。
  • loc 则用来描述这个 AST Element 在整个字符串(template)中的位置信息。
  • type 则是用于描述这个元素的类型(例如 5 为插值、2 为文本)等等。

而且,能够看到的是不一样于「Vue2.x」的 AST,这里咱们多了诸如 helperscodegenNodehoists 等属性。而,这些属性会在 transform 阶段进行相应地赋值,进而帮助 generate 阶段生成更优的可执行代码。

transfrom 优化原始抽象语法树(AST)

对于 transform 阶段,若是了解过编译器的工做流程的同窗应该知道,一个完整的编译器的工做流程会是这样:

  • 首先,parse 解析原始代码字符串,生成抽象语法树 AST。
  • 其次,transform 转化抽象语法树,让它变成更贴近目标「DSL」的结构。
  • 最后,codegen 根据转化后的抽象语法树生成目标「DSL」的可执行代码。

而在「Vue3」采用 Monorepo 的方式管理项目后,compile 对应的能力就是一个编译器。因此,transform 也是整个编译过程的重中之重。换句话说,若是没有 transformAST 作诸多层面的转化,「Vue」仍然会挂在 diff 这个饱受诟病的过程。

相比之下,「Vue2.x」的编译阶段没有完整的 transform,只是 optimize 优化了一下 AST,能够想象在「Vue」设计之初尤大也没想到它之后会 这么地流行

那么,咱们来看看 transform 函数源码中的定义:

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]
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = [...context.imports]
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached
}

能够说,transform 函数作了什么,在它的定义中是尽收眼底。这里咱们提一下它对静态提高其决定性做用的两件事:

  • 将原始 AST 中的静态节点对应的 AST Element 赋值给根 AST 的 hoists 属性。
  • 获取原始 AST 须要的 helpers 对应的键名,用于 generate 阶段的生成可执行代码的获取对应函数,例如 createTextVNodecreateStaticVNoderenderList 等等。

而且,在 traverseNode 函数中会对 AST Element 应用具体的 transform 函数,大体能够分为两类:

  • 静态节点 transform 应用,即节点不含有插值、指令、props、动态样式的绑定等。
  • 动态节点 transform 应用,即节点含有插值、指令、props、动态样式的绑定等。

那么,咱们就来看看对于静态节点 transform 是如何应用的?

静态节点 transform 应用

这里,对于上面咱们说到的这个栗子,静态节点就是这个部分:

<div>hi vue3</div>

而它在没有进行 transform 应用以前,它对应的 AST 会是这样:

{
  children: [{
    content: "hi vue3"
    loc: {start: {…}, end: {…}, source: "hi vue3"}
    type: 2
  }],
  codegenNode: undefined,
  isSelfClosing: false,
  loc: {start: {…}, end: {…}, source: "<div>hi vue3</div>"},
  ns: 0,
  props: [],
  tag: "div",
  tagType: 0,
  type: 1
}

能够看出,此时它的 codegenNodeundefined。而在源码中各种 transform 函数被定义为 plugin,它会根据 baseParse 生成的 AST 递归应用对应的 plugin。而后,建立对应 AST Element 的 codegen 对象。

因此,此时咱们会命中 transformElementtransformText 两个 plugin 的逻辑。

transformText

transformText 顾名思义,它和文本相关。很显然,此时的 AST Element 所属的类型就是 Text。那么,咱们先来看一下 transformText 函数对应的伪代码:

export const transformText: NodeTransform = (node, context) => {
  if (
    node.type === NodeTypes.ROOT ||
    node.type === NodeTypes.ELEMENT ||
    node.type === NodeTypes.FOR ||
    node.type === NodeTypes.IF_BRANCH
  ) {
    return () => {
      const children = node.children
      let currentContainer: CompoundExpressionNode | undefined = undefined
      let hasText = false

      for (let i = 0; i < children.length; i++) { // {1}
        const child = children[i]
        if (isText(child)) {
          hasText = true
          ...
        }
      }
      if (
        !hasText ||
        (children.length === 1 &&
          (node.type === NodeTypes.ROOT ||
            (node.type === NodeTypes.ELEMENT &&
              node.tagType === ElementTypes.ELEMENT)))
      ) { // {2}
        return
      }
      ...
    }
  }
}

能够看到,这里咱们会命中 {2} 的逻辑,即若是对于节点含有单一文本 transformText 并不须要进行额外的处理,即该节点仍然在这里仍然保留和「Vue2.x」版本同样的处理方式。

transfromText 真正发挥做用的场景是当模板中存在这样的状况:

<div>ab {a} {b}</div>

此时 transformText 须要将二者放在一个单独的 AST Element 下,在源码中它被称为「Compound Expression」,即组合的表达式。这种组合的目的是为了 patchVNode 这类 VNode 时作到更好地定位和实现 DOM 的更新。反之,若是是一个文本节点和插值动态节点的话,在 patchVNode 阶段一样的操做须要进行两次,例如对于同一个 DOM 节点操做两次。

transformElement

transformElement 是一个全部 AST Element 都会被执行的一个 plugin,它的核心是为 AST Element 生成最基础的 codegen属性。例如标识出对应 patchFlag,从而为生成 VNode 提供依据,例如 dynamicChildren

而对于静态节点,一样是起到一个初始化它的 codegenNode 属性的做用。而且,从上面介绍的 patchFlag 的类型,咱们能够知道它的 patchFlag 为默认值 0。因此,它的 codegenNode 属性值看起来会是这样:

{
  children: {
    content: "hi vue3"
    loc: {start: {…}, end: {…}, source: "hi vue3"}
    type: 2
  },
  directives: undefined,
  disableTracking: false,
  dynamicProps: undefined,
  isBlock: false,
  loc: {start: {…}, end: {…}, source: "<div>hi vue3</div>"},
  patchFlag: undefined,
  props: undefined,
  tag: ""div"",
  type: 13
}

generate 生成可执行代码

generatecompile 阶段的最后一步,它的做用是将 transform 转换后的 AST 生成对应的可执行代码,从而在以后 Runtime 的 Render 阶段时,就能够经过可执行代码生成对应的 VNode Tree,而后最终映射为真实的 DOM Tree 在页面上。

一样地,这一阶段在「Vue2.x」也是由 generate 函数完成,它会生成是诸如 _l_c 之类的函数,这本质上是对 _createElement 函数的封装。而相比较「Vue2.x」版本的 generate,「Vue3」改变了不少,其 generate 函数对于的伪代码会是这样:

export function generate(
  ast: RootNode,
  options: CodegenOptions & {
    onContextCreated?: (context: CodegenContext) => void
  } = {}
): CodegenResult {
  const context = createCodegenContext(ast, options)
  if (options.onContextCreated) options.onContextCreated(context)
  const {
    mode,
    push,
    prefixIdentifiers,
    indent,
    deindent,
    newline,
    scopeId,
    ssr
  } = context
  ...
  genFunctionPreamble(ast, context)
  ...

  if (!ssr) {
    ...
    push(`function render(_ctx, _cache${optimizeSources}) {`)
  }
  ....

  return {
    ast,
    code: context.code,
    // SourceMapGenerator does have toJSON() method but it's not in the types
    map: context.map ? (context.map as any).toJSON() : undefined
  }
}

因此,接下来,咱们就来一睹带有静态节点对应的 AST 生成的可执行代码的过程会是怎样。

CodegenContext 代码生成上下文

从上面 generate 函数的伪代码能够看到,在函数的开始调用了 createCodegenContext 为当前 AST 生成了一个 context。在整个 generate 函数的执行过程都依托于一个 CodegenContext 生成代码上下文(对象)的能力,它是经过 createCodegenContext 函数生成。而 CodegenContext 的接口定义会是这样:

interface CodegenContext
  extends Omit<Required<CodegenOptions>, 'bindingMetadata'> {
  source: string
  code: string
  line: number
  column: number
  offset: number
  indentLevel: number
  pure: boolean
  map?: SourceMapGenerator
  helper(key: symbol): string
  push(code: string, node?: CodegenNode): void
  indent(): void
  deindent(withoutNewLine?: boolean): void
  newline(): void
}

能够看到 CodegenContext 对象中有诸如 pushindentnewline 之类的方法。而它们的做用是在根据 AST 来生成代码时用来实现换行添加代码缩进等功能。从而,最终造成一个个可执行代码,即咱们所认知的 render 函数,而且,它会做为 CodegenContextcode 属性的值返回。

下面,咱们就来看下静态节点的可执行代码生成的核心,它被称为 Preamble 前导。

genFunctionPreamble 生成前准备

整个静态提高的可执行代码生成就是在 genFunctionPreamble 函数部分完成的。而且,你们仔细斟酌一番静态提高的字眼,静态二字咱们能够不看,可是提高二字,直抒本意地表达出它(静态节点)被提升了

为何说是提升了?由于在源码中的体现,确实是被提升了。在前面的 generate 函数,咱们能够看到 genFunctionPreamble 是先于 render 函数加入 context.code 中,因此,在 Runtime 阶段的 Render,它会先于 render 函数执行。

geneFunctionPreamble 函数(伪代码):

function genFunctionPreamble(ast: RootNode, context: CodegenContext) {
  const {
    ssr,
    prefixIdentifiers,
    push,
    newline,
    runtimeModuleName,
    runtimeGlobalName
  } = context
  ...
  const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`
  if (ast.helpers.length > 0) {
    ...
    if (ast.hoists.length) {
      const staticHelpers = [
        CREATE_VNODE,
        CREATE_COMMENT,
        CREATE_TEXT,
        CREATE_STATIC
       ]
        .filter(helper => ast.helpers.includes(helper))
        .map(aliasHelper)
        .join(', ')
      push(`const { ${staticHelpers} } = _Vue\n`)
    }
  }
  ...
  genHoists(ast.hoists, context)
  newline()
  push(`return `)
}

能够看到,这里会对前面咱们在 transform 函数说起的 hoists 属性的长度进行判断。显然,对于前面说的这个栗子,它的 ast.hoists.length 长度是大于 0 的。因此,这里就会根据 hoists 中的 AST 生成对应的可执行代码。所以,到这里,生成的可执行代码会是这样:

const _Vue = Vue
const { createVNode: _createVNode } = _Vue
// 静态提高部分
const _hoisted_1 = _createVNode("div", null, "hi vue3", -1 /* HOISTED */)
// render 函数会在这下面

小结

静态节点提高在整个 compile 编译阶段体现,从最初的 baseCompiletransform 转化原始 AST、再到 generate 的优先 render 函数处理生成可执行代码,最后交给 Runtime 时的 Render 执行,这种设计能够说是很是精妙!因此,这样一来,就完成了咱们常常看到在一些文章说起的「Vue3」对于静态节点在整个生命周期中它只会执行一次建立的源码实现,这在必定程度上下降了性能上的开销。

写在最后

看完静态的节点在整个编译过程的处理,我想你们可能都火烧眉毛地想去了解对于静态节点的 patchVNode 又是怎样一番景象?原先,我是打算在一篇文章描述完整个过程,可是后来思考,这无形中给阅读增长了成本。由于,在「Vue3」版本的 patchVNode 已不只仅是 diff 的比较过程,它对于每一种 VNode 都实现了不一样的 patch 过程。因此,patchVNode 的过程会在写在下一篇文章,敬请期待!

往期文章回顾

从零到一,带你完全搞懂 vite 中的 HMR 原理(源码分析)

详解,从后端导出文件到前端(Blob)下载过程

❤️ 爱心三连击

经过阅读,若是你以为有收获的话,能够爱心三连击!!!

相关文章
相关标签/搜索