写在前面
当前笔记是在读书和写代码的同时做的记录,后续也未做整理,可能在某些方面写的比较简单。
更加详细的内容请直接查看项目代码
QA
Q: diff的目的
A:当新旧 vnode 的子节点都是一组节点时,以最小的性能开销完成更新操作
简单的diff算法
我们为了最大限度的复用Dom,添加了属性key
当我们进行更新时,当遇到oldChildren与newChildren同为Array的情况下,我们会根据patchFlag是否标记key进行不同方式的更新。
在vue中,vFor指令下会对当前的
VNode.patchFlag添加KEYED_FRAGMENT标记,以便做到上述的判断。
- 当没有
key时
我们取oldChildren与newChildren数量的最小值,然后遍历,依次做出更新
for (let i = 0; i < minLength; i++) {
patch(c1[i], c2[i], container)
}
之后针对新增、删除的节点,做出二次处理
/**
* 有新增的
*/
if (oldL < newL) {
for (let i = minLength; i < newL; i++) {
patch(null, c2[i], container)
}
}
/**
* 有删除的
*/
if (oldL > newL) {
for (let i = minLength; i < oldL; i++) {
unmount(c1[i])
}
}
- 当有
key时
我们通过两重for循环,依次找到key相同的两个节点,并记录旧节点的下标lastIndex,之后更新这两个节点
patch(oldVnode, newVnode, container)
在遍历到下一个节点时,需要判断下标是否大于lastIndex,如果大于,则是正常现象,并再次记录,如果出现小于,则说明当前节点的位置发生了改变,我们就需要在更新后将当前的节点插入到上一个节点之后。
const preVNode = c2[i - 1] // 1. 拿到上一个节点
if (preVNode) {
const anchor = preVNode.el!.nextSibling as Node // 2. 上一个节点的真实节点,它的下一个节点
/**
* 插入到上一个节点与一个节点原下一个节点之间
* newA、anchor
*
* 插入
*
* newA、newB、anchor
*
* 放在anchor前面
*/
insert(newVnode.el!, container, anchor)
}
解决了位置问题还要解决添加与删除的问题。
我们创建了一个find变量,它表示在本次遍历中是否找到相同的key。如果没有找到,则说明本次进入遍历的newVNode是一个新节点,我们需要添加进去
patch(null, newVnode, container, anchor)
我们在两重循环结束后,需要根据oldChildren在进行依次遍历,比较旧列表中每一个节点的key都能找到新节点,如果找不到,那它就是被删除的节点。
unmount(oldVNode)
双端比较
在简单的diff中我们通过双重循环来进行新旧节点列表的对比,是自上而下的比较方法,但它的性能并不是最优的,比如如下例子。
const oldList = [
h('p', {key: 1}, '哈哈哈1'),
h('p', {key: 2}, '哈哈哈2'),
h('p', {key: 3}, '哈哈哈3'),
]
const newList = [
h('p', {key: 3}, '哈哈哈3'),
h('p', {key: 1}, '哈哈哈1'),
h('p', {key: 2}, '哈哈哈2')
]
我们用之前的方式进行更新时,大致需要进行如下步骤
- 找到
key === 3的节点,确定下标 - 找到
key === 1的节点,移动节点 - 找到
key === 2的节点,移动节点
那么我们通过肉眼观察可以看到,其实只需要将key === 3的节点移动到头部即可,为此我们需要对首尾进行同时比较。
let oldStartIdx = 0
let oldEndIdx = c1.length -1
let newStartIdx = 0
let newEndIdx = c2.length -1
/**
* 四个索引的节点
*/
let oldStartVNode = c1[oldStartIdx]
let newStartVNode = c2[newStartIdx]
let oldEndVNode = c1[oldEndIdx]
let newEndVNode = c2[newEndIdx]
我们先将首尾下标以及对应的节点声明出来,当我们对某一对对应的节点做出处理后,就需要改动这些下标已经引用的节点
/**
* 开启循环
*
* oldStartIdx <= oldEndIdx 说明旧列表还没处理完
* newStartIdx <= newEndIdx 说明新列表还没处理完
*/
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
...
}
- 头部节点相同:
oldStartVNode.key === newStartVNode.key
我们先打上补丁,patch(oldStartVNode, newStartVNode, container)
现在需要修改下标以及引用
oldStartVNode = c1[++oldStartIdx]
newStartVNode = c2[++newStartIdx]
由于两个都是头部节点,那么只需要修改对应的startVNode以及startIdx,都向后进一位
-
尾部节点相同:
oldEndVNode.key === newEndVNode.key同上,但是此时作出修改的是
endVNode以及endIdx,向前进一位 -
新头与旧尾相同:
oldEndVNode.key === newStartVNode.key打上补丁...
此时我们需要进行位置移动
insert(oldEndVNode.el!, container, oldStartVNode.el!)我们要将匹配到的旧尾的节点移动到头部,之后修改下标
oldEndVNode = c1[--oldEndIdx] newStartVNode = c2[++newStartIdx]旧尾向前进一,新头向后进一
-
新尾与旧头相同:
oldStartVNode.key === newEndVNode.key进行位置移动
insert(oldStartVNode.el!, container, oldEndVNode.el!.nextSibling)
将旧头移动到末尾
```js
oldStartVNode = c1[++oldStartIdx]
newEndVNode = c2[--newEndIdx]
-
以上四种情况外
此时我们遇到了尴尬的情况,例子如下
const oldList = [
h('p', {key: 1}, '哈哈哈1'),
h('p', {key: 2}, '哈哈哈2'),
h('p', {key: 3}, '哈哈哈3'),
h('p', {key: 4}, '哈哈哈4'),
]
const newList = [
h('p', {key: 3}, '哈哈哈3'),
h('p', {key: 4}, '哈哈哈4'),
h('p', {key: 1}, '哈哈哈1'),
h('p', {key: 2}, '哈哈哈2')
]
四种情况都对不上,那我们要怎么办呢?
我们只看新头,也就是key === 3的节点,对旧节点做出查找
const idxInold = c1.findIndex(vnode => vnode && vnode.key === newStartVNode.key)
从旧节点中找到对应节点的下标,之后就是打补丁并移动位置
if (idxInold > 0) {
// 找到了
const vnodeToMove = c1[idxInold]
// 打个补丁
patch(vnodeToMove, newStartVNode, container)
// 将这个节点放到真实节点最前方
insert(vnodeToMove.el!, container, oldStartVNode.el)
// 已经处理过这个节点,所以从c1中去除
;(c1[idxInold] as VNode | undefined) = undefined
} else {
// 没找到,那么这个节点就是新加的
// 挂载并放在旧节点列表的头部
patch(null, newStartVNode, container, oldStartVNode.el)
}
newStartVNode = c2[++newStartIdx]
本阶段只处理了新列表中的头部节点,所以只移动newStartIdx即可。注意:我们在本阶段处理节点时,将对应的旧列表中的节点改成了undefined,这就会导致在某个阶段所取到的旧列表中的节点会是undefined,所以我们还需要加两层判断
if (!oldStartVNode) {
console.log('oldStartVNode 为空')
// 说明该节点被处理过了
oldStartVNode = c1[++oldStartIdx]
} else if (!oldEndVNode) {
console.log('oldEndVNode 为空')
oldEndVNode = c1[--oldEndIdx]
}
在遇到undefined的情况,直接修改对应的下标。
现在while循环结束,正常情况下,oldEndIdx < oldStartIdx 表示旧列表处理完成,newEndIdx < newStartIdx 新列表处理完成。 但我们还需要考虑到有新增节点和卸载节点的情况。
oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx新列表还有未处理的节点
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
for (let i = newStartIdx; i <= newEndIdx; i++) {
patch(null, c2[i], container, oldStartVNode.el)
}
}
我们从此时的newStartIdx下标出发,将剩余的节点打上补丁并插入到此时的oldStartVNode之前,因为在while结束之后,oldStartVNode已经到了对应的位置
newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx旧列表还有未处理的节点
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
console.log('去卸载了', i, c1[i])
unmount(c1[i])
}
不需要多说,直接循环卸载掉即可。
快速diff
快速diff是vue3中引用的算法,比vue2中的双端算法更优。
我们要分几个步骤:
- 预处理
- 对剩余节点的判断处理
- 过滤出需要移动的节点组
- 移动节点
预处理
首先,我们需要对diff的两者进行预处理。举个例子:
const old = [
h('p', {key: 1}, '哈哈哈1'),
h('p', {key: 2}, '哈哈哈2'),
h('p', {key: 3}, '哈哈哈3'),
h('p', {key: 4}, '哈哈哈4'),
h('p', {key: 6}, '哈哈哈6'),
h('p', {key: 5}, '哈哈哈5')
]
const new = [
h('p', {key: 1}, 'n哈哈哈1'),
h('p', {key: 3}, 'n哈哈哈3'),
h('p', {key: 4}, 'n哈哈哈4'),
h('p', {key: 2}, 'n哈哈哈2'),
h('p', {key: 7}, 'n哈哈哈7'),
h('p', {key: 5}, 'n哈哈哈5'),
]
可以看到在本例子中,两者的头部节点与尾部节点都是相同的,所以我们可以先patch前后相同的节点。
let j = 0
let oldVNode = c1[j]
let newVNode = c2[j]
/**
* 对比前置节点
*
* 直到找到带有不同key的节点
*/
while (oldVNode.key === newVNode.key) {
console.log('处理前置节点', oldVNode, newVNode)
patch(oldVNode, newVNode, container)
j++
oldVNode = c1[j]
newVNode = c2[j]
}
我们从头部开始,依次对比两方的key,当key相同时直接更新,然后继续向后对比。
同理,处理尾部节点。
let oldEndIdx = c1.length - 1
let newEndIdx = c2.length - 1
let oldEndVNode = c1[oldEndIdx]
let newEndVNode = c2[newEndIdx]
/**
* 对比后置节点
*
*/
while (oldEndVNode.key === newEndVNode.key) {
console.log('处理后置节点', oldEndVNode, newEndVNode)
patch(oldEndVNode, newEndVNode, container)
oldEndVNode = c1[--oldEndIdx]
newEndVNode = c2[--newEndIdx]
}
对剩余节点的判断处理
现在我们处理了前后比较容易处理的节点,当前还剩如下节点。
const old = [
// h('p', {key: 1}, '哈哈哈1'),
h('p', {key: 2}, '哈哈哈2'),
h('p', {key: 3}, '哈哈哈3'),
h('p', {key: 4}, '哈哈哈4'),
h('p', {key: 6}, '哈哈哈6'),
// h('p', {key: 5}, '哈哈哈5')
]
const new = [
// h('p', {key: 1}, 'n哈哈哈1'),
h('p', {key: 3}, 'n哈哈哈3'),
h('p', {key: 4}, 'n哈哈哈4'),
h('p', {key: 2}, 'n哈哈哈2'),
h('p', {key: 7}, 'n哈哈哈7'),
// h('p', {key: 5}, 'n哈哈哈5'),
]
那么我们需要分几种情况讨论
- 当旧节点已经处理完成(
j > oldEndIdx) 但是新节点还有剩余(j <= newEndIdx)
此时就说明,在新节点中剩下的都是新增的节点,所以我们直接挂载
if (j > oldEndIdx && j <= newEndIdx) {
const anchorIdx = newEndIdx + 1
const anchor = anchorIdx < c2.length ? c2[anchorIdx].el : null
while (j <= newEndIdx) {
console.log('处理新增节点', c2[j])
patch(null, c2[j++], container, anchor)
}
}
- 当新节点已经处理完成(
j > newEndIdx) 但是旧节点还有剩余(j <= oldEndIdx)
此时说明,在旧节点中剩余的节点都是更新之后不再需要的,我们直接卸载
if (j > newEndIdx && j <= oldEndIdx) {
/**
* 新列表处理完了,但旧列表还有
*/
while (j <= oldEndIdx) {
console.log('卸载旧节点', c1[j])
unmount(c1[j++])
}
}
- 两方都有剩余
我们首先需要得到还没有处理的节点数量,以及未处理的起始位置
// 没有预处理的数量
const count = newEndIdx - j + 1
const oldStartIdx = j
const newStartIdx = j
通过遍历新列表,生成一个key与索引的对应关系
/**
* 索引表
*/
const keyIndex = new Map()
/**
* 建立新列表中,key与索引的对应关系
*/
for (let i = newStartIdx; i <= newEndIdx; i++) {
keyIndex.set(c2[i].key, i)
}
之后就开始根据旧列表遍历key,更新节点
/**
* 用来做节点移动的判断
*/
let moved = false
let pos = 0
/**
* 用来记录已处理的节点数量
*/
let patched = 0
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
oldVNode = c1[i]
/**
* 只有当更新数量小于等于需要更新的数量时,才进入更新
*
* 否则直接卸载
*/
if (patched <= count) {
const k = keyIndex.get(oldVNode.key)
if (typeof k !== 'undefined') {
// 匹配到了新列表中的索引
newVNode = c2[k]
patch(oldVNode, newVNode, container)
patched++ // 记录更新的数量
source[k - newStartIdx] = i
if (k < pos) {
/**
* 此时说明当前的节点发生了移动
*
* 原理如同简单的diff中的下标大小判断
*/
moved = true
} else {
pos = k
}
} else {
unmount(oldVNode)
}
} else {
unmount(oldVNode)
}
}
依次遍历剩余的旧列表中的节点,如果在新列表的索引对应中找得到相同的key,那么就做出更新patch,并增加一下更新的数量,当更新数量超过新列表中的已有的数量时,那么证明旧列表中剩下的没有更新的都是已经被抛弃的节点。
在更新之于,我们还维护里一个坐标数组source,他用来记录新节点在旧节点中对应的位置索引。
// 现在我们有如下节点。
const old = [
// h('p', {key: 1}, '哈哈哈1'),
h('p', {key: 2}, '哈哈哈2'),
h('p', {key: 3}, '哈哈哈3'),
h('p', {key: 4}, '哈哈哈4'),
h('p', {key: 6}, '哈哈哈6'),
// h('p', {key: 5}, '哈哈哈5')
]
const new = [
// h('p', {key: 1}, 'n哈哈哈1'),
h('p', {key: 3}, 'n哈哈哈3'),
h('p', {key: 4}, 'n哈哈哈4'),
h('p', {key: 2}, 'n哈哈哈2'),
h('p', {key: 7}, 'n哈哈哈7'),
// h('p', {key: 5}, 'n哈哈哈5'),
]
const source = new Array(count).fill(-1)
// source = [-1, -1, -1, -1]
// 在循环结束后,source则会变成
// source = [2, 3, 1, -1]
如何判断是否发生节点移动呢。
我们的做法类似与简单的diff算法中一样
let moved = false
let pos = 0
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
oldVNode = c1[i]
const k = keyIndex.get(oldVNode.key)
if (k < pos) {
/**
* 此时说明当前的节点发生了移动
*
* 原理如同简单的diff中的下标大小判断
*/
moved = true
} else {
pos = k
}
}
我们正在遍历旧列表,而k则是当前的节点在新列表中的索引,在遍历中我们会记录当前节点的坐标pos,比如key === 2的节点,他的k则是3,而到key === 3的节点时,k变成了1。我们可以允许节点整体向后推移,但如果后方的节点跑到了前面,那么就会标记这组节点发生了移动moved = true。
移动节点
在预处理后,两方都有剩余节点的情况下,我们通过遍历,记录了key的对应索引,以及是否发生了节点移动。
我们对source求出最长增长子序列的索引。
/**
* 通过算法得出的不需要移动位置的节点索引
*
* source: [2, 3, 1, -1]
*
* seq: [0,1]
*/
const seq = lis(source)
为什么要求最长增长子序列的索引呢?
为了移动节点时的性能提升,我们要尽可能的减少移动的频率,求得结果为[0,1],则这两个索引对应的节点不需要移动,只需要移动其他索引对应的节点即可。
我们开始从后往前遍历(因为dom的api,insert是将dom插入到节点前方,所以需要先确定后置节点才更方便操作。)
let s = seq.length - 1
let i = count - 1
/**
* 从后往前遍历source, 比对seq,找出需要移动的节点
*/
for (i; i >= 0; i--) {
if (source[i] === -1) {
// 从旧列表中得到的索引是-1,说明是新增的节点
const pos = i + newStartIdx // 拿到新列表中的索引
const newVNode = c2[pos]
const nextPos = pos + 1
// 锚点,新列表中该节点的后置节点
const anchor = nextPos < c2.length ? c2[nextPos].el : null
patch(null, newVNode, container, anchor)
} else if (i !== seq[s]) {
// 如果此索引不再seq中,则是需要移动的节点
const pos = i + newStartIdx // 拿到新列表中的索引
const newVNode = c2[pos]
const nextPos = pos + 1
// 锚点,新列表中该节点的后置节点
const anchor = nextPos < c2.length ? c2[nextPos].el : null
insert(newVNode.el!, container, anchor)
} else {
// 此节点不需要移动
s--
}
}
开始循环,分成了三种情况
-
当
source的索引是-1时,这是一个没有在旧列表中存在的节点,所以我们直接挂载到它在新列表中的下一个节点的前方。 -
当前的索引与子序列中的索引不同,那么我们就移动节点到它在新列表中的下一个节点的前方。
-
当前索引与子序列中的索引相同,那么它是一个固定节点,我们将循环子序列的
s向前进一位,开始对比下一个子序列
最长增长子序列算法
我们将问题分成两步。
- 得到最长子序列数组,注意:并不是增长的
具体方案就一句话:用栈结构,如果值比栈内所有值都大则入栈,否则替换比它大的最小数,最后的栈就是答案
var lengthOfLIS = function(nums) {
const res = [nums[0]] // 先确定一个初始的值
/**
* 开始循环nums中的值
*/
for (let i = 1; i < nums.length; i++) {
let cur = nums[i]
let isLarger = false
/**
* 将当前的值与res中的每一个值进行比较
*
* 找到比他大的最小值
*
* 找到后就将其替换,并做出标记`isLarger`
*/
for (let j = 0; j < res.length; j++) {
if (res[j] >= cur && !isLarger) {
res[j] = cur
isLarger = true
break;
}
}
/***
* 如果当前的值比res中的每一个值都大,则填充到末尾
*/
if (!isLarger) {
res.push(cur)
}
}
return res
};
现在我们进行代入
const nums = [2, 3, 1, -1]
lengthOfLIS(nums) // 结果:[-1, 3]
可以看到结果并不符合我们的要求,因为它不是递增的,而且我们需要的是索引。
为此我们进行改造。
将res中记录的值改为索引比较简单,只需要修改一下调用(nums[res[j]]) 与添加(res.push(i))即可
试想,我们在比较大小之后,将当前值赋值到了res中对应的位置res[j] = cur,如果res是个二位数组,将赋值改为追加,那么它是不是就可以记录当前位置曾经赋过的值
var lengthOfLIS = function(list) {
const res = [[0]] // 二维数组
for (let i = 1; i < list.length; i++) {
let cur = list[i]
let isLarger = false
for (let j = 0; j < res.length; j++) {
const resTask = res[j] // res中当前位置的一维数组
if (list[resTask.at(-1)] > cur) { // 用数组的最后一位进行判断
resTask.push(i)
isLarger = true
break;
}
}
if (!isLarger) {
res.push([i]) // 添加一个数组到二维数组中
}
}
return res
};
此时进行带入
const nums = [2, 3, 1, -1]
lengthOfLIS(nums) // 结果:[ [ 0, 2, 3 ], [ 1 ] ]
- 找到二维数组的递增
在二维数组中找到递增的一维数组就比较简单了。
我们从最后一位往前推,拿到最后一个一维数组的最后一个元素,作为结果的最后一位,然后向前比较,找到前一个一维数组中比当前数字小的这一位,然后继续向前比较
let endNum = res.at(-1)!.at(-1)! // 拿到最后一个一维数组的最后一个元素作为比较值
const result = [endNum] // 结果数组
for (let i = res.length - 2; i >= 0; i--) { // 从倒数第二个开始向前遍历
const curRes = res[i] // 当前的一维数组
let length = curRes.length - 1
let num = curRes[length]
/**
* 循环这个一维数组
*
* 直到找到比比较值小的数字
*/
while(num > endNum) {
num = curRes[--length]
}
// 修改比较值
endNum = num
// 向结果中添加
result.unshift(num)
}
最后的得到的result既是最长增长子序列。
写在最后
更完善的代码请看my-vue