Vue3组件更新流程分析


highlight: a11y-dark

接上篇 Vue3组件初始化流程分析,本文主要来分析 vue3组件的更新流程。 还是之前的例子: App.vue
const App = {
name: "App",
setup() {
const count = ref(0);
const onClick = () => {
count.value++
};
return {
count,
onClick
}
},
render() {
return h(
"div", { id: "root" }, [
h("div", {}, "count:" + this.count),
h("button", { onClick: this.onClick }, "click")
]
)
}
}
最终页面渲染如下:
97.png
当点击 click 按钮时,会执行 onClick 函数, count 的值会 +1,此时会触发组件的更新。由于 count 是响应式数据,会再次触发 setupRenderEffect 函数的执行,我们来看一下更新流程:
function setupRenderEffect(instance, initialVNode, container, anchor) {
instance.update = effect(() => {
if (!instance.isMounted) { // mounted
...
instance.isMounted = true;
} else { // updated
const { proxy } = instance;
const subTree = instance.render.call(proxy,proxy);
const prevSubTree = instance.subTree;
instance.subTree = subTree;
patch(prevSubTree, subTree, container, instance, anchor);
}
})
}
由于组件初次渲染后会把 isMounted 置为 true,再次执行 setupRenderEffect 时会执行 else 逻辑。首先执行 render 函数生成新的 Vnode,然后取出上一次的 Vnode,并用新的 Vnode 覆盖老的 Vnode,最后执行 patch 函数将新老 Vnode 同时传入。新老 Vnode 对比如下:
98.png
98.png

patch

function patch(n1, n2, container, parentComponent, anchor) {
const { type, shapeFlag } = n2;
...
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, parentComponent, anchor);
}
...
}
再次执行 patch 函数,由于我们传入的 n2.typediv, 此时会执行 processElement 逻辑,依次传入所有参数。

processElement

function processElement(n1, n2, container, parentComponent, anchor) {
if (!n1) {
mountElement(n2, container, parentComponent, anchor);
} else {
patchElement(n1, n2, container, parentComponent, anchor);
}
}
由于 n1 存在,此时会执行 patchElement 函数,依次传入所有参数。

patchElement

function patchElement(n1, n2, container, parentComponent, anchor) {
const oldProps = n1.props
const newProps = n2.props
const el = (n2.el = n1.el)
patchChildren(n1, n2, el, parentComponent, anchor)
patchProps(el, oldProps, newProps)
}
patchElement 函数会执行以下逻辑: 1、获取到新老 Vnodeprops 属性。 2、获取到老 Vnodeel 属性(el 可以映射到真实 DOM),并赋值给新 Vnodeel此步可以直接复用老的 DOM 元素。 3、执行 patchChildren 逻辑, 并把 n1、n2、el 传入。 4、执行 patchProps 逻辑,并把新老 props 传入。

patchProps

export const EMPTY_OBJ = {};
function patchProps(el, oldProps, newProps) {
if (oldProps !== newProps) {
for (const key in newProps) {
const prevProp = oldProps[key];
const nextProp = newProps[key];
if (prevProp !== nextProp) {
hostPatchProp(el, key, prevProp, nextProp);
}
}
if (oldProps !== EMPTY_OBJ) {
for (const key in oldProps) {
if (!(key in newProps)) {
hostPatchProp(el, key, oldProps[key], null);
}
}
}
}
}
function hostPatchProp(el, key, prevVal, nextVal) {
const isOn = key => /^on[A-Z]/.test(key); // 事件注册onClick
if (isOn(key)) {
const event = key.slice(2).toLowerCase();
el.addEventListener(event, nextVal);
} else {
if (nextVal === undefined || nextVal === null) {
el.removeAttribute(key);
} else {
el.setAttribute(key, nextVal);
}
}
}

patchChildren

