[vue3] vue3更新组件流程与diff算法
cnblogs 2024-08-22 08:11:00 阅读 53
Vue3 中的 patch 函数结合 diff 算法,通过比较新旧 vnode 序列,优化组件更新流程。diff 算法复用旧节点并最小化移动操作,利用最长递增子序列算法提升渲染性能,可以有效减少创建和销毁节点的开销。
在<code>Vue3中,组件的更新通过patch
函数进行处理。
patch函数
源码位置:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
const patch: PatchFn = (
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
namespace = undefined,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren,
) => {
// 二者相同,不需要更新
if (n1 === n2) {
return
}
// vnode类型不同,直接卸载旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
// ......
const { type, ref, shapeFlag } = n2
switch (type) {
case Text:
// 处理文字节点
break
case Comment:
// 处理注释节点
break
case Static:
// 静态节点
break
case Fragment:
// Fragment节点
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 处理普通DOM元素
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 处理组件
} else if (shapeFlag & ShapeFlags.TELEPORT) {
// 处理teleport
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
// 处理suspense
} else if (__DEV__) {
// 报错:vnode类型不在可识别范围内
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}
}
patch
函数用来挂载或者更新vnode
。
patch
的大致流程:
n1
和n2
如果相等,则表示无变化,直接退出;n1
和n2
如果引用不同,则先检查其vnode
类型,如果类型不同,则直接卸载n1
,挂载n2
;- 主流程:根据
n1
和n2
的vnode类型,调用不同的process
函数。
process
process
函数的参数列表大致相同,都是要传入n1
、n2
和container
等参数。patch
函数主要起到一个分类讨论的功能。
这里只讨论普通元素类型和组件类型的vnode处理过程,因为这是Vue
应用中最常见、覆盖范围最广的两种类型。
普通元素类型,即
ShapeFlags.ELEMENT
,在浏览器环境下就是指DOM
类型。
普通元素vs组件
从组件树的角度来理解普通元素和组件元素的区别。
一个组件的<code>children可以是普通元素或组件元素。
- <li>
叶子节点不可能是组件,因为组件必须被实现且被注册,其实现必须使用已注册的组件或者普通元素。并且组件是虚拟元素,并不能被实际挂载到指定平台上,只能递归地
patch
它的children
,直到把普通元素都挂载到界面上。
叶子节点必须是普通元素,因为只有普通元素能够通过相关平台挂载到界面上。Vue
会在编译时确定mount
方法,以适应不同的平台。对于浏览器环境来说,普通元素是通过vnode来表示DOM
节点,将vnode转换成实际DOM元素并插入到页面上的操作由vue3
源码中的runtime-dom
这个package实现。
叶子节点必须是普通元素,但是普通元素不一定是叶子节点,比如一个
div
标签内部可以包含其它组件。
在 Vue3 - patch 函数的源码中可以看到除了这两种类型,还有很多针对其它类型 vnode 的 process 函数,这些 process 函数主要做的只有两件事:挂载和更新。
对于旧vnode n1
和 新vnode n2
:
- 当
n1
是null
时,则表示挂载n2
; - 当
n1
不为null
时,则表示n1
更新为n2
;
patchElement
patchElement 会对它的 children 也进行 patch,也就是调用 patchChildren 函数。
children 有三种情况:文本、数组、NULL。
diff算法
diff 算法用于将旧children的vnode数组更新为新children的vnode数组,它通过比较两个序列,尽可能地复用相同的vnode,以此来减少频繁创建vnode带来的开销。
事实上,diff 是在 patchKeyedChildren 中实现的,对于没有设置 key 的数组,patchChildren函数内部调用的是 patchUnkeyedChildren,函数实现大致如下:
<li>计算两个数组的长度的最小值 <code>commonLength;li>
- 前
commonLength
个 vnode 直接 patch 更新,不会考虑移动到不同位置来复用;- 旧序列如果有剩余则unmount,新序列如果有多余则mount。
这种做法在大多数情况下都会需要创建 vnode,开销还是比较大的。因此为了提高渲染性能,使用渲染列表的时候要写上 key。
vue3 的 diff算法实现在patchKeyedChildren函数中,主要包含五个流程,其中第五个是最复杂的步骤:
- <li>
两个序列从尾部向头部依次同步,直到不能匹配进入下个流程;
两个序列长度可能不一样,最后一个元素的索引不一样,因此需要两个变量 <code>e1 和
li>e2
来指向ending index
。
两个序列从头部向尾部依次同步,直到不能匹配进入下个流程;
起始索引都是从0开始,用一个变量 <code>i 就可以了。
li>在上述两个流程之后:
- <li>
如果新序列遍历完了,而旧序列还有剩余,则旧序列剩余的vnode依次unmount;
未知序列,尽可能地通过移动复用vnode,剩下的mount或者unmount。
头部和尾部都同步了若干vnode,但是两个序列都还没有遍历完成,说明中间有一段序列是混乱的、难以匹配的。
在步骤5中,有细分为多个子步骤:
首先用<code>s1和
s2
表示旧新序列的起始索引:const s1 = s2 = i;
5.1.遍历新序列,使用
Map
建立key
到newIndex
的映射:keyToNewIndexMap
;使用
Map
的原因是PropertyKey
这个类型是联合类型string | number | symbol
,不能简单的用对象或数组表示。建立 key 到 index 的映射,是为了后续我们可以通过旧序列中的 key 来建立可复用情况下新旧节点之间的映射关系。
// 这段代码不是源码,只保留主干。
const keyToNewIndexMap: Map<PropertyKey, number> = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = c2[i];// c2 即 children2
if (nextChild.key != null)
keyToNewIndexMap.set(nextChild.key, i)
}
}
5.2. 遍历旧序列,使用一个
newIndexToOldIndexMap
数组建立新旧序列中可复用节点的位置对应关系。newIndexToOldIndexMap的作用:
通过这个数组,我们可以知道一个新vnode可以由哪个旧vnode更新得到。在这个数组中,newIndex是以 0 开始的,而 oldIndex 是以 1 开始的,这是为了把
oldIndex==0
作为一个特殊标识,表示新节点在旧序列中不存在。当newIndexToOldIndexMap[k] = 0
,则表示新序列中第 k 个vnode在旧序列中不存在,无法复用。newIndexToOldIndexMap的构建过程:
遍历旧序列 <code>c1:
for(let i=s1; i<=e1; i++){...}
- <li>
如果 key 为 undefined,则说明这个旧的 vnode 在新序列中不存在了,卸载这个旧 vnode;
如果 key 为某个数字,则表明这个旧 vnode 在新序列中有 vnode 的 key 跟它一样,可以复用。使用
patch
函数将旧节点更新为新节点。这一步骤中记录新旧序列索引映射的代码是
newIndexToOldIndexMap[newIndex - s2] = i + 1
。- 减去
s2
是因为序列包含 diff 算法步骤1同步的头节点; i+1
是因为这个数组记录的 oldIndex 是从 1 开始的。
- 减去
使用
keyToNewIndexMap
查询c1[i]
的 key:如果发现新序列中的节点都找到与之对应的旧节点了,那么
for
循环后续的旧节点都直接卸载。
如果旧序列遍历完了,而新序列还有剩余,则新序列剩余的vnode依次mount;
li>
在这个步骤中,还通过一个 moved
变量来记录节点的相对位置是否被移动了:
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
如果当前新节点的索引 newIndex
大于或等于此前遍历到的最大新节点索引 maxNewIndexSoFar
,那么当前节点在新列表中的顺序相对于旧列表来说是保持递增的。
5.3 移动与挂载
经过上面若干步骤,能复用的旧节点都通过 patch 将数据更新到新节点上了,不能复用的旧节点都被卸载了。
而新节点如果没有在旧序列中出现,则挂载;如果在旧序列中出现了,
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。