最近在看《vue.js 设计与实现》过程中,先对diff算法的过程做个总结。vue在渲染更新过程中,diff算法起到关键性作用,充分减少dom操作,从vue2到vue3,为了提升性能,尤大也在不断的优化这个过程。 diff算法也发生了些许变化,但目的都是优化vue应用更新的速度。接下来我们来了解一下:
patchFlags
vue代码在编译过程中,会添加patchFlag,减少非动态内容的对比更新。
<template>
<p>text</p>
<p>{{text}}</p>
<p :title="title">text</p>
</template>
// 编译后
_createElementVNode("p", null, "text", -1);
_createElementVNode("p", null, _toDisplayString($setup.text), 1);
_createElementVNode("p", { title: $setup.title }, "text", 8);
所有patchFlag标识:
export const enum PatchFlags {
// 1 表示具有动态文本内容的元素
TEXT = 1,
// 2 表示具有动态类绑定的元素
CLASS = 1 << 1,
// 4 表示具有动态样式的元素
STYLE = 1 << 2,
// 8 表示具有非类/样式动态props的元素
PROPS = 1 << 3,
// 16 表示带有带有动态key元素。
FULL_PROPS = 1 << 4,
// 32 表示带有事件监听器的元素
HYDRATE_EVENTS = 1 << 5,
// 64 表示子元素顺序不变的片段
STABLE_FRAGMENT = 1 << 6,
// 128 表示子元素中有key或部分有key的片段
KEYED_FRAGMENT = 1 << 7,
// 256 表示子元素中没有key的片段
UNKEYED_FRAGMENT = 1 << 8,
// 512 表示有非props需要patch的,比如'ref'、'directives'
NEED_PATCH = 1 << 9,
// 1024 表示具有动态插槽的组件
DYNAMIC_SLOTS = 1 << 10,
// 2048 表示仅因为用户在模板的根级别放置注释而创建的片段
DEV_ROOT_FRAGMENT = 1 << 11,
// 特殊标识
// -1 表示静态节点不需要更新
HOISTED = -1,
// -2 表示差异算法应该退出
BAIL = -2
}
Patch函数
patch的作用,就是对新旧节点添加相同的真实dom的引用,尽可能的复用真实dom节点,避免重复创建dom节点,对比新旧节点,通过判断patchFlag,进行不同类型的更新操作,打补丁。
// 判断节点的类型及key值是否相同
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
...
return n1.type === n2.type && n1.key === n2.key
}
const patch: PatchFn = (
n1, // 旧节点
n2, // 新节点
container, // 所在容器
anchor = null, // 锚点元素
...
) => {
...
// 节点类型不同直接卸载
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
...
// 根据节点类型,进行不同的挂载及更新
const { type, ref, shapeFlag } = n2
switch (type) {
case Text:
processText(n1, n2, container, anchor)
break
...
}
在patch过程中,当n1为null,会在容器中挂载n2节点。
其中anchor是一个锚点元素,当我们对新旧节点做增删或移动等操作时,作为参照节点。
- 通过insertBefore完成移动,dom元素挂载到锚点元素之前;
- 当anchor为null,会在容器末尾插入元素;
// insert函数
insert(el, parent, anchor){
container.insertBefore(el, anchor || null)
}
快速DIFF(源码位置)
同层比较vNode中的children,将通过patchFlag区分为有key和无key的情况,两种情况的对比会存在不同。
const patchChildren: PatchChildrenFn = (
n1,
n2,
container,
anchor,
...
) => {
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children
const { patchFlag, shapeFlag } = n2
// fast path
if (patchFlag > 0) {
// 有key或部分有key,位与运算大于0
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
...
)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// 没有key
patchUnkeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
...
)
return
}
}
...
}
patchUnkeyedChildren(无key)
那么针对没有key的节点,patchUnkeyedChildren的作用是什么呢?
- 比较新旧children的length,取最小值,进行循环,调用patch函数
- 如果oldLength > newLength,卸载剩余的旧节点
- 如果newLength > oldLength,挂载剩余的旧节点
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
...
) => {
c1 = c1 || EMPTY_ARR
c2 = c2 || EMPTY_ARR
const oldLength = c1.length
const newLength = c2.length
// 获取最小值
const commonLength = Math.min(oldLength, newLength)
let i
for (i = 0; i < commonLength; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
patch(
c1[i],
nextChild,
container,
null,
...
)
}
if (oldLength > newLength) {
// 卸载节点
unmountChildren
} else {
// 挂载节点
mountChildren
}
}
patchKeyedChildren(有key或部分有key)
这种情况的比较会复杂一些,先举个简单的例子,和纯文本对比思路类似:
TEXT1: (i like you)
TEXT2: (i like you)too
------
TEXT3: (i use) vue (for app developement)
TEXT4: (i use) react (for app developement)
两段文本对比中,括号中的内容是不需要作处理的,经过预处理以后,真正需要比较的是不同的部分。比如1和2的区别是新增了too,3和4的区别是vue和react。
vue中也是这样的提前做了处理,对不同的部分进行新增、删除、比较操作。
const patchKeyedChildren = (
c1: VNode[], // 旧的节点
c2: VNodeArrayChildren, // 新的节点
container: RendererElement,
parentAnchor: RendererNode | null,
...
) => {
let i = 0 // 开始 index
const l2 = c2.length
let e1 = c1.length - 1 // 旧:最后一个节点index
let e2 = l2 - 1 // 新:最后一个节点index
...
}
预处理
从开头、结尾对新旧节点中可以复用的节点进行patch操作,打补丁
// 从开头比较
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
...
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, ...)
} else {
break
}
i++
}
// 从结尾比较
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
...
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, ...)
} else {
break
}
e1--
e2--
}
新增和删除
当新旧节点进过步骤1处理后,一种情况是只有新增和删除节点,进行处理
// 新增节点
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) {
patch(null, n2, container, anchor, ...)
i++
}
}
}
// 删除节点
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
移动节点
当新旧节点进过步骤1处理后,另一种情况是还有剩余的节点,需要按照新节点的顺序对元素进行移动等处理 过程如下:
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
const s1 = i // 旧:开始 index
const s2 = i // 新:开始 index
- 遍历剩余的新节点,生成一个
key:index的索引表keyToNewIndexMap;
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (nextChild.key != null) {
...
keyToNewIndexMap.set(nextChild.key, i)
}
}
- 遍历剩余的旧节点,获取新节点对应在旧节点容器中的
index,生成一个数组newIndexToOldIndexMap,同时进行判断
- 如果旧节点数大于新节点数,说明剩余的节点都是多余的,
patched >= toBePatched,直接卸载 - 如果在
keyToNewIndexMap中未找到或没有相同类型的节点,直接卸载 - 如果旧节点在
keyToNewIndexMap中对应的新索引值处于递增,则不需要移动,否则添加标记moved = true
// 已patched节点的个数
let patched = 0
// 需要patched的节点个数
const toBePatched = e2 - s2 + 1
// 节点是否需要移动
let moved = false
// 最大索引值,会改变
let maxNewIndexSoFar = 0
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
// 多余节点
if (patched >= toBePatched) {
// 卸载节点
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 没有key时,通过isSameVNodeType比较
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
newIndex = j
break
}
}
}
// 未找到相同类型节点
if (newIndex === undefined) {
// 卸载节点
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 判断是否需要移动
newIndexToOldIndexMap[newIndex - s2] = i + 1
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
...
)
patched++
}
}
- 根据新节点的顺序及最大递增子序列移动相应dom元素,如[0, 8, 4, 12]的最大递增子序列为[0, 8, 12]或[0, 4, 12]
newIndexToOldIndexMap中默认值为0,说明没有对应的节点, 挂载新节点- 如果
moved = true,通过insert函数完成移动节点
// 获取最大递增子序列
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
// 锚点元素为下一个新节点
const anchor = nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
// newIndexToOldIndexMap中默认值为0,说明没有对应的节点
if (newIndexToOldIndexMap[i] === 0) {
// 挂载节点
patch(
null,
nextChild,
container,
anchor,
...
)
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 通过insert函数完成移动节点
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
关于key
通过分析diff算法,可以知道,当我们在项目中做列表循环的时候,有时候会将index或者index + 变量作为key赋值,每次列表变化,都会重新赋值,并没有起到快速diff的作用。