对于子节点来说,只会有三种可能,分别是:文本节点、数组和空。所以这个方法里所有的if else分支就是在考虑新旧节点可能的全部情况,并进行相应的处理。
function patchChildren(n1, n2, container, parentComponent, anchor) {
const prevShapeFlag = n1.shapeFlag;
const c1 = n1.children;
const { shapeFlag } = n2;
const c2 = n2.children;
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 新文本
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 老数组
unmountChildren(n1.children);
}
if (c1 !== c2) { // 老文本
hostSetElementText(container, c2);
}
} else { // 新数组
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) { // 老文本
hostSetElementText(container, "");
mountChildren(c2, container, parentComponent, anchor);
} else {
// 老数组
patchKeyedChildren(c1, c2, container, parentComponent, anchor);
}
}
}
情况1: 新子节点是文本,老子节点是数组。此时需要执行 unmountChildren 把老子节点卸载掉,执行 hostSetElementText 设置新文本。
function unmountChildren(children) {
for (let i = 0; i < children.length; i++) {
const el = children[i].el;
hostRemove(el);
}
}
情况2: 新子节点是文本,老子节点是文本。如果文本不一致,执行 hostSetElementText 设置新文本。 情况3: 新子节点是数组,老子节点是文本。此时需要执行 hostSetElementText 清空文本,执行 mountChildren 挂载新节点。
function mountChildren(children, container, parentComponent, anchor) {
children.forEach((v) => {
patch(null, v, container, parentComponent, anchor)
})
}
情况4: 新子节点是数组,老子节点是数组。此时执行 patchKeyedChildren(核心 diff 算法)。 用一张图归纳如下:
99.png

patchKeyedChildren

前提: 先假设所有元素都拥有一个独一无二的 key 值。 我们可以思考一下,新旧子节点都是数组会有哪几种变化情况?
100.png
无论是哪种变化最后都是由更新、添加、删除、移动这四种操作的一种或者几种的组合。 patchKeyedChildren 函数首先获取到新老 children 的长度,用以计算出尾部索引 e1、e2,定义开始索引 i = 0。接着定义 isSameVNodeType 用于判断 vnode 节点的类型是否相同(type、key必须相等)。
 function isSameVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}
function patchKeyedChildren(c1, c2, container, parentComponent, parentAnchor) {
const l2 = c2.length;
let i = 0;
let e1 = c1.length - 1;
let e2 = l2 - 1;
...
}
源码里在 diff 算法的最开始,会先从头部正序扫描从尾部倒序扫描,以便排除类型一样的干扰项,进一步的提高效率。

头部正序扫描(sync from start)

while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor)
} else {
break
}
i++
}
按上述逻辑我们会先取新老 children 的第一个子元素通过 isSameVNodeType 函数来进行比对。按上述例子来看,第一个子元素 type 都是 divkey 值都没传默认是 undefined,此时会再次调用 patch 函数把新老 children 的第一个子元素传入。依次执行 processElement -> patchElement -> patchChildren 的逻辑。此时 n1children c1 为文本:count: 0n2children c2 也为文本:count: 1,因而会命中如下逻辑:
if (c1 !== c2) {
hostSetElementText(container, c2);
}
此时就完成了第一个子元素的 patch 流程。第二个子元素 button 也是同样的对比逻辑,依次执行 processElement -> patchElement -> patchChildren。不过由于新老 button 的内容并没有变化,所以也不会有实际的 DOM 处理操作。

核心 diff

上面的例子比较简单,接下来我们用多个 li 元素来模拟整个 diff 流程。

头部正序扫描(sync from start)

 h('ul',[
h('li', { key: 'a' }, 'a'),
h('li', { key: 'b' }, 'b'),
h('li', { key: 'c' }, 'c')
])
h('ul',[
h('li', { key: 'a' }, 'a'),
h('li', { key: 'b' }, 'b'),
h('li', { key: 'd' }, 'd'),
h('li', { key: 'e' }, 'e')
])
图解:
101.png
代码:
 while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor)
} else {
break
}
i++
}

