从 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
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 = _createElementVNode ( "div" , null , "1" , -1 ); const _hoisted_2 = _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 ); }
更新类型标记(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 )_createElementBlock ("div" , { class : _normalizeClass ({ active : _ctx.active }), age : {dynamic : _ctx.dynamic } }, _toDisplayString (_ctx.dynamic ), 11 , ["age" ])
渲染函数中的数字 2
和 11
就是这两个节点的 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 ) ... }
在 render
的过程中,会对已经 mount 的 element 进行 patch 操作,具体函数是 patchElement
,里面也会对 patchFlag
进行比对,并且具体 vNode 上最终有哪些属性被更新了。大致如下:
1 2 3 4 5 6 if (patchFlag > 0 ) { if (patchFlag & patchFlag.FULL_PROPS ) patchProps (...) 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 export { createBaseVNode as createElementVNode }function createBaseVNode ( type : VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT, props: (Data & VNodeProps) | null = null , children: unknown = null , 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, 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 , anchor : null , target : null , targetAnchor : null , staticCount : 0 , shapeFlag, patchFlag, dynamicProps, dynamicChildren : null , appContext : null , ctx : currentRenderingInstance } as VNode if (needFullChildrenNormalization) { normalizeChildren (vnode, children) if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags .SUSPENSE ) { ;(type as typeof SuspenseImpl ).normalize (vnode) } } else if (children) { 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 } if (isVNode (type )) { const cloned = cloneVNode (type , props, 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) } 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) } } 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 的方法独立出来,也就避免了初始化过程中许多不必要的判断和处理。