乐趣区

关于vue.js:从编译过程理解-Vue3-静态节点提升过程源码分析

前言

动态节点晋升 是「Vue3」针对 VNode 更新过程性能问题而提出的一个优化点。家喻户晓,在大型利用场景下,「Vue2.x」的 patchVNode 过程,即 diff 过程是十分迟缓的,这是一个非常令人头疼的问题。

尽管,对于面试常问的 diff 过程在肯定水平上是缩小了对 DOM 的间接操作。然而,这个缩小是有肯定老本的。因为,如果是简单利用,那么就会存在父子关系非常复杂的 VNode,而这也就是 diff 的痛点,它会一直地递归调用 patchVNode,一直重叠而成的几毫秒,最终就会造成 VNode 更新迟缓。

也因而,这也是为什么咱们所看到的大型利用诸如阿里云之类的采纳的是基于「React」的技术栈的起因之一。所以,「Vue3」也是改过自新,重写了整个 Compiler 过程,提出了动态晋升、靶向更新等优化点,来进步 patchVNode 过程。

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

什么是 patchFlag

因为,在 compile 过程的 transfrom 阶段会提及 AST Element 上的 patchFlag 属性。所以,在正式意识 complie 之前,咱们先搞清楚一个概念,什么是 patchFlag

patchFlagcomplier 时的 transform 阶段解析 AST Element 打上的 优化标识。并且,顾名思义 patchFlagpatch 一词示意着它会为 runtime 时的 patchVNode 提供根据,从而实现靶向更新 VNode 的成果。因而,这样一来一往,也就是耳熟能详的 Vue3 奇妙联合 runtimecompiler 实现靶向更新和动态晋升。

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

并且,值得一提的是整体上 patchFlag 的分为两大类:

  • patchFlag 的值 大于 0 时,代表所对应的元素在 patchVNode 时或 render 时是能够被优化生成或更新的。
  • patchFlag 的值 小于 0 时,代表所对应的元素在 patchVNode 时,是须要被 full diff,即进行递归遍历 VNode tree 的比拟更新过程。

其实,还有两类非凡的 flagshapeFlagshapeFlag,这里我就不对此开展,有趣味的同学能够自行去理解。

Compile 编译过程

比照 Vue2.x 编译过程

理解过「Vue2.x」源码的同学,我想应该都晓得在「Vue2.x」中的 Compile 过程会是这样:

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

而在「Vue3」中,整体的 Compile 过程依然是三个阶段,然而不同于「Vue2.x」的是,第二个阶段换成了失常编译器都会存在的阶段 transform。所以,它看起来会是这样:


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

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)下载过程

❤️ 爱心三连击

通过浏览,如果你感觉有播种的话,能够爱心三连击!!!

退出移动版