尾部倒序扫描(sync from end)

 h('ul',[
h('li', { key: 'a' }, 'a'),
h('li', { key: 'b' }, 'b'),
h('li', { key: 'c' }, 'c')
])
h('ul',[
h('li', { key: 'd' }, 'd'),
h('li', { key: 'e' }, 'e')
h('li', { key: 'b' }, 'b'),
h('li', { key: 'c' }, 'c')
])
图解:
106.png
代码:
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = c2[e2]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor);
} else {
break
}
e1--
e2--
}

同序列 + 挂载(common sequence + mount)

107.png
108.png
下面两种情况,新的孩子比老的孩子多,此时会满足 i > e1 && i <= e2。i -> e2之间是需要新增的元素。 插入位置判断: 通过判断 e2 + 1 和 l2 的关系,如果 e2 + 1 < l2 是前插,anchor 为 e2 + 1 索引对应的 el 元素,否则是向后插入,anchor 为 null。
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? c2[nextPos].el : null; // anchor用来标识:往前插、往后插
while (i <= e2) {
patch(null, c2[i], container, parentComponent, anchor);
i++;
}
}
}
// 插入 api
function insert(child, parent, anchor = null) {
parent.insertBefore(child, anchor);
}

同序列 + 卸载(common sequence + unmount)

下面两种情况,老的孩子比新的孩子多,此时会满足 i > e2 && i <= e1。i -> e1之间是需要移除的元素。
109.png
110.png
else if (i > e2) {
while (i <= e1) {
hostRemove(c1[i].el);
i++;
}
}
function hostRemove(child) {
const parent = child.parentNode;
if (parent) {
parent.removeChild(child);
}
}

乱序比对(unknown sequence)

