前言
Vue3
出来一段时间了,对diff
算法进行了一波优化。
在阅读之前,最好需要了解一些diff
算法的基础:
1、vNode
是什么?
2、为什么需要使用diff
算法?
传送门:VNode - 源码版
本文主要分为三个部分:
一、diff
算法的流程和思路
二、深入源码,看看具体的实现以及代码的优化
三、React 16以下
和Vue2
移动dom
的方式,以及Vue3 diff
的优化
Vue3 diff 思路
了解过React
或者Vue2
的小伙伴应该都知道,通常diff
对比只有在拥有相同的父元素时,才会往下遍历。那现在假设他们的父节点是相同的,现在直接开始进行子节点们的比较。为了区分不同的场景下的思路,每一个部分都会举的不同的例子。
第一个例子在头尾遍历预处理时使用:
预处理优化
与Vue2
的双向遍历不一样,先来看看下面这两组简单的节点对比,在Vue3
中首先会进行头尾的单向遍历,进行预处理优化。
1、从头开始遍历
首先会遍历开始节点,判断新老的第一个节点是否是同一个节点,相同的话,执行patch
方法更新差异,然后往下继续比较,否则break
跳出。可以看到下图中,A vs A
是一样的,然后去比较B
,B
也是相同的节点,再去比较C vs F
,发现不一样了
2、尾部开始遍历
接着我们开始从后往前遍历,也是找相同的元素,G vs G
一致,那么执行patch
后往前对比,F vs F
一致,一方遍历完毕,跳出循环。
3、一方已经处理完毕
根据上面的操作,目前新节点还剩下一个新增节点C
,此时会去判断是否老节点已经遍历完毕,然后直接新增真实的dom节点C
。
那如果是老节点还剩下一个多余节点(下图为新例子),则会去判断新节点是否遍历完成,下图的I
节点则是要卸载。
到了这一步,比较核心的场景还没有出现,如果运气好,可能到这里就结束了,那我们也不能全靠运气。剩下的一个场景是新老节点都还有多个子节点存在的情况。那接下来看看,Vue3
是怎么做的。为了结合move
、新增
和卸载
的操作,在这里引入另一个全新的例子,。
每次在对元素进行移动的时候,我们可以发现一个规律,如果想要移动的次数最少,就意味着需要有一部分元素是稳定不动的,那么究竟能够保持稳定不动的元素有一些什么规律呢?
可以看一下上面这个例子:C H D E
vs D E I C
,在比对的时候,凭着肉眼可以看出只需要将C
进行移动到最后,然后卸载H
,新增I
就好了。D E
可以保持不动,可以发现D E
在新老节点中的顺序都是不变的,D
在E
的后面,下标处于递增状态。
这里引入一个概念,叫最长递增子序列。
官方解释:在一个给定的数组中,找到一组递增的数值,并且长度尽可能的大。
有点比较难理解,那来看具体例子:
const arr = [10, 9, 2, 5, 3, 7, 101, 18]
=> [2, 3, 7, 18]
这一列数组就是arr的最长递增子序列,其实[2, 3, 7, 101]也是。
所以最长递增子序列符合三个要求:
1、子序列内的数值是递增的
2、子序列内数值的下标在原数组中是递增的
3、这个子序列是能够找到的最长的
但是我们一般会找到数值较小的那一组数列,因为他们可以增长的空间会更多。
那接下来的思路是:如果能找到老节点在新节点序列中顺序不变的节点们,就知道,哪一些节点不需要移动,然后只需要把不在这里的节点插入进来就可以了。因为最后要呈现出来的顺序是新节点的顺序,移动是只要老节点移动,所以只要老节点保持最长顺序不变,通过移动个别节点,就能够跟它保持一致。所以在此之前,先把所有节点都找到,再找对应的序列。最后其实要得到的则是这一个数组:[2, 3, 新增 , 0]
。其实diff
移动的思路已经清楚了,接下来就是看看怎么从代码逻辑中去实现这段逻辑了。
4、patch && unmount
通过上面的铺垫,得知了要找到这样一个数组[2, 3, 新增, 0]
,不过因为数组的初始值是0
,代表的是新增
的意思,所以其他元素坐标顺延+1
,0
仅代表新增
,最后也就是[3, 4, 0, 1]
,可以看成第1位,第2位,第3位的意思。
找到这个数组就很简单了,先初始化一个数组:[0, 0, 0, 0]
,再遍历老节点,找到对应的新节点,然后加入到新节点对应的坐标上。
开始遍历了,在遍历过程中,会执行patch
和unmount
操作,如下图表格:
当前老坐标下标 | 当前找到的新节点坐标 | 新节点坐标下所对应的旧节点数组(初始值为0,代表新增,加进来坐标+1) |
---|---|---|
0 | 3 | [0, 0, 0, 1] |
1 | 无 | 卸载,执行unmount方法 |
2 | 0 | [3, 0, 0, 1] |
3 | 1 | [3, 4, 0, 1] |
跟着上面的表格,可以看元素变化图,以及真实的dom节点做了什么操作:
1、遍历老节点,拿到第一个节点C
,去新节点列表中找相同的节点,找到新节点中有C
,在第三位,下标为3
,于是在数组的第3
位下标中,把当前老节点的下标加进去,由于前面说过,坐标都要+1,所以此时数组为[0, 0, 0, 1]
,并且此时也会去执行patch
方法,会将新旧节点的差异部分对齐,比如新旧C
节点仅有class
不一致,此时便会去执行更新class
的方法。
2、遍历到第二位H
,H
在新节点中找不到,所以会直接执行unmount
方法,去卸载H
,此时真实dom也发生了变化。
3、遍历到第三位D
,继续去新节点列表中找相同的节点D
,下标为0
,于是在数组的第0
位下标中,把当前老节点的下标+1塞进数组,所以此时数组为[3, 0, 0, 1]
,并且此时也会去执行patch
方法。
4、遍历到第四位E
,同理,在新节点中找到后把当前老节点的下标+1塞进数组,所以此时数组为[3, 4, 0, 1]
,并且此时也会去执行patch
方法。
遍历完后,最后得到了一个[3, 4, 0, 1]
的数组,并且此时已经执行了有相同节点的patch
方法和多余节点的unmount
方法。
通过肉眼可以看到,它的最长增长子序列为[3, 4]
(本文不做最长递增子序列求解,想了解求解,请移步最长递增子序列求解传送门)。[3, 4]
的下标为[0, 1]
,也就是说新节点的第0位D
和第1位E
不需要动。
5、move && mount
它所对应的是第一个节点D
和第二个节点E
,所以这两个节点是不需要动的。
最后再遍历数组[3, 4, 0, 1]
,如果当前的节点与在最长增长子序列中,则不移动,为0
直接新增
,剩下的则move
到当前位置。接下来想深入了解的话,可以看一下第二部分的源码,Vue3 diff
的这段源码还是比较清晰的。
源码
源码文件路径:packages/runtime-core/src/renderer.ts
源码仓库地址:vue-next
patchChildren
我们从patchChildren
方法开始,进行子节点之间的比较。
const patchChildren: PatchChildrenFn = () => {
// 获得当前新旧节点下的子节点们
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children
const { patchFlag, shapeFlag } = n2
// fragment有两种类型的静态标记:子节点有key、子节点无key
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// 子节点全部或者部分有key
patchKeyedChildren()
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// 子节点没有key
patchUnkeyedChildren()
return
}
}
// 子节点有三种可能:文本节点、数组(至少一个子节点)、没有子节点
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 匹配到当前是文本节点:卸载之前的节点,为其设置文本节点
unmountChildren()
hostSetElementText()
} else {
// old子节点是数组
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 现在(new)也是数组(至少一个子节点),直接full diff(调用patchKeyedChildren())
} else {
// 否则当前没有子节点,直接卸载当前所有的子节点
unmountChildren()
}
} else {
// old的子节点是文本或者没有
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 清空文本
hostSetElementText(container, '')
}
// 现在(new)的节点是多个子节点,直接新增
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新建子节点
mountChildren()
}
}
}
}
我们可以直接用文本描述一下这段代码:
1、获得当前新旧节点下的子节点们(c1、c2);
2、使用patchFlag
进行按位与判断fragment
的子节点是否有key(patchFlag是什么稍后下面说);
3、不管有没有key,只要匹配成功一定是数组,有key/部分有key则调用patchKeyedChildren
方法进行diff计算,无key则调用patchUnkeyedChildren
方法;
4、不是fragment节点,那么子节点有三种可能:文本节点、数组(至少一个子节点)、没有子节点;
5、如果new的子节点是文本节点:old有子节点的话则直接进行卸载,并为其设置文本节点;
6、否则new的子节点是数组 or 无节点,在这个基础上:
如果old的子节点为数组,那么new的子节点也是数组的话,调用patchKeyedChildren
方法,直接full diff,否则new没有子节点,直接进行卸载。
最后old的子节点为文本节点 or 没有节点(此时新节点可能为数组,也可能没有节点),所以当old的子节点为文本节点,那么则清空文本,new节点如果是数组的话,直接新增。
7、此时所有的情况已经处理完毕了,不过真正的diff还没开始,那我们来看一下没有key
的情况下,是如何进行diff
的。
patchUnkeyedChildren
没有key的处理比较简单,直接上删减版源码
const patchUnkeyedChildren = () => {
c1 = c1 || EMPTY_ARR
c2 = c2 || EMPTY_ARR
const oldLength = c1.length
const newLength = c2.length
// 拿到新旧节点的最小长度
const commonLength = Math.min(oldLength, newLength)
let i
// 遍历新旧节点,进行patch
for (i = 0; i < commonLength; i++) {
// 如果新节点已经挂载过了(已经过了各种处理),则直接clone一份,否则创建一个新的vnode节点
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
patch()
}
// 如果旧节点的数量大于新节点数量
if (oldLength > newLength) {
// 直接卸载多余的节点
unmountChildren( )
} else {
// old length < new length => 直接进行创建
mountChildren()
}
}
我们继续文本描述一下逻辑:
1、首先会拿到新旧节点的最短公共长度
2、然后遍历公共部分,直接进行patch
3、如果旧节点的数量大于新节点数量,直接卸载多余的节点,否则新建节点
patchKeyedChildren
到了Diff算法比较核心的部分,我们先看一个大概预览,了解一下流程~再把patchKeyedChildren
源码内部拆分一下,逐步来看。
const patchKeyedChildren = () => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 1 // next ending index
// 1. 进行头部遍历,遇到相同节点则继续,不同节点则跳出循环
while (i <= e1 && i <= e2) {}
// 2. 进行尾部遍历,遇到相同节点则继续,不同节点则跳出循环
while (i <= e1 && i <= e2) {}
// 3. 如果旧节点已遍历完毕,并且新节点还有剩余,则遍历剩下的进行新增
if (i > e1) {
if (i <= e2) {}
}
// 4. 如果新节点已遍历完毕,并且旧节点还有剩余,则直接卸载
else if (i > e2) {
while (i <= e1) {}
}
// 5. 新旧节点都存在未遍历完的情况
else {
// 5.1 创建一个map,为剩余的新节点存储键值对,映射关系:key => index
// 5.2 遍历剩下的旧节点,新旧数据对比,移除不使用的旧节点
// 5.3 拿到最长递增子序列进行move or 新增挂载
}
}
1、第一步是进行头部遍历,遇到相同节点则继续,下标 + 1,不同节点则跳出循环
// 1. sync from start
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
const n1 = c1[i]
// 如果新节点已经挂载过了(已经经历了各种处理),则直接clone一份,否则创建一个新的vnode节点
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 相同节点,则继续执行patch方法
if (isSameVNodeType(n1, n2)) {
patch()
} else {
break
}
i++
}
此时i = 2, e1 = 6, e2 = 7, 旧节点剩下C、D、E、F、G,新节点剩下D、E、I、C、F、G
这里判断是否为相同节点的方法isSameVNodeType
,是通过类型和key来进行判断,在Vue2中是通过key和sel(属性选择器:tag + id + class)来判断是否是相同元素。这里的类型指的是ShapeFlag,也是一个标志位,是对元素的类型进行不同的分类,比如:元素、组件、fragment、插槽等等
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key
}
2、第二步是进行尾部遍历,遇到相同节点则继续,length - 1,不同节点则跳出循环
// 2. sync from end
// a (b c)
// d e (b c)
// 进行尾部遍历,遇到相同节点则继续,不同节点则跳出循环
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) {
patch()
} else {
break
}
e1--
e2--
}
此时i = 2, e1 = 4, e2 = 5, 旧节点剩下C、D、E,新节点剩下D、E、I、C
3、如果旧节点已遍历完毕,并且新节点还有剩余,则遍历剩下的进行新增
// 3.common sequence + mount
// (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, c2[i]) // 节点新增(伪代码)
i++
}
}
}
因为我们上面的图例(i < e1)走不到这段逻辑,所以我们可以直接看一下代码注释(注释真的写得非常详细了,patchKeyedChildren
里面的原注释我都保留了)。如果旧节点遍历完毕,开头或者尾部还剩下了新节点,则进行节点新增(通过传参,patch
内部会处理)。
4、如果新节点已经遍历完毕,则说明多余的节点需要卸载
// 4.common sequence + unmount
// (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++
}
}
因为我们上面的图例(i < e2)依然走不到这段逻辑,所以我们可以继续看一下原注释。i > e2意味着新节点遍历完毕,如果新节点遍历完毕,开头或者尾部还剩下了旧节点,则进行节点卸载unmount
。
5、新旧节点都没有遍历完成的情况
// 5. unknown sequence
// [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
else {
const s1 = i // prev starting index
const s2 = i // next starting index
...
}
按照上面图的例子来看,s1 = 2, s2 = 2,旧节点剩下C、D、E,新节点剩下D、E、I、C需要继续进行diff
5.1、生成map对象,通过键值对的方式存储新节点的key => index
// 5.1 build key:index map for newChildren
// 创建一个空的map对象
const keyToNewIndexMap = new Map()
// 遍历剩下没有patch的新节点,也就是D、E、I、H
for (i = s2; i <= e2; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 如果剩余的新节点有key的话,则将其存储起来,key对应index
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
执行完上面的方法,得到keyToNewIndexMap = {D => 2, E => 3, I => 4, C => 5}
,keyToNewIndexMap
主要用来干嘛呢~请继续往下看
5.2、遍历剩下的旧节点,新旧数据对比,移除不使用的旧节点
// 5.2 loop through old children left to be patched and try to patch
// matching nodes & remove nodes that are no longer present
let j
// 记录即将被patch过的新节点数量
let patched = 0
// 拿到剩下要遍历的新节点的长度,按照上面的图示toBePatched = 4
const toBePatched = e2 - s2 + 1
// 是否发生过移动
let moved = false
// 用于跟踪是否有任何节点移动
let maxNewIndexSoFar = 0
// works as Map<newIndex, oldIndex>
// 注意:旧节点 oldIndex偏移量 + 1
// 并且oldIndex = 0是一个特殊值,代表新节点没有对应的旧节点
// newIndexToOldIndexMap主要作用于最长增长子序列
// newIndexToOldIndexMap从变量名可以看出,它代表的是新旧节点的对应关系
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
// 此时newIndexToOldIndexMap = [0, 0, 0, 0]
// 遍历剩余旧节点的长度
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
if (patched >= toBePatched) {
// patched大于剩余新节点的长度时,代表当前所有新节点已经patch了,因此剩下的节点只能卸载
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex
if (prevChild.key != null) {
// 旧节点的key存在的话,则通过旧节点的key找到对应的新节点的index位置下标
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 旧节点没有key的话,则遍历所有的新节点
for (j = s2; j <= e2; j++) {
// newIndexToOldIndexMap[j - s2]如果等于0的话
// 代表当前新节点还没有被patch,因为在下面的运算中
// 如果找到新节点对应的旧节点位置,newIndexToOldIndexMap[j - s2]则会等于旧节点的下标 + 1
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
// 当前新节点还没有被找到,并新旧节点相同,则将新节点的位置赋予newIndex
newIndex = j
break
}
}
}
if (newIndex === undefined) {
// 当前旧节点没有找到对应的新节点,则进行卸载
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 找到了对应的新节点,则将旧节点的位置存储在对应的新节点下标
newIndexToOldIndexMap[newIndex - s2] = i + 1
// maxNewIndexSoFar如果不是逐级递增,则代表有新节点的位置前移了,那么需要进行移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
// 更新节点差异
patch()
// 找到一个对应的新节点,+1
patched++
}
}
这段代码比较长,但是总的来说做了下面几件事:
1、拿到新节点对应的旧节点下标newIndexToOldIndexMap
(下标+1,因为0代表的是新节点没有对应的旧节点,直接创建新节点),在我们的图例中newIndexToOldIndexMap = [4, 5, 0, 3]
。
2、存在在遍历的过程中,如果老节点找到对应的新节点,则进行打补丁,更新节点差异,找不到则删除该老节点
3️、通过新节点下标的顺序是否递增来判断,是否有节点发生过移动
5.3、对剩下没有找到的新节点进行挂载,对需要移动的节点进行移动
// 5.3 move and mount
// 仅在有节点需要移动的时候才生成最长递增子序列
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// 此时图示中的increasingNewIndexSequence = [4, 5]
// 从后面开始遍历,将最后一个patch的节点用作锚点
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
// 代表新增
if (newIndexToOldIndexMap[i] === 0) {
// mount new
patch( )
} else if (moved) {
// 移动的条件:当前最长子序列的length小于0(没有稳定的子序列),或者当前的节点不在稳定的序列中
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
最后这段源码用到了一个优化方法,最长上升子序列,这段大致的流程就是:
1、通过moved
来判断当前是否有节点进行了移动,如果有的话则通过getSequence(newIndexToOldIndexMap)
拿到最长上升子序列,我们的图示中拿到的是increasingNewIndexSequence = [4, 5]
;
2、遍历剩余新节点的长度,从后面开始遍历,判断newIndexToOldIndexMap[i] === 0
,当前的新节点是否有对应的老节点,如果等于0,就是没有,直接新增;
3、否则通过moved
判断是否有移动,有移动的话,如果当前最长子序列的length < 0
,或者当前的节点不在稳定的序列中,则意味着现在没有稳定的子序列,每个节点需要进行移动,或者,最后一个新节点,不在末尾的子序列中,子序列的末尾另有他人,那当前也需要进行移动。若是不符合移动的条件,则说明当前新节点在最长上升子序列中,不需要进行移动,只用等待别的节点去移动。
到这里,diff算法源码核心流程就了解得差不多了,接下来看看Vue3
对比React/Vue2
的优化点。
React、Vue2、Vue3
这里可以大体回顾一下React
和Vue2
移动dom的思路,用两个比较典型的例子进行介绍,这里主要对比的是,相同父元素的子元素之间的diff
移动操作,所以在下面的例子中,都是在同一层面下,A、B、C、D
为各自的key
:
React 16以下的版本
React
在移动dom节点时,是只会往右边进行移动的
从新节点开始遍历,在老节点中找相同的节点
左边:普遍情况
1、B
:在老节点中的第二位找到了相同的元素,由于上面说过,React
只会往右移动,它会用一个变量记录所有找到的老节点的下标,这个变量叫做lastIndex
,初始值为0,每找到一个比lastIndex
大的老节点下标都会更新它,所以它永远是找到过的老节点下标的最大的那个值。并且每次找到的老节点的下标一定要小于lastIndex才可以往右移动,这句话这么理解,因为我们是要往右边进行移动的,往右边走是增大的,所以每次找到的老节点下标,如果比上次找到的老节点下标大的话,那就说明位置顺序是正常的,没有往右边移动的必要了。
那么此时,因为lastIndex
的初始值是0
,找到的老节点的下标为1
,则无需移动,因为找到的老节点已经比较靠右了,但需要更新lastIndex
。
2、A
:上次记录的lastIndex
已经更新到了1
,此时找到的老节点A
的位置为0
,则需要进行右移。无需更新lastIndex
。
3、D
:当前lastIndex
还是1
,此时找到的老节点D
的位置为3
,是大于上次找到的老节点的最大位置的,所以无需移动。lastIndex
更新为3
。
4、C
:lastIndex
更新到了3
,此时找到的老节点C
的位置为2
,因为实际上新节点中C
是靠右的,不能比上次找到的老节点位置小,所以C
需要移动。
我们可以看到,在这种情况下,真实dom
是move
了两次,patch
了四次。而实际上,这种移动次数也是最少的。但是往右移动这种方式,仔细想想,好像并不是所有场景下都那么完美的。比如,上面我们提到的右边的那种情况。
右边:极端情况
通过我们上面方法的比对,来看看移动路径:
1、D
:由于找到的老节点在最右边,已经为最右边了,不能往右边移动了,所以暂定不动。
2、A
:移动到最右边。
3、B
:移动到最右边。
4、C
:移动到最右边。
明眼人都可以看出来,其实最好的移动方式只要移动一次就能够达到我们想要的效果,但是React
需要移动三次。
Vue2
刚刚在React
中右边例子的情况还有优化空间,那可以看看Vue2
是怎么解决这个问题的。
Vue2
为双向遍历,同时从前后往中间进行遍历
遍历的顺序:新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾
右边:极端情况
1、按照上面说的遍历的顺序,先是进行新旧节点当前首位A vs D
进行diff,不相同让新旧节点当前末位 D vs C
进行diff,不相同继续让旧节点当前首位和新节点当前末位 A vs C
进行diff,不相同继续让新节点当前首位和旧节点当前末位 D vs D
进行diff,哦豁,皇天不负有心人,第四次寻找终于找到了,因为新旧节点的位置是一前一后,显然不应该,于是找到D
后将它挪到前面来。之后两边的指针各挪一步。
2、紧接着,重复进行新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾
这个遍历顺序,但是第一次就找到了A vs A
,他们都是第一个,不需要移动,然后指针+1。
3、继续重复进行新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾
这个遍历顺序,找到了B vs B
,他们都是第一个,不需要移动,然后指针+1。
4、继续重复进行新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾
这个遍历顺序,找到了C vs C
,他们都是第一个,不需要移动,然后指针+1。
最后的结果是,所有的都遍历完了,最后只是将D
挪到了第一位而已,对上面的情况进行了优化。当然,这是一个非常理想的状态,那接下来来看看,普遍的情况是怎么做的。
左边:普遍情况
前面我们说过了那个遍历的顺序:新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾
,但是总不能每次都能够在最两边找到元素,接下来就来看看,如果前面的规则走完之后该怎么办。
1、前面的四次判断没有发现相同的新节点的话,则会遍历老节点找当前首位新节点B
(箭头指向的第一位新节点
),遍历老节点发现了B
在第二位,那我们现在才遍历到第一位,显然与我们想要的位置不符,于是将B的真实dom
移动到第一位,并将老节点列表原本B
的位置设置为undefined
,之后遇到undefined
的元素一律跳过。然后新节点初始下标+1。
2、新节点的下标+1后,我们的循环又重新开始了,又会按照前面的顺序进行判断(新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾
),于是遍历到了A vs A
,新旧节点的指针往后增加一位。
3、由于前面我们说过,B
已经是undefined
了,所以会跳过该元素,直接到C
,新旧节点的C
分别是末位和首位,于是移动C的真实dom
到最后一位。旧节点首位指针+1,新节点末位指针-1。
4、最后只剩下了D
元素,也就不需要移动了。
Vue2
在移动dom的上面已经做的比较好了,并且只会移动必须要挪动的那部分元素。通过前面的了解,Vue3
好像并没有在移动dom
上有更多的优化,那Vue3
在diff的过程中优化了什么呢?
优化
1、预处理优化
平时我们在修改列表的时候,有一些比较常见场景,比如说列表中间节点的增加、删除、修改等,如果使用了这样的方式查找,可以减少diff的时间
,甚至可以不用diff
来达到我们想要的结果,并且还可以减少后续diff的复杂度。这个预处理优化策略,是Neil Fraser提出的。
举个栗子:
像下面这些情况,在预处理中能够很快的做完这些处理。但是在Vue2
中,会需要一个接一个的重复新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾
。当然,Vue3
也不是在所有场景中都是最优的。
2、PatchFlags静态节点优化Vue2
在patch
阶段时会进行全量diff
,但是有的节点只声明了动态文本或者动态class,明明可以知道他是不变的,为什么还需要去diff它呢?所以Vue3
在这个过程中做了一些优化,我们来看看这个叫做PatchFlags
的东西是什么(只截了部分代码)。
export const enum PatchFlags {
// 动态文本
TEXT = 1,
// 动态class
CLASS = 1 << 1,
// 动态style
STYLE = 1 << 2,
// 动态props
PROPS = 1 << 3,
// 动态变化的属性,比如:[attr] = "foo"
FULL_PROPS = 1 << 4,
...
}
那翻译一下,其实这个枚举是长这样的:
export const enum PatchFlags {
// 动态文本
// 十进制: 1
// 二进制: 0000 0001
TEXT = 1,
// 动态class
// 十进制: 1
// 二进制: 1往左移一位: 0000 0001
CLASS = 1 << 1,
// 动态style
// 十进制: 1
// 二进制: 1往左移两位: 0000 0010
STYLE = 1 << 2,
// 动态props
// 十进制: 1
// 二进制:1往左移三位: 0000 0100
PROPS = 1 << 3,
...
}
举个例子:
这个div
,在编译的过程中,会执行这样一段代码(伪代码):
// 有动态绑定的class将执行
if (hasClassBinding) {
patchFlag |= PatchFlags.CLASS
}
// 有动态绑定的style将执行
if (hasStyleBinding) {
patchFlag |= PatchFlags.STYLE
}
// 有动态文本节点将执行
if (hasDynamicTextChild) {
patchFlag |= PatchFlags.TEXT
}
因为上面的例子中只有动态class和style,没有动态文本节点,所以只会执行:
patchFlag |= PatchFlags.CLASS
patchFlag |= PatchFlags.STYLE
patchFlag |= PatchFlags.CLASS
执行过程:
// 因为patchFlag初始值为0,所以第一次执行或运算时:
// 按位或:二进制位进行比对计算,有一个是1,结果的对应位就是1。
// 0000 0000
// 0000 0010
// ---------
// 0000 0010
那么此时二进制10等于十进制2,所以patchFlag = 2
。
紧接着执行patchFlag |= PatchFlags.STYLE
:
// 因为patchFlag此时为2,所以执行或运算时使用二进制10:
// 0000 0010
// 0000 0100
// ---------
// 0000 0110
那么此时二进制110等于十进制6,所以patchFlag = 6
。
然后在diff阶段执行patch
方法的时候会将patchFlag
取出来然后执行这一段代码,这段代码意味着,只有当括号内计算为真
的时候才会执行if
内的方法:
首先会判断patchFlag & PatchFlags.TEXT
是否为真,这一次是会进行与运算,执行过程:
// 按位与:二进制位进行比对计算,两个都是1,结果的对应位就才是1。
// 因为patchFlag此时等于6,所以第一次执行或运算时:
// 0000 0110 ----当前patchFlag
// 0000 0001 ----常量PatchFlags.TEXT
// ---------
// 0000 0000
换算之后的结果等于0
,所以不会执行修改文本节点的方法。当然,在我们的例子中文本节点是静态节点,也无需去比对修改。
接着会判断patchFlag & PatchFlags.CLASS
:
// 按位与:二进制位进行比对计算,两个都是1,结果的对应位就才是1。
// 因为patchFlag此时等于6,所以第一次执行或运算时:
// 0000 0110 ----当前patchFlag
// 0000 0010 ----常量PatchFlags.CLASS
// ---------
// 0000 0010
此时十进制结果为2
,则认为是动态节点,需要去执行修改class
的方法,之后再判断patchFlag & PatchFlags.STYLE
:
// 按位与:二进制位进行比对计算,两个都是1,结果的对应位就才是1。
// 因为patchFlag此时等于6,所以第一次执行或运算时:
// 0000 0110 ----当前patchFlag
// 0000 0100 ----常量PatchFlags.STYLE
// ---------
// 0000 0100
同理,也会执行修改style的方法。
当然Vue3
做的优化不止这么多,大家可以多看看源码,会有很多代码优化上的收获。
参考资料:
源码:https://github.com/vuejs/vue-...
diff优化策略:https://neil.fraser.name/writ...
inforno:https://github.com/infernojs/inferno
https://blog.csdn.net/u014125...
https://zhuanlan.zhihu.com/p/...
https://hustyichi.github.io/2...
https://segmentfault.com/a/11...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。