在上一篇文章中,我们分析了在编译过程静态节点的提升。并且,在文章的结尾也说了,下一篇文章将会介绍 patch
过程。
说起「Vue3」的 patch
过程,其中最为津津乐道的就是靶向更新。靶向更新,顾名思义,即更新的过程是带有目标性的、直接性的。而,这也是和静态节点提升一样,是「Vue3」针对 VNode
更新性能问题的一大优化。
那么,今天,我们就来揭秘「Vue3」compile 和 runtime 结合的 patch
过程 究竟是如何实现的!
说起「Vue3」的 patch
,老生常谈的就是 patchFlag
。所以,对于 shapeFlag
我想大家可能有点蒙,这是啥?
ShapeFlag
顾名思义,是对具有形状的元素进行标记,例如普通元素、函数组件、插槽、keep alive
组件等等。它的作用是帮助 Rutime 时的 render
的处理,可以根据不同 ShapeFlag
的枚举值来进行不同的 patch
操作。
在「Vue3」源码中 ShapeFlag
和 patchFlag
一样被定义为枚举类型,每一个枚举值以及意义会是这样:
了解过「Vue2.x」源码的同学应该知道第一次 patch
的触发,就是在组件创建的过程。只不过此时,oldVNode
为 null
,所以会表现为挂载的行为。因此,在认知靶向更新的过程之前,不可或缺地是我们需要知道组件是怎么创建的?
既然说 patch
的第一次触发会是组件的创建过程,那么在「Vue3」中组件的创建过程会是怎么样的?它会经历这么三个过程:
在之前,我们讲过 compile
编译过程会将我们的 template
转化为可执行代码,即 render
函数。而,compiler
生成的 render
函数会绑定在当前组件实例的 render
属性上。例如,此时有这样的 template
模板:
<div><div>hi vue3</div><div>{{msg}}</div></div>
它经过 compile
编译处理后生成的 render
函数会是这样:
const _Vue = Vue const _hoisted_1 = _createVNode("div", null, "hi vue3", -1 /* HOISTED */) function render(_ctx, _cache) { with (_ctx) { const { createVNode: _createVNode, toDisplayString: _toDisplayString, Fragment: _Fragment, openBlock: _openBlock, createBlock: _createBlock } = _Vue return (_openBlock(), _createBlock(_Fragment, null, [ _createVNode("div", null, [ _hoisted_1, _createVNode("div", null, _toDisplayString(msg), 1 /* TEXT */) ]) ])) } }
这个 render
函数真正执行的时机是在安装全局的渲染函数对应 effect
的时候,即 setupRenderEffect
。而渲染 effect
会在组件创建时和更新时触发。
这个时候,可能又有同学会问什么是 effect
?effect
并不是「Vue3」的新概念,它的本质是「Vue2.x」源码中的 watcher
,同样地,effect
也会负责依赖收集和派发更新。
有兴趣了解「Vue3」依赖收集和派发更新过程的同学可以看一下这篇文章4k+ 字分析 Vue 3.0 响应式原理(依赖收集和派发更新)
而 setupRenderEffect
函数对应的伪代码会是这样:
function setupRenderEffect() { instance.update = effect(function componentEffect() { // 组件未挂载 if (!instance.isMounted) { // 创建组件对应的 VNode tree const subTree = (instance.subTree = renderComponentRoot(instance)) ... instance.isMounted = true } else { // 更新组件 ... } }
可以看到,组件的创建会命中 renderComponentRoot(instance)
的逻辑,此时 renderComponentRoot(instance)
会调用 instance
上的 render
函数,然后为当前组件实例构造整个 VNode Tree
,即这里的 subTree
。renderComponentRoot
函数对应的伪代码会是这样:
function renderComponentRoot(instance) { const { ... render, ShapeFlags, ... } = instance if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) { ... result = normalizeVNode( render!.call( proxyToUse, proxyToUse!, renderCache, props, setupState, data, ctx ) ) ... } }
可以看到,在 renderComponentRoot
中,如果当前 ShapeFlags
为 STATEFUL_COMPONENT
时会命中调用 render
的逻辑。这里的 render
函数,就是上面我们所说的 compile
编译后生成的可执行代码。它最终会返回一个 VNode Tree
,它看起来会是这样:
{ ... children: (2) [{…}, {…}], ... dynamicChildren: (2) [{…}, {…}], ... el: null, key: null, patchFlag: 64, ... shapeFlag: 16, ... type: Symbol(Fragment), ... }
了解过何为靶向更新的同学应该知道,它的实现离不开 VNode Tree
上的 dynamicChildren
属性,dynamicChildren
则是用来承接整个 VNode Tree
中的所有动态节点, 而标记动态节点的过程又是在 compile
编译的 transform
阶段,可以说是环环相扣,所以,这也是我们常说的「Vue3」Runtime
和 Compile
的巧妙结合。
显然在「Vue2.x」是不具备构建 VNode
的 dynamicChildren
属性的条件。那么,「Vue3」又是如何生成的 dynamicChildren
?
Block VNode
是「Vue3」针对靶向更新而提出的概念,它的本质是动态节点对应的 VNode
。而,VNode
上的 dynamicChildren
属性则是衍生于 Block VNode
,因此,它也就是充当着靶向更新中的靶的角色。
这里,我们再回到前面所提到的 compiler
编译时生成 render
函数,它返回的结果:
(_openBlock(), _createBlock(_Fragment, null, [ _createVNode("div", null, [ _hoisted_1, _createVNode("div", null, _toDisplayString(msg), 1 /* TEXT */) ]) ]))
需要注意的是openBlock
必须写在createBlock
之前,因为在Block Tree
中的Children
总是会在createBlock
之前执行。
可以看到有两个和 Block
相关的函数:_openBlock()
和 _createBlock()
。实际上,它们分别对应着源码中的 openBlock()
和 createBlock()
函数。那么,我们分别来认识一下这两者:
openBlock
会为当前 Vnode
初始化一个数组 currentBlock
来存放 Block
。openBlock
函数的定义十分简单,会是这样:
function openBlock(disableTracking = false) { blockStack.push((currentBlock = disableTracking ? null : [])); }
openBlock
函数会有一个形参 disableTracking
,它是用来判断是否初始化 currentBlock
。那么,在什么情况下不需要创建 currentBlock
?
当存在 v-for
形成的 VNode
时,它的 render
函数中的 openBlock()
函数形参 disableTracking
就是 true
。因为,它不需要靶向更新,来优化更新过程,即它在 patch
时会经历完整的 diff
过程。
换个角理解,为什么这么设计?靶向更新的本质是为了从一颗存在动态、静态节点的 VNode Tree
中筛选出动态的节点形成 Block Tree
,即 dynamicChildren
,然后在 patch
时实现精准、快速的更新。所以,显然 v-for
形成的 VNode Tree
它不需要靶向更新。
这里,大家可能还会有一个疑问,为什么创建好的Block VNode
又被push
到了blockStack
中?它又有什么作用?有兴趣的同学可以去试一下v-if
场景,它最终会构造一个Block Tree
,有兴趣的同学可以看一下这篇文章Vue3 Compiler 优化细节,如何手写高性能渲染函数
createBlock
则负责创建 Block VNode
,它会调用 createVNode
方法来依次创建 Block VNode
。createBlock
函数的定义:
function createBlock(type, props, children, patchFlag, dynamicProps) { const vnode = createVNode(type, props, children, patchFlag, dynamicProps, true); // 构造 `Block Tree` vnode.dynamicChildren = currentBlock || EMPTY_ARR; closeBlock(); if (shouldTrack > 0 && currentBlock) { currentBlock.push(vnode); } return vnode; }
可以看到在 createBlock
中仍然会调用 createVNode
创建 VNode
。而 createVNode
函数本质上调用的是源码中的 _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 {}
当我们调用 _createVNode()
创建 Block VNode
时,需要传入的 isBlockNode
为 true
,它用来标识当前 VNode
是否为 Block VNode
,从而避免 Block VNode
依赖自己的情况发生,即就不会将当前 VNode
加入到 currentBlock
中。其对应的伪代码会是这样:
function _createVNode() { ... if ( shouldTrack > 0 && !isBlockNode && currentBlock && patchFlag !== PatchFlags.HYDRATE_EVENTS && (patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) ) { currentBlock.push(vnode) } ... }
所以,只有满足上面的 if
语句中的所有条件的 VNode
,才能作为 Block Node
,它们对应的具体含义会是这样:
v-once
指令下的 VNode
。Block Node
。Block Node
,对于 v-for
场景下,curretBlock
为 null
,它不需要靶向更新。32
事件监听,只有事件监听情况时事件监听会被缓存。Block Node
,这是为了保证下一个 VNode
的正常卸载。至于,再深一层次探索为什么?有兴趣的同学可以自行去了解。
讲完 VNode
的创建过程,我想大家都会意识到一点,如果使用手写 render
函数的形式开发,我们就需要对 createBlock
、openBlock
等函数的概念有一定的认知。因为,只有这样,我们写出的 render
函数才能充分利用好靶向更新过程,实现的应用更新性能也是最好的。
前面,我们也提及了 patch
是组件创建和更新的最后一步,有时候它也会被称为 diff
。在
「Vue2.x」中它的 patch
过程会是这样:
VNode
间的比较,判断这两个新旧 VNode
是否属于同一个引用,是则不进行后续比较,不是则对比每一级的 VNode
。VNode
的首尾,循环条件为头指针索引小于尾指针索引。VNode
的当前匹配成功的真实 DOM
移动到对应新 VNode
匹配成功的位置。VNode
中的真实 DOM
节点插入到旧 VNode
的对应位置中,即,此时是创建旧 VNode
中不存在的 DOM
节点。VNode
的 children
不存在为止。粗略一看,就能明白「Vue2.x」patch
是一个硬比较的过程。所以,这也是它的缺陷所在,无法合理地处理大型应用情况下的 VNode
更新。
虽然「Vue3」的 patch
没有像 compile
一样会重新命名一些例如 baseCompile
、transform
阶段性的函数。但是,其内部的处理相对于「Vue2.x」变得更为智能。
它会利用 compile
阶段的 type
和 patchFlag
来处理不同情况下的更新,这也可以理解为是一种分而治之的策略。其对应的伪代码会是这样:
function patch(...) { if (n1 && !isSameVNodeType(n1, n2)) { ... } if (n2.patchFlag === PatchFlags.BAIL) { ... } const { type, ref, shapeFlag } = n2 switch (type) { case Text: processText(n1, n2, container, anchor) break case Comment: processCommentNode(n1, n2, container, anchor) break case Static: if (n1 == null) { mountStaticNode(n2, container, anchor, isSVG) } else if (__DEV__) { patchStaticNode(n1, n2, container, isSVG) } break case Fragment: processFragment(...) break default: if (shapeFlag & ShapeFlags.ELEMENT) { processElement(...) } else if (shapeFlag & ShapeFlags.COMPONENT) { processComponent(...) }else if (shapeFlag & ShapeFlags.TELEPORT) { ;(type as typeof TeleportImpl).process(...) } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { ;(type as typeof SuspenseImpl).process(...) } else if (__DEV__) { warn('Invalid VNode type:', type, `(${typeof type})`) } }
可以看到,除开文本、静态、文档碎片、注释等 VNode
会根据 type
处理。默认情况下,都是根据 shapeFlag
来处理诸如组件、普通元素、Teleport
、Suspense
组件等。所以,这也是为什么文章开头会介绍 shapeFlag
的原因。
并且,从 render
阶段创建 Block VNode
到 patch
阶段根据特定 shapeFlag
的不同处理,在一定程度上,shapeFlag
具有和 patchFlag
一样的价值!
这里取其中一种情况,当 ShapeFlag
为 ELEMENT
时,我们来分析一下 processElement
是如何处理 VNode
的 patch
的。
同样地 processElement
会处理挂载的情况,即 oldVNode
为 null
的时候。processElement
函数的定义:
const processElement = ( n1: VNode | null, n2: VNode, container: RendererElement, anchor: RendererNode | null, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, isSVG: boolean, optimized: boolean ) => { isSVG = isSVG || (n2.type as string) === 'svg' if (n1 == null) { mountElement( n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized ) } else { patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized) } }
其实,个人认为oldVNode
改为n1
、newVNode
改为n2
,这命名是否有点仓促?
可以看到,processElement
在处理更新的情况时,实际上会调用 patchElement
函数。
patchElement
会处理我们所熟悉的 props
、生命周期、自定义事件指令等。这里,我们不会一一分析每一种情况会发生什么。我们就以文章开头提的靶向更新为例,它是如何处理的?
其实,对于靶向更新的处理很是简单,即如果此时 n2
(newVNode
) 的 dynamicChildren
存在时,直接"梭哈",一把更新 dynamicChildren
,不需要处理其他 VNode
。它对应的伪代码会是这样:
function patchElement(...) { ... if (dynamicChildren) { patchBlockChildren( n1.dynamicChildren!, dynamicChildren, el, parentComponent, parentSuspense, areChildrenSVG ) ... } ... }
所以,如果 n2
的 dynamicChildren
存在时,则会调用 patchBlockChildren
方法。而,patchBlockChildren
方法实际上是基于 patch
方法的一层封装。
patchBlockChildren
会遍历 newChildren
,即 dynamicChildren
来处理每一个同级别的 oldVNode
和 newVNode
,以及它们作为参数来调用 patch
函数。以此类推,不断重复上述过程。
const patchBlockChildren: PatchBlockChildrenFn = ( oldChildren, newChildren, fallbackContainer, parentComponent, parentSuspense, isSVG ) => { for (let i = 0; i < newChildren.length; i++) { const oldVNode = oldChildren[i] const newVNode = newChildren[i] const container = oldVNode.type === Fragment || !isSameVNodeType(oldVNode, newVNode) || oldVNode.shapeFlag & ShapeFlags.COMPONENT || oldVNode.shapeFlag & ShapeFlags.TELEPORT ? hostParentNode(oldVNode.el!)! : fallbackContainer patch( oldVNode, newVNode, container, null, parentComponent, parentSuspense, isSVG, true ) } }
大家应该会注意到,此时还会获取当前 VNode
需要挂载的容器,因为 dynamicChildren
有时候会是跨层级的,并不是此时的 VNode
就是它的 parent
。具体会分为两种情况:
1. oldVNode 的父节点作为容器
oldVNode
的类型为文档碎片时。oldVNode
和 newVNode
不是同一个节点时。shapeFlag
为 teleport
或 component
时。2. 初始调用 patch 的容器
patch
方法传入的根 VNode
的挂载点作为容器。具体每一种情况为什么需要这样处理,讲起来又将是长篇大论,预计会放在下一篇文章中和大家见面。
本来初衷是想化繁为简,没想到最后还是写了 3k+ 的字。因为,「Vue3」将 compile
和 runtime
结合运用实现了诸多优化。所以,已经不可能出现如「Vue2.x」一样分析 patch
只需要关注 runtime
,不需要关注在这之前的 compile
做了一些奠定基调的处理。因此,文章总会不可避免地有点晦涩,这里建议想加深印象的同学可以结合实际栗子单步调式一番。
从编译过程,理解 Vue3 静态节点提升(源码分析)
从零到一,带你彻底搞懂 vite 中的 HMR 原理(源码分析)
通过阅读,如果你觉得有收获的话,可以爱心三连击!!!