面向 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 的方法独立出来,也就避免了初始化过程中许多不必要的判断和处理。