从 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 函数遍历并标记静态节点。判定静态节点的逻辑较为复杂,其遵循的主要逻辑包括:

  1. 根结点不会被 hoist,考虑到可能的 props 透传。
  2. 对于普通的 Element 结点,需要其本身和所有子节点没有 key, ref, 绑定的 props, 指令 等,才有可能上升为 static。
  3. 判断子节点的过程中,使用哈希表 constantCache 来缓存判断的结果。
  4. 针对没有被 hoist 的节点,其 props 在非动态变更的情况下,可以被 hoist
  5. 单独的文本节点,可以被 hoist

当节点满足可 hoist,并且连续拥有至少20个静态节点或5个有 props 的节点,就会通过字符串化来存储和 innerHTML 来渲染节点。参考 compiler-dom/src/transforms/stringifyStatic.ts
虽然利用 innerHTML 来存储和渲染静态节点可以有效减少 Virtual DOM 的数量,但是判断和生成字符串需要遍历两次节点树,所以编译过程会更加耗费时间。

以这个组件为例: ^3ed9c1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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>

编译后的渲染函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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。

假设当前的节点模版长这样:

1
2
<div :class="{ active }"></div>
<div :class="{ active }" :age={dynamic}>{{dynamic}}</div>

通过 Vue Template Explorer (vuejs.org) 查看编译后的渲染函数:

1
2
3
4
5
6
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"])

渲染函数中的数字 211 就是这两个节点的 Patch Flag,是通过位运算出来的。比如第一个节点只绑定了动态的 class,那么它的 Patch Flag 就是 10b,而第二个节点则是对应了动态的 text, class 和 props,所以对应 Flag 就是 1011b
运行时的渲染器就会利用这些编译时增加的 Patch Flag 信息,进行相应的更新操作。

这个 Patch Flag 在渲染的时候到底有什么好处呢?挂载在 vNode 上的 patchFlag 可以帮助快速定位需要执行的 diff 操作。在 shouldUpdateComponent 的生命周期内就会进行以下操作:

1
2
3
4
5
if (patchFlag > 0) {
if (patchFlag & patchFlag.DYNAMIC_SLOTS) return true
if (patchFlag & PatchFlags.PROPS) // 逐个对比动态变更的 props
...
}

render 的过程中,会对已经 mount 的 element 进行 patch 操作,具体函数是 patchElement,里面也会对 patchFlag 进行比对,并且具体 vNode 上最终有哪些属性被更新了。大致如下:

1
2
3
4
5
6
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 官方给出的例子:

1
2
3
4
5
6
7
<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.js 3.2 关于 vnode 部分的优化 - 掘金 (juejin.cn)

先回顾一下 Vue 编译后的渲染函数长什么样子:Vue Template Explorer (vuejs.org)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<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 是怎么写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// 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 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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 的方法独立出来,也就避免了初始化过程中许多不必要的判断和处理。