
面向 Vue 中 VNode 的一次 Deep Dive
从 Vue 源码出发,探索 Vue 内部针对虚拟 DOM 的各种优化操作
Vue 针对虚拟 DOM 做了什么特殊优化?
渲染机制 | Vue.js (vuejs.org) Vue3 的hoist与diff (funfish.github.io)
针对虚拟 DOM 而言,Vue 的优化重点放在了同时控制 编译 compile 和 运行时 runtime,通过两者的紧密耦合(互相可预见)来实现的 带编译信息的虚拟 DOM。
静态节点提升(Static Node Hoisting)
针对完全静态的元素,因为没有重新渲染或 diff 的必要,所以 Vue 会自动提升这部分的 vnode 创建函数到模版的渲染函数之外。
在足够多连续的静态元素时,会被压缩为一个静态 vnode(字符串形式),直接通过 innerHTML 来挂载。
源码分析
尤大 19 年的 commit: feat(compiler): hoist static trees · vuejs/core@095f5ed (github.com)
编译过程:从 compile-core 文件夹中编写的 createRoot(), genFunctionPreamble() 等函数可以发现,Vue 在编译过程中会在根结点(Root Node)中维护一个 hoists 属性,存放静态的节点。
compiler.compile(..., {hoistStatic: true}) 函数中的 hoistStatic 就决定了是否要在编译(模版=>实际的渲染函数)当前节点的过程中启用静态节点提升。
针对启用了 hoistStatic 的组件,会在编译过程中利用 walk 函数遍历并标记静态节点。判定静态节点的逻辑较为复杂,其遵循的主要逻辑包括:
- 根结点不会被 hoist,考虑到可能的 props 透传。
 - 对于普通的 Element 结点,需要其本身和所有子节点没有 
key, ref, 绑定的 props, 指令等,才有可能上升为 static。 - 判断子节点的过程中,使用哈希表 
constantCache来缓存判断的结果。 - 针对没有被 hoist 的节点,其 props 在非动态变更的情况下,可以被 
hoist。 - 单独的文本节点,可以被 
hoist。 
当节点满足可 hoist,并且连续拥有至少20个静态节点或5个有 props 的节点,就会通过字符串化来存储和 innerHTML 来渲染节点。参考 compiler-dom/src/transforms/stringifyStatic.ts。
虽然利用 innerHTML 来存储和渲染静态节点可以有效减少 Virtual DOM 的数量,但是判断和生成字符串需要遍历两次节点树,所以编译过程会更加耗费时间。
以这个组件为例: ^3ed9c1
<template>
    <div>1</div>
    <div>{{ t }}</div>
    <div>
        <a href="1" />
        <a href="1" />
        <a href="1" />
        <a href="1" />
        <a href="1" />
    </div>
</template>
<script lang="ts" setup>
const t = 1;
</script>
编译后的渲染函数:
const _hoisted_1 = /* @__PURE__ */ _createElementVNode(
  "div",
  null,
  "1",
  -1
  /* HOISTED */
);
const _hoisted_2 = /* @__PURE__ */ _createStaticVNode('<div><a href="1"></a><a href="1"></a><a href="1"></a><a href="1"></a><a href="1"></a></div>', 1);
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createElementBlock(
    _Fragment,
    null,
    [
      _hoisted_1,
      _createElementVNode("div", null, _toDisplayString($setup.t)),
      _hoisted_2
    ],
    64
    /* STABLE_FRAGMENT */
  );
}
更新类型标记(Patch Flag)
在编译的过程中,会有一些标记信息(Patch Flag)存放在渲染函数中,其中 Patch 在 Vue 中的含义就是更新,同义词是 diffing。
假设当前的节点模版长这样:
<div :class="{ active }"></div>
<div :class="{ active }" :age={dynamic}>{{dynamic}}</div>
通过 Vue Template Explorer (vuejs.org) 查看编译后的渲染函数:
createElementVNode("div", { class: _normalizeClass({ active: _ctx.active }) }, null, 2 /* CLASS */)
_createElementBlock("div", {
    class: _normalizeClass({ active: _ctx.active }),
    age: {dynamic: _ctx.dynamic}
  }, _toDisplayString(_ctx.dynamic), 11 /* TEXT, CLASS, PROPS */, ["age"])