111.png
经过上面多步的比对,最终会形成图示的虚线框。此时 i = 2、e1 = 4、e2 = 5
let s1 = i // 2
let s2 = i // 2
const toBePatched = e2 - s2 + 1 // // 有多少节点需要比对: 4个
let patched = 0 // 用于标识比对过的次数,patch 1次加1次
const keyToNewIndexMap = new Map()
const newIndexToOldIndexMap = new Array(toBePatched).fill(0) // [0, 0, 0, 0]
for (let i = s2; i <= e2; i++) {
const nextChild = c2[i]
keyToNewIndexMap.set(nextChild.key, i) // 依据 key 值构成映射表
}
第1步: 定义了 keyToNewIndexMap,通过新元素的 key 和索引构制映射表。结构如下:
keyToNewIndexMap: { e: 2, c: 3, d: 4. h: 5 }
for (let i = s1; i <= e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) { // 性能优化
hostRemove(prevChild.el);
continue;
}
// 拿老的节点去映射表里面找
let newIndex;
if (prevChild.key != null) { // 有key
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
for (let j = s2; j <= e2; j++) { // 无 key
if (isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
if (newIndex === undefined) { // 索引不存在,说明老的节点在c2中不存在,需要移除
hostRemove(prevChild.el);
} else { // 老的节点在c2中存在,进行patch
newIndexToOldIndexMap[newIndex - s2] = i + 1;
patch(prevChild, c2[newIndex], container, parentComponent, null);
patched++;
}
}
第2步: 从 s1 遍历至 e1,拿到每一个 children,寻找 children 是否存在于c2中(分有key 和 无key 的查找,存在的话会找到索引值)。 第3步: 如果找不到索引值,说明老的 children 在 c2 中不存在,调用 hostRemove 移除老的 children。如下图 q 需要被移除:
112.png
如果存在,会把老的 children 和 新的 children 进行 patch 比对,执行patched++ 表明 patch 过一次,然后更新 newIndexToOldIndexMap 数组的值。 更新流程如下: 先寻找 c,newIndex 为 3,newIndexToOldIndexMap 更新为[0, 3, 0, 0], i++ 后 再寻找 d,newIndex 为 4,newIndexToOldIndexMap 更新为[0, 3, 4, 0], i++ 后 再寻找 e,newIndex 为 2,newIndexToOldIndexMap 更新为[5, 3, 4, 0]。
for (let i = toBePatched - 1; i >= 0; i--) { // 从后向前插入
const nextIndex = i + s2
const nextChild = c2[nextIndex]
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null
if (newIndexToOldIndexMap[i] === 0) { // 没有被patch过,说明需要新增
patch(null, nextChild, container, parentComponent, anchor)
} else {
// 根据参照物 将节点直接移动过去 所有节点都要移动 (但是有些节点可以不动)
hostInsert(nextChild.el, container, anchor)
}
}

最长递增子序列优化

Vue3 采用最长递增子序列,求解不需要移动的元素有哪些
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap) // 最长递增子序列 [1, 2]
let j = increasingNewIndexSequence.length - 1 // 1
for (let i = toBePatched - 1; i >= 0; i--) { // 从后向前插入
const nextIndex = i + s2
const nextChild = c2[nextIndex]
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null
if (newIndexToOldIndexMap[i] === 0) { // 没有被patch过,说明需要新增
patch(null, nextChild, container, parentComponent, anchor)
} else {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
hostInsert(nextChild.el, container, anchor)
} else {
j--
}
}
}
通过 getSequence 算法把 newIndexToOldIndexMap 传入,得到的最长递增子序列为[1, 2],此时 j = 1;然后由后向前遍历,先寻找插入位置 anchor。如果 newIndexToOldIndexMap 某一项为 0,说明没有被 patch 过,表明该元素需要创建。如果 i 和 increasingNewIndexSequence[j] 不相等,说明需要移动位置,否则 j–。
function getSequence(arr) { // [5, 3, 4, 0] -> [1, 2]
const p = arr.slice();
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
完整 diff 算法
function patchKeyedChildren(c1, c2, container, parentComponent, parentAnchor) {
const l2 = c2.length;
let i = 0;
let e1 = c1.length - 1;
let e2 = l2 - 1;
function isSameVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}
// sync from start
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor);
} else {
break;
}
i++;
}
// sync from end
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor);
} else {
break;
}
e1--;
e2--;
}
// common sequence + mount
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? c2[nextPos].el : null; // anchor用来标识:往前插、往后插
while (i <= e2) {
patch(null, c2[i], container, parentComponent, anchor);
i++;
}
}
} else if (i > e2) { // common sequence + unmount
while (i <= e1) {
hostRemove(c1[i].el);
i++;
}
} else { // 中间对比
let s1 = i;
let s2 = i;
const toBePatched = e2 - s2 + 1;
let patched = 0;
const keyToNewIndexMap = new Map();
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
let moved = false; // 优化考虑
let maxNewIndexSoFar = 0; // 优化考虑
for (let i = s2; i <= e2; i++) {
const nextChild = c2[i];
keyToNewIndexMap.set(nextChild.key, i); // 依据 key 值构成映射表
}
for (let i = s1; i <= e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) { //
hostRemove(prevChild.el);
continue;
}
// 拿老的节点去映射表里面找
let newIndex;
if (prevChild.key != null) { // 有key
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
for (let j = s2; j <= e2; j++) { // 无 key
if (isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
if (newIndex === undefined) { // 索引不存在,说明老的节点在c2中不存在,需要移除
hostRemove(prevChild.el);
} else { // 老的节点在c2中存在,进行patch
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
moved = true;
}
newIndexToOldIndexMap[newIndex - s2] = i + 1;
patch(prevChild, c2[newIndex], container, parentComponent, null);
patched++;
}
}
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap) // 最长递增子序列
: [];
let j = increasingNewIndexSequence.length - 1;
for (let i = toBePatched - 1; i >= 0; i--) { // 从后向前插入
const nextIndex = i + s2;
const nextChild = c2[nextIndex];
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null;
if (newIndexToOldIndexMap[i] === 0) { // 没有被patch过,说明需要新增
patch(null, nextChild, container, parentComponent, anchor);
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
hostInsert(nextChild.el, container, anchor);
} else {
j--;
}
}
}
}
}

总结

以上即为 Vue3 的更新流程,除了 diff 算法比对,另额外加入了最长递增子序列的算法来计算需要移动的元素,从而减少移动次数。
------本页内容已结束,喜欢请分享------

感谢您的来访,获取更多精彩文章请收藏本站。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容