Vue3源码——从patch函数到组件更新
前言
前面我们探讨了组件的挂载逻辑,挂载逻辑主要就是通过执行render函数生成vnode,然后将vnode通过patch函数处理,最终生成真实DOM,并挂载到父容器,页面也就呈现出内容。这一节我们来看一下Vue3的组件更新逻辑是怎样进行的。
组件更新
组件更新的大体流程和组件挂载比较相似,具体为:
- 首先,获取编译阶段得到的render函数。
- 然后,执行render函数,得到新的vnode树。
- 最后,通过patch函数比较新旧vnode树,并根据节点的类型去执行不同的操作(这里挂载阶段还不存在旧树,旧树为null)。在这个阶段则会生成真实DOM结构,并将DOM挂载到父容器,从而呈现在页面上。
我们把重点放在更新和挂载差异相对较大的patch函数上。
patch函数
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = isHmrUpdating ? false : !!n2.dynamicChildren) => {
// 如果新旧vnode节点相同,则无须patch
if (n1 === n2) {
return;
}
// 如果新旧vnode节点,type类型不同,则直接卸载旧节点
// 这里isSameVNodeType会判断规则为n1.type === n2.type && n1.key === n2.key
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1);
unmount(n1, parentComponent, parentSuspense, true);
n1 = null;
}
...
const { type, ref: ref2, 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 (true) {
patchStaticNode(n1, n2, container, isSVG);
}
break;
// 处理Fragment
case Fragment:
// Fragment
...
break;
default:
if (shapeFlag & 1 /* ELEMENT */) {
// element类型
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else if (shapeFlag & 6 /* COMPONENT */) {
// 组件
...
} else if (shapeFlag & 64 /* TELEPORT */) {
// teleport
...
} else if (shapeFlag & 128 /* SUSPENSE */) {
// suspense
...
} else if (true) {
warn2("Invalid VNode type:", type, `(${typeof type})`);
}
}
...
};
patch函数上一节也有提到,这里再回顾一下:
- 首先,判断新旧节点是否为相同节点,如果不是相同节点,则直接将旧节点卸载。
- 然后,再根据新节点的类型type,采用不同的操作。
我们仍旧以element的节点类型为例,看一下processElement函数。
processElement函数
const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
isSVG = isSVG || n2.type === "svg";
if (n1 == null) {
// 挂载
...
} else {
// 更新
patchElement(
n1,
n2,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
}
};
可以看到processElement函数在更新阶段接到的参数n1,n2分别为新旧vnode节点,n1不为空,所以更新阶段的核心逻辑在于patchElement函数:
patchElement函数
const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
const el = n2.el = n1.el;
let { patchFlag, dynamicChildren, dirs } = n2;
...
// hmr触发更新时
if (isHmrUpdating) {
patchFlag = 0;
optimized = false;
dynamicChildren = null;
}
// 动态节点数组
if (dynamicChildren) {
patchBlockChildren(
n1.dynamicChildren,
dynamicChildren,
el,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds
);
} else if (!optimized) {
// 全量更新(在热更新hmr时才会触发)
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds,
false
);
}
// 根据patchFlag来判断需要更新的类型
if (patchFlag > 0) {
if (patchFlag & 16 /* FULL_PROPS */) {
// 全量更新props
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
);
} else {
// 更新class
if (patchFlag & 2 /* CLASS */) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, "class", null, newProps.class, isSVG);
}
}
// 更新style
if (patchFlag & 4 /* STYLE */) {
hostPatchProp(el, "style", oldProps.style, newProps.style, isSVG);
}
// 更新props
if (patchFlag & 8 /* PROPS */) {
const propsToUpdate = n2.dynamicProps;
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i];
const prev = oldProps[key];
const next = newProps[key];
if (next !== prev || key === "value") {
hostPatchProp(
el,
key,
prev,
next,
isSVG,
n1.children,
parentComponent,
parentSuspense,
unmountChildren
);
}
}
}
}
// 更新文本节点
if (patchFlag & 1 /* TEXT */) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children);
}
}
} else if (!optimized && dynamicChildren == null) {
// 热更新触发更新props
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
);
}
...
};
抽离出函数的核心逻辑,我们可以看到patchElement函数会根据是否为热更新hmr来决定调用哪个函数:
- 如果不是热更新,那么就走patchBlockChildren函数,仅更新动态节点,而不用去管静态节点,这样也能节省一些性能。
- 如果是热更新,那么就走patchChildren函数,进行全量更新。
- 之后再根据patchFlag的值来定位具体的更新位置,最后做出相应的处理。
我们以下面模板为例:
<div>
<span> {{x}} </span>
<div>123</div>
</div>
这个模板经过处理后得到vnode是这个样子的:

