runtime运行时,diff算法核心实现
在日常我们使用vue
进行v-for
的时候,都需要传一个key
,这个key
的作用到底是什么呢?
Vue
有一个方法专门为了对比新旧节点是否相同,会用到key
,如下:
lua
/**
* 根据 key || type 判断是否为相同类型节点 type即是元素名,组件等等
*/
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key
}
php
//以下两个节点即是相同的节点
const vnode = h('li', {
key: 1,
}, '1')
const vnode2 = h('li', {
key: 1,
}, '2')
isSameVNodeType(vnode, vnode2) // true
自前向后diff
对比
创建如下对应实例:
less
<script>
const { h, render } = Vue
const vnode = h('ul', [
h('li', {
key: 1
}, 'a'),
h('li', {
key: 2
}, 'b'),
h('li', {
key: 3
}, 'c'),
])
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h('ul', [
h('li', {
key: 1
}, 'a'),
h('li', {
key: 2
}, 'b'),
h('li', {
key: 3
}, 'd')
])
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
自前向后即是从数组下标0开始依次遍历新节点数组和旧节点数组,通过上述isSameVNodeType
方法判断是否是同一个节点,如果是则挂载,不是则需要后续的处理。
csharp
/**
* diff
*/
const patchKeyedChildren = (oldChildren, newChildren, container, parentAnchor) => {
/**
* 索引
*/
let i = 0
/**
* 新的子节点的长度
*/
const newChildrenLength = newChildren.length
/**
* 旧的子节点最大(最后一个)下标
*/
let oldChildrenEnd = oldChildren.length - 1
/**
* 新的子节点最大(最后一个)下标
*/
let newChildrenEnd = newChildrenLength - 1
// 1. 自前向后的 diff 对比。经过该循环之后,从前开始的相同 vnode 将被处理
while (i <= oldChildrenEnd && i <= newChildrenEnd) {
const oldVNode = oldChildren[i]
const newVNode = normalizeVNode(newChildren[i])
// 如果 oldVNode 和 newVNode 被认为是同一个 vnode,则直接 patch 即可
if (isSameVNodeType(oldVNode, newVNode)) {
patch(oldVNode, newVNode, container, null)
}
// 如果不被认为是同一个 vnode,则直接跳出循环
else {
break
}
// 下标自增
i++
}
}
自后向前的diff
对比
创建如下测试实例:
less
<script>
const { h, render } = Vue
const vnode = h('ul', [
h('li', {
key: 1
}, 'a'),
h('li', {
key: 2
}, 'b'),
h('li', {
key: 3
}, 'c'),
])
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h('ul', [
h('li', {
key: 4
}, 'a'),
h('li', {
key: 2
}, 'b'),
h('li', {
key: 3
}, 'd')
])
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
自后向前即是从数组最后一位开始依次遍历新节点数组和旧节点数组,通过上述isSameVNodeType
方法判断是否是同一个节点,如果是则挂载,不是则需要后续的处理。
上述自前向后,在执行如上测试实例时,第一个节点就会对比不成功直接会跳出循环,所以需要自后向前处理;
kotlin
// 2. 自后向前的 diff 对比。经过该循环之后,从后开始的相同 vnode 将被处理
while (i <= oldChildrenEnd && i <= newChildrenEnd) {
const oldVNode = oldChildren[oldChildrenEnd]
const newVNode = normalizeVNode(newChildren[newChildrenEnd])
if (isSameVNodeType(oldVNode, newVNode)) {
patch(oldVNode, newVNode, container, null)
} else {
break
}
oldChildrenEnd--
newChildrenEnd--
}
新节点多余旧节点的diff
对比
创建如下实例:
less
<script>
const { h, render } = Vue
const vnode = h('ul', [
h('li', {
key: 1
}, 'a'),
h('li', {
key: 2
}, 'b'),
])
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h('ul', [
h('li', {
key: 3
}, 'c'),
h('li', {
key: 1
}, 'a'),
h('li', {
key: 2
}, 'b'),
])
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
- 以上测试实例,首先会进行自前向后的
diff
对比,但是第一次对比就会break
跳出;- 接下来进行自后向前的
diff
对比,挂载后两个节点b
和a
;- 此时
oldChildrenEnd
的值变为**-1**,不再满足自后向前的循环条件,跳出循环;
注意:此时索引i
大于oldChildrenEnd
,小于等于newChildrenEnd
,那么就代表新节点多余旧节点。
代码实现:
css
// 3. 新节点多余旧节点时的 diff 比对。
if (i > oldChildrenEnd) {
if (i <= newChildrenEnd) {
//此时newChildren为0 nextPos = 1
const nextPos = newChildrenEnd + 1
//锚点 以哪个元素为基准 的前面出入多出的元素
const anchor =
// 1 < 2成立 newChildren[nextPos].el 对应旧节点当中的a ,也就是多余节点会被插入到a的前面
nextPos < newChildrenLength ? newChildren[nextPos].el : parentAnchor
while (i <= newChildrenEnd) {
patch(null, normalizeVNode(newChildren[i]), container, anchor)
i++
}
}
}
旧节点多余新节点的diff
对比
创建如下对应实例:
less
<script>
const { h, render } = Vue
const vnode = h('ul', [
h('li', {
key: 3
}, 'c'),
h('li', {
key: 1
}, 'a'),
h('li', {
key: 2
}, 'b'),
])
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h('ul', [
h('li', {
key: 1
}, 'a'),
h('li', {
key: 2
}, 'b'),
])
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
当旧节点多余新节点的时候,我们只需要找到新节点中没有的卸载即可;
- 以上测试实例,首先会进行自前向后的
diff
对比,但是第一次对比就会break
跳出;- 接下来进行自后向前的
diff
对比,挂载后两个节点b
和a
;- 此时
newChildrenEnd
的值变为**-1**,不再满足自后向前的循环条件,跳出循环;
注意:此时索引i
大于newChildrenEnd
,小于等于oldChildrenEnd
,那么就代表旧节点多余新节点。
代码实现:
scss
// 4. 旧节点多与新节点时的 diff 比对。
else if (i > newChildrenEnd) {
while (i <= oldChildrenEnd) {
unmount(oldChildren[i])
i++
}
}
ini
//unmout方法
const unMount = vnode => {
const child = vnode.el
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
}
乱序下的diff
对比
最长递增子序列
假设有如下两组节点
旧节点:1,2,3,4,5,6
新节点:1,3,2,4,6,5
我们可以根据新节点生成递增子序列:
1 3 6
1 2 4 6
1 4 5
- ....
根据我们之前的四种场景可知,所谓的 diff
,其实说白了就是对 一组节点 进行 添加、删除、打补丁 的对应操作。那么除了以上三种操作之外,其实还有最后一种操作方式,那就是 移动。
对于以上的节点对比而言,如果我们想要把 旧节点转化为新节点 ,那么将要涉及到节点的 移动,所以问题的重点是:如何进行移动。
那么接下来,我们来分析一下移动的策略,整个移动根据递增子序列的不同,将拥有两种移动策略:
1、3、6
递增序列下:
- 因为
1、3、6
的递增已确认,所以它们三个是不需要移动的,那么我们所需要移动的节点无非就是 三 个2、4、5
。- 所以我们需要经过 三次 移动
1、2、4、6
递增序列下:
- 因为
1、2、4、6
的递增已确认,所以它们四个是不需要移动的,那么我们所需要移动的节点无非就是 两个3、5
。- 所以我们需要经过 两次 移动
由以上分析,我们可知:最长递增子序列的确定,可以帮助我们减少移动的次数
最长递增子序列求解算法实现:
scss
/**
* 获取最长递增子序列下标
* 维基百科:https://en.wikipedia.org/wiki/Longest_increasing_subsequence
* 百度百科:https://baike.baidu.com/item/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/22828111
*/
function getSequence(arr) {
// 获取一个数组浅拷贝。注意 p 的元素改变并不会影响 arr
// p 是一个最终的回溯数组,它会在最终的 result 回溯中被使用
// 它会在每次 result 发生变化时,记录 result 更新前最后一个索引的值
const p = arr.slice()
// 定义返回值(最长递增子序列下标),因为下标从 0 开始,所以它的初始值为 0
const result = [0]
let i, j, u, v, c
// 当前数组的长度
const len = arr.length
// 对数组中所有的元素进行 for 循环处理,i = 下标
for (i = 0; i < len; i++) {
// 根据下标获取当前对应元素
const arrI = arr[i]
//
if (arrI !== 0) {
// 获取 result 中的最后一个元素,即:当前 result 中保存的最大值的下标
j = result[result.length - 1]
// arr[j] = 当前 result 中所保存的最大值
// arrI = 当前值
// 如果 arr[j] < arrI 。那么就证明,当前存在更大的序列,那么该下标就需要被放入到 result 的最后位置
if (arr[j] < arrI) {
p[i] = j
// 把当前的下标 i 放入到 result 的最后位置
result.push(i)
continue
}
// 不满足 arr[j] < arrI 的条件,就证明目前 result 中的最后位置保存着更大的数值的下标。
// 但是这个下标并不一定是一个递增的序列,比如: [1, 3] 和 [1, 2]
// 所以我们还需要确定当前的序列是递增的。
// 计算方式就是通过:二分查找来进行的
// 初始下标
u = 0
// 最终下标
v = result.length - 1
// 只有初始下标 < 最终下标时才需要计算
while (u < v) {
// (u + v) 转化为 32 位 2 进制,右移 1 位 === 取中间位置(向下取整)例如:8 >> 1 = 4; 9 >> 1 = 4; 5 >> 1 = 2
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Right_shift
// c 表示中间位。即:初始下标 + 最终下标 / 2 (向下取整)
c = (u + v) >> 1
// 从 result 中根据 c(中间位),取出中间位的下标。
// 然后利用中间位的下标,从 arr 中取出对应的值。
// 即:arr[result[c]] = result 中间位的值
// 如果:result 中间位的值 < arrI,则 u(初始下标)= 中间位 + 1。即:从中间向右移动一位,作为初始下标。 (下次直接从中间开始,往后计算即可)
if (arr[result[c]] < arrI) {
u = c + 1
} else {
// 否则,则 v(最终下标) = 中间位。即:下次直接从 0 开始,计算到中间位置 即可。
v = c
}
}
// 最终,经过 while 的二分运算可以计算出:目标下标位 u
// 利用 u 从 result 中获取下标,然后拿到 arr 中对应的值:arr[result[u]]
// 如果:arr[result[u]] > arrI 的,则证明当前 result 中存在的下标 《不是》 递增序列,则需要进行替换
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1]
}
// 进行替换,替换为递增序列
result[u] = i
}
}
}
// 重新定义 u。此时:u = result 的长度
u = result.length
// 重新定义 v。此时 v = result 的最后一个元素
v = result[u - 1]
// 自后向前处理 result,利用 p 中所保存的索引值,进行最后的一次回溯
while (u-- > 0) {
result[u] = v
v = p[v]
}
return result
}
创建如下测试实例:
less
<script>
const { h, render } = Vue
const vnode = h('ul', [
h('li', {
key: 1
}, 'a'),
h('li', {
key: 2
}, 'b'),
h('li', {
key: 3
}, 'c'),
h('li', {
key: 4
}, 'd'),
h('li', {
key: 5
}, 'e')
])
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h('ul', [
h('li', {
key: 1
}, 'new-a'),
h('li', {
key: 3
}, 'new-c'),
h('li', {
key: 2
}, 'new-b'),
h('li', {
key: 6
}, 'new-f'),
h('li', {
key: 5
}, 'new-e'),
])
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
- 执行上述测试实例,首先自前向后对比,挂载
new-a
,退出循环;- 执行自后向前对比,挂载
new-e
,退出循环;- 新节点多于或少于旧节点情况都不是,此时就要处理乱序的
diff
对比;
注意:此时i=1,newChildrenEnd=3,oldChildrenEn=3
- 此时需要处理下标
1 - 3
的节点,也就是从i
开始到newChildrenEnd
结束
乱序diff
代码实现:
ini
else {
// 旧节点开始的索引 1
const oldStartIndex = i
// 新节点开始的索引 1
const newStartIndex = i
// 遍历新节点数组生成一个索引与值的映射表
/**
* [
* 3 => 1
2 => 2
6 => 3
* ]
*/
const keyToNewIndexMap = new Map()
for (i = newStartIndex; i <= newChildrenEnd; i++) {
// normalizeVNode 将数组中每个节点转成vnode
const nextChild = normalizeVNode(newChildren[i])
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
let j
// 已处理节点的数量
let patched = 0
// 待处理的节点数量 3 1
const toBePatched = newChildrenEnd - newStartIndex + 1
// 是否需要移动的节点
let moved = false
// 最大的新节点索引
let maxNewIndexSoFar = 0
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
// 遍历旧节点数组
for (i = oldStartIndex; i <= oldChildrenEnd; i++) {
const prevChild = oldChildren[i]
// 如果已经处理的节点大于等于待处理节点的数量 则卸载该节点 开始下次循环
if (patched >= toBePatched) {
unMount(prevChild)
continue
}
// 新节点索引值
let newIndex
// 如果当前旧节点的key不为空 则在事先处理好的映射表中获取新节点的索引值
if (prevChild.key != null) {
// 2
newIndex = keyToNewIndexMap.get(prevChild.key)
}
// 如果在映射表中没有找到 表明新节点没有该节点 则直接卸载该旧节点
if (newIndex === undefined) {
unMount(prevChild)
} else {
// [0,2,0] 存储新节点的下标
newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1
// 如果新节点的值不大于最大的新节点索引值 则代表此时不递增了 需要移动节点
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
patch(prevChild, newChildren[newIndex], container, null)
patched++
}
}
// 如果有节点需要移动 则获取最长递增子序列下标
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: []
j = increasingNewIndexSequence.length - 1
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = newStartIndex + i
const nextChild = newChildren[nextIndex]
const anchor =
nextIndex + 1 < newChildrenLength
? newChildren[nextIndex + 1].el
: parentAnchor
// 如果存储的新节点的下标为0 则代表新节点中无对应的旧节点 此时 需要直接挂载旧节点
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, anchor)
} else if (moved) {
// 移动节点
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor)
} else {
j--
}
}
}
}
javascript
//move 方法
function move(vnode, container, anchor) {
const { el } = vnode
container.insertBefore(el, anthor || null)
}