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