Vue3源码分析:compile和runtime结合patch过程
前言
在上一篇文章中,我们分析了在编译过程静态节点的提升。并且,在文章的结尾也说了,下一篇文章将会介绍 patch 过程。
说起「Vue3」的 patch 过程,其中最为津津乐道的就是靶向更新。靶向更新,顾名思义,即更新的过程是带有目标性的、直接性的。而,这也是和静态节点提升一样,是「Vue3」针对 VNode 更新性能问题的一大优化。
那么,今天,我们就来揭秘「Vue3」compile 和 runtime 结合的 patch过程 究竟是如何实现的!
什么是 shapeFlag
说起「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 = Vueconst _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也会负责依赖收集和派发更新。
而 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 创建过程
Block VNode
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 */)
])
]))
可以看到有两个和 Block 相关的函数:_openBlock() 和 _createBlock()。实际上,它们分别对应着源码中的 openBlock() 和 createBlock() 函数。那么,我们分别来认识一下这两者:
openBlock
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 它不需要靶向更新。
createBlock
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,它们对应的具体含义会是这样:
- sholdTrack 大于 0,即没有
v-once指令下的VNode。 - isBlockNode 是否当前节点为
Block Node。 - currentBlock 为数组时才创建
Block Node,对于v-for场景下,curretBlock为null,它不需要靶向更新。 - patchFlag 有意义且不为
32事件监听,只有事件监听情况时事件监听会被缓存。 - shapeFlags 是组件的时候,必须为
Block Node,这是为了保证下一个VNode的正常卸载。
小结
讲完 VNode 的创建过程,我想大家都会意识到一点,如果使用手写 render 函数的形式开发,我们就需要对 createBlock、openBlock 等函数的概念有一定的认知。因为,只有这样,我们写出的 render 函数才能充分利用好靶向更新过程,实现的应用更新性能也是最好的。
patch 过程
对比 Vue2.x 的 patch
前面,我们也提及了 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
虽然「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
同样地 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)
}
}
可以看到,processElement 在处理更新的情况时,实际上会调用 patchElement 函数。
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
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源码分析:compile和runtime结合patch过程 的全部内容, 来源链接: utcz.com/a/45032.html