渲染函数中的数字 2 和 11 就是这两个节点的 Patch Flag,是通过位运算出来的。比如第一个节点只绑定了动态的 class,那么它的 Patch Flag 就是 10b,而第二个节点则是对应了动态的 text, class 和 props,所以对应 Flag 就是 1011b。
运行时的渲染器就会利用这些编译时增加的 Patch Flag 信息,进行相应的更新操作。
这个 Patch Flag 在渲染的时候到底有什么好处呢?挂载在 vNode 上的 patchFlag 可以帮助快速定位需要执行的 diff 操作。在 shouldUpdateComponent 的生命周期内就会进行以下操作:
if (patchFlag > 0) {
 if (patchFlag & patchFlag.DYNAMIC_SLOTS) return true
 if (patchFlag & PatchFlags.PROPS) // 逐个对比动态变更的 props
 ...
}
在 render 的过程中,会对已经 mount 的 element 进行 patch 操作,具体函数是 patchElement,里面也会对 patchFlag 进行比对,并且具体 vNode 上最终有哪些属性被更新了。大致如下:
if (patchFlag > 0) {
 if (patchFlag & patchFlag.FULL_PROPS) patchProps(...) // 在 key 变动的情况下,对 props 进行全量更新
 if (patchFlag & PatchFlags.CLASS) hostPatchProp(el, 'class', ...)
 if (patchFlag & PatchFlags.STYLE) hostPatchProp(el, 'style', ...)
 ...
}
上个章节对 Hoist 进行分析的时候举的 🌰 中,我们发现当前节点的 Patch Flag 设为了 64。这就相当于告诉运行时渲染器在更新的过程中,这些根结点的相对位置不会发生变化,因此更新(Patch)的过程中,就不会进行节点重新排序的操作。
块(Block)的优化 - 树结构打平
一个内部稳定的节点在 Vue 中被称之为 区块 BLOCK,在这里稳定指的是没有用到会影响结构的指令(v-if, v-for)。
Vue 官方给出的例子:
<div> <!-- root block -->
  <div>...</div>         <!-- 不会追踪 -->
  <div :id="id"></div>   <!-- 要追踪 -->
  <div>                  <!-- 不会追踪 -->
    <div>{{ bar }}</div> <!-- 要追踪 -->
  </div>
</div>
这本身是一棵 DOM 树,但是 Vue 可以细分 Block 节点的整棵树上有哪些需要追踪的节点,忽略不需要追踪的节点。对于 Block 来说,他只需要追踪有可能会更新的节点,并且打平成一个数组(听起来跟 [1, [2, 3]].flat() 有点像)。
这个操作就可以让重渲染的时候减少需要遍历的节点数量,时间复杂度由原有的 O(N^3) 变成了接近 O(N)。
Vue 中的 vNode 到底维护了哪些属性?
先回顾一下 Vue 编译后的渲染函数长什么样子:Vue Template Explorer (vuejs.org)
<div :class="{ active }">{{dynamic}}</div>
<CarList :cars={cars} />
<script>
// 编译后对应的渲染函数
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_CarList = _resolveComponent("CarList")
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _createElementVNode("div", {
      class: _normalizeClass({ active: _ctx.active })
    }, _toDisplayString(_ctx.dynamic), 3 /* TEXT, CLASS */),
    _createVNode(_component_CarList, {
      cars: {carList: _ctx.carList}
    }, null, 8 /* PROPS */, ["cars"])
  ], 64 /* STABLE_FRAGMENT */))
}
</script>
不难发现,对于元素节点和组件节点,会分别使用 _createElementVNode 和 _createVNode 方法创建虚拟节点。
事实上,在 Vue.js 3.2 之前,都是统一用
createVNode方法创建 VNode 的。直到这个 PR 被提出并和 Vue.js 3.2 一起被发布,才对 element 和 component 的 VNode 创建进行了区分,最终带来了 element 节点渲染过程 200% 的性能提升。
createVNode 是一个抽象层级更高的方法,而 createElementVNode 也被封装在内。
我们先来看一看 createElementVNode 是怎么写的:
// packages/runtime-core/src/vnode.ts
// 对外导出的 createElementVNode 在本文件内的函数名是 createBaseVNode
export { createBaseVNode as createElementVNode }
function createBaseVNode(
  // 节点类型,例如 'div', 'span', ...
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  // 节点的所有 props
  props: (Data & VNodeProps) | null = null,
  // 子节点
  children: unknown = null,
  // 用于 diff 算法
  patchFlag = 0,
  dynamicProps: string[] | null = null,
  shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT,
  isBlockNode = false,
  needFullChildrenNormalization = false
) {
  const vnode = {
    __v_isVNode: true,
    __v_skip: true,
    type, // 节点类型
    props, // 节点 props
    key: props && normalizeKey(props),
    ref: props && normalizeRef(props),
    scopeId: currentScopeId,
    slotScopeIds: null,
    children,
    component: null, // 组件实例
    suspense: null,
    ssContent: null,
    ssFallback: null,
    dirs: null,
    transition: null, // 过渡动画
    el: null, // 对应的 DOM 元素
    anchor: null,
    target: null,
    targetAnchor: null,
    staticCount: 0,
    shapeFlag,
    patchFlag,
    dynamicProps,
    dynamicChildren: null,
    appContext: null, // APP 上下文
    ctx: currentRenderingInstance // 当前渲染实例的上下文
  } as VNode
  if (needFullChildrenNormalization) {
    normalizeChildren(vnode, children)
    if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
      ;(type as typeof SuspenseImpl).normalize(vnode)
    }
  } else if (children) {
    // 根据子节点是字符串还是数组类型,更新 shapedFlag
    vnode.shapeFlag |= isString(children)
      ? ShapeFlags.TEXT_CHILDREN
      : ShapeFlags.ARRAY_CHILDREN
  }
  if (__DEV__ && vnode.key !== vnode.key) {
    warn(`VNode created with invalid key (NaN). VNode type:`, vnode.type)
  }
  if (
    isBlockTreeEnabled > 0 &&
    !isBlockNode &&
    currentBlock &&
    (vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
    vnode.patchFlag !== PatchFlags.HYDRATE_EVENTS
  ) {
    currentBlock.push(vnode)
  }
  if (__COMPAT__) {
    convertLegacyVModelProps(vnode)
    defineLegacyVNodeProperties(vnode)
  }
  return vnode
}
接下来看更加完整的 createVNode 方法:
function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0,
  dynamicProps: string[] | null = null,
  isBlockNode = false
): VNode {
  if (!type || type === NULL_DYNAMIC_COMPONENT) {
    if (__DEV__ && !type) {
      warn(`Invalid vnode type when creating vnode: ${type}.`)
    }
    type = Comment
  }
  // 如果是 vNode,就克隆并合并 props 和 children
  if (isVNode(type)) {
    const cloned = cloneVNode(type, props, true /* mergeRef: true */)
    if (children) {
      normalizeChildren(cloned, children)
    }
    if (isBlockTreeEnabled > 0 && !isBlockNode && currentBlock) {
      if (cloned.shapeFlag & ShapeFlags.COMPONENT) {
        currentBlock[currentBlock.indexOf(type)] = cloned
      } else {
        currentBlock.push(cloned)
      }
    }
    cloned.patchFlag |= PatchFlags.BAIL
    return cloned
  }
  if (isClassComponent(type)) {
    type = type.__vccOpts
  }
  if (__COMPAT__) {
    type = convertLegacyComponent(type, currentRenderingInstance)
  }
  // 重新处理 class 和 style(可能是对象)
  if (props) {
    props = guardReactiveProps(props)!
    let { class: klass, style } = props
    if (klass && !isString(klass)) {
      props.class = normalizeClass(klass)
    }
    if (isObject(style)) {
      if (isProxy(style) && !isArray(style)) {
        style = extend({}, style)
      }
      props.style = normalizeStyle(style)
    }
  }
  // 对 vnode 的 type 进行编码
  const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : __FEATURE_SUSPENSE__ && isSuspense(type)
    ? ShapeFlags.SUSPENSE
    : isTeleport(type)
    ? ShapeFlags.TELEPORT
    : isObject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : isFunction(type)
    ? ShapeFlags.FUNCTIONAL_COMPONENT
    : 0
  if (__DEV__ && shapeFlag & ShapeFlags.STATEFUL_COMPONENT && isProxy(type)) {
    type = toRaw(type)
    warn(
      `Vue received a Component which was made a reactive object. This can ` +
        `lead to unnecessary performance overhead, and should be avoided by ` +
        `marking the component with \`markRaw\` or using \`shallowRef\` ` +
        `instead of \`ref\`.`,
      `\nComponent that was made reactive: `,
      type
    )
  }
  return createBaseVNode(
    type,
    props,
    children,
    patchFlag,
    dynamicProps,
    shapeFlag,
    isBlockNode,
    true
  )
}
可以看到,createVNode 复用了 createBaseVNode 方法,并且在初始化的过程中对节点进行了额外处理。正因如此,将创建 Element 的方法独立出来,也就避免了初始化过程中许多不必要的判断和处理。