我们可以看到,包含变量 x 的span节点被处理进入了dynamicChildren数组中,而不会变动的静态节点<div>123</div>
则不在dynamicChildren数组中。
这也很容易理解,只要我们不是从代码层面来修改静态节点的话,那无论我们做什么操作,<div>123</div>
这个节点始终是不会改变的,都是可以直接复用的。而我们通过页面操作,一般来说会改变的只有变量 x 的值,所以Vue在做patch处理的时候,只需要关注这一部分动态节点即可。
动态节点的处理——patchBlockChildren函数
const patchBlockChildren = (oldChildren, newChildren, fallbackContainer, parentComponent, parentSuspense, isSVG, slotScopeIds) => {
// 遍历新旧动态节点,并对每一个动态节点递归执行patch操作
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i];
const newVNode = newChildren[i];
const container = (
oldVNode.el &&
(oldVNode.type === Fragment || !isSameVNodeType(oldVNode, newVNode) || oldVNode.shapeFlag & (6 /* COMPONENT */ | 64 /* TELEPORT */)) ? hostParentNode(oldVNode.el) : (fallbackContainer)
);
patch(
oldVNode,
newVNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
true
);
}
};
patchBlockChildren函数的逻辑非常明确,就是直接遍历新旧vnode中的动态节点数组dynamicChildren,然后通过递归调用patch函数的方式去比对每一个动态节点。
我们仍以这个模板为例,只不过这次我们加入一个定时器来触发更新操作:
<template>
<div>
<span>{{ x }}</span>
<div>123</div>
</div>
</template>
<script setup>
import { ref } from "vue";
let x = ref(999)
setTimeout(() => {
x.value++
}, 1000);
</script>
我们来顺一下整个的组件更新流程加深一下对源码的认识:
-
编译到挂载:
template
经过模板编译生成了render函数
。- 通过执行
render函数
得到了新节点的虚拟DOM,也就是vnode
。 - 进入挂载阶段,执行
patch函数
(因为此时没有旧节点,所以patch函数的第一个参数n1传null),完成组件的挂载,最终将DOM结构呈现在页面上。 - 定时器执行后,触发组件更新。
-
更新阶段,我们直接从
patch函数
开始:- 进入
patch函数
,通过isSameVNodeType函数
来比对新旧节点vnode是否为相同节点,如果不是相同节点,则直接将旧节点卸载。 - 根据节点类型,判断当前节点为
element类型
,进入processElement函数
。 processElement函数
判断是否存在旧节点,存在旧节点进入更新函数patchElement
。- 在patchElement函数中,首先判断此次更新是否为热更新HMR触发,我们这里当然不是HMR,继续往下判断是否存在动态节点数组
dynamicChildren
。 - 存在动态节点数组
dynamicChildren
,则调用patchBlockChildren函数
对动态节点数组进行处理。 - 遍历动态节点数组,对内部的每个动态节点递归调用patch函数。这里我们就是对
span
节点进行递归操作,我们看一下此时传入patch函数
的新旧节点是什么样子的: - 可以看到新旧节点的
shapFlag
值为9,所以进入patch函数
后,会再次使用processElement函数
对它们进行处理。 - 同样,因为是更新操作,所以进入
processElement函数
后,会调用patchElement
执行更新操作。 - 在
patchElement函数
中,首先会判断此次更新并不是来自于热更新HMR,然后判断是否存在动态节点数组dynamicChildren
,我们从上面的新旧vnode可以看出,新节点的dynamicChildren
属性为null
,所以直接跳过对子节点的处理。 - 继续执行
patchElement函数
后面的逻辑,根据patchFlag
的值,我们判断出新旧节点的更新发生在文本节点处。又因为n1.children
为999
,n2.children
为1000
,二者不等,所以执行hostSetElementText函数,hostSetElementText(el, n2.children);
。 - setElementText函数即为
hostSetElementText函数
,setElementText: (el, text) => {el.textContent = text;}
,可以看到,在这个函数中,通过直接操作DOM的方式,将新的 x 的值呈现在了DOM 结构上。
- 进入
至此,我们就将整个patchBlockChildren函数的更新逻辑走完了,是不是就也还好了,没有想象中那么的遥不可及~
接下来,我们再看一下,当我们通过修改源码由HMR触发的组件更新如何操作的。
HMR触发全量更新——patchChildren函数
关于HMR热更新,还不清楚的朋友可以先查一下,大概知道它是做什么的就行,不影响我们这里继续探究源码。
可以就先将HMR理解为,我们在本地将代码通过脚手架跑起来之后,然后在编辑器上修改了代码文件并保存,页面会在不刷新的情况下实时的将我们的修改内容体现在页面上,这个过程就是热更新HMR。
我们直接看patchChildren函数
:
const patchChildren = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized = false) => {
const c1 = n1 && n1.children;
const prevShapeFlag = n1 ? n1.shapeFlag : 0;
const c2 = n2.children;
const { patchFlag, shapeFlag } = n2;
// ...
// 如果新节点是文本节点
if (shapeFlag & 8 /* TEXT_CHILDREN */) {
// 如果旧节点的子节点为数组,则直接卸载旧节点
if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
unmountChildren(c1, parentComponent, parentSuspense);
}
if (c2 !== c1) {
// 将新的文本节点加入到DOM
hostSetElementText(container, c2);
}
} else {
// 如果新节点不是文本节点
// 旧节点为数组
if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
// 新节点也为数组节点
if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
// 进行数组间的diff,也是常考的diff算法操作
patchKeyedChildren(
c1,
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else {
// 新节点不是数组,直接将旧节点卸载
unmountChildren(c1, parentComponent, parentSuspense, true);
}
} else {
// 旧节点为文本
if (prevShapeFlag & 8 /* TEXT_CHILDREN */) {
// 清空对应的DOM
hostSetElementText(container, "");
}
// 新节点为数组,旧节点不是数组
if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
// 执行挂载新节点的操作
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
}
}
}
};
patchChildren函数
在做全量更新的时候,它的主要思路就是,通过判断新旧节点的类型,决定执行不同的操作。具体的细节在上面代码注释中已经体现,这里就不再赘述。
关于新旧节点都是数组的时内部是如何处理的,这一节暂时还没有体现。因为这个地方也是Vue的diff算法比较奇特的一节,准备下一节详细的去分析一下diff算法。
最后
至此,我们就将组件更新的逻辑完全顺下来了,整个的过程的关键就是不断的判断新旧节点的类型,然后执行不同的操作,其中里面还涉及到了递归的思想,所以作为前端工作之余玩一玩算法还是挺好的,也推荐有兴趣的朋友可以尝试玩一下~
转载自:https://juejin.cn/post/7278613905379098679