双端Diff算法
上一章我们介绍了简单Diff算法。利用虚拟节点的key属性,复用DOM元素,并通过移动DOM的方式来完成更新,从而减少不断地创建和销毁DOM元素带来的性能开销。但简单Diff算法还有很大的优化余地,本章要介绍的双端Diff算法来优化。
双端比较的原理
对比两组节点边缘位置的节点,找到可复用的节点,移动DOM 找到两组节点的边缘
function patchChildren(n1, n2, containe) {
if (typeof n2.children === 'string') {
// ..
} else if (Array.isArray(n2.children)) {
if (Array.isArray(n1.children)) {
patchKeyedChildren(n1, n2, container)
} else {
// ...
}
}
// ...
}
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
const oldStartIdx = 0
const oldEndIdx = oldChildren.length - 1
const newStartIdx = 0
const newEndIdx = newChildren.length - 1
const oldStartVNode = oldChildren[oldStartIdx]
const oldEndVNode = oldChildren[oldEndIdx]
const newStartVNode = newChildren[newStartIdx]
const newEndVNode = newChildren[newEndIdx]
}
对比两组节点边缘位置的节点,找可复用的节点。分为4个步骤
- 比较旧的一组子节点的第一个子节点(oldStartVNode)与新的一组子节点的第一个子节点(newStartVNode)
- 比较旧的一组子节点的最后一个子节点(oldEndVNode)与新的一组子节点的最后一个子节点(newEndVNode)
- 比较旧的一组子节点的第一个子节点(oldStartVNode)与新的一组子节点的最后一个子节点(newEndVNode)
- 比较旧的一组子节点的最后一个子节点(oldEndVNode)与新的一字子节点的第一个子节点(newStartVNode)
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
const oldStartIdx = 0
const oldEndIdx = oldChildren.length - 1
const newStartIdx = 0
const newEndIdx = newChildren.length - 1
const oldStartVNode = oldChildren[oldStartIdx]
const oldEndVNode = oldChildren[oldEndIdx]
const newStartVNode = newChildren[newStartIdx]
const newEndVNode = newChildren[newEndIdx]
if (oldStartVNode.key === newStartVNode.key) {
// 第一步...
} else if (oldEndVNode.key === newEndVNode.key) {
// 第二步...
} else if (oldStartVNode.key === newEndVNode.key) {
// 第三步...
} else if (oldEndVNode.key === newStartVNode.key) {
// 第四步...
}
}
结合示例,分析4个步骤的具体操作,
- 第一步:对比oldStartVNode、newStartVNode。两者key值相同,可以复用。不需要移动DOM的位置,但是需要更新。
let oldvnode = {
type: 'div',
children: [
{
type: 'div',
children: '1',
key: 1
},
{
type: 'div',
children: '2',
key: 2
},
{
type: 'div',
children: '3',
key: 3
}
]
}
let newvnode = {
type: 'div',
children: [
{
type: 'div',
children: '111',
key: 1
},
{
type: 'div',
children: '333',
key: 3
}
{
type: 'div',
children: '222',
key: 2
}
]
}
function patchKeyedChildren(n1, n2, container) {
// ...
if (oldStartVNode.key === newStartVNode.key) {
// 更新
patch(oldStartVNode, newStartVNode, container)
// 不需要移动
} else if (oldEndVNode.key === newEndVNode.key) {
} else if (oldStartVNode.key === newEndVNode.key) {
} else if (oldEndVNode.key === newStartVNode.key) {
}
}
- 第二步:对比oldEndVNode、newEndVNode。两者key值相同,可以复用。不需要移动DOM的位置,但是需要更新。
let oldvnode = {
type: 'div',
children: [
{
type: 'div',
children: '1',
key: 1
},
{
type: 'div',
children: '2',
key: 2
},
{
type: 'div',
children: '3',
key: 3
}
]
}
let newvnode = {
type: 'div',
children: [
{
type: 'div',
children: '222',
key: 2
},
{
type: 'div',
children: '111',
key: 1
},
{
type: 'div',
children: '333',
key: 3
}
]
}
function patchKeyedChildren(n1, n2, container) {
// ...
if (oldStartVNode.key === newStartVNode.key) {
// 更新
patch(oldStartVNode, newStartVNode, container)
// 不需要移动
} else if (oldEndVNode.key === newEndVNode.key) {
// 更新
patch(oldEndVNode, newEndVNode, container)
// 不需要移动
} else if (oldStartVNode.key === newEndVNode.key) {
} else if (oldEndVNode.key === newStartVNode.key) {
}
}
- 第三步:对比oldStartVNode、newEndVNode。两者key值相同,可以复用。需要更新,需要移动。
应该移动到哪个位置呢?
需要移动的DOM处于新的一组子节点的末尾,所以应该移动到旧的一组子节点最后一个子节点对应DOM的后面。
let oldvnode = {
type: 'div',
children: [
{
type: 'div',
children: '1',
key: 1
},
{
type: 'div',
children: '2',
key: 2
},
{
type: 'div',
children: '3',
key: 3
}
]
}
let newvnode = {
type: 'div',
children: [
{
type: 'div',
children: '222',
key: 2
},
{
type: 'div',
children: '333',
key: 3
},
{
type: 'div',
children: '111',
key: 1
}
]
}
function patchKeyedChildren(n1, n2, container) {
// ...
if (oldStartVNode.key === newStartVNode.key) {
// 更新
patch(oldStartVNode, newStartVNode, container)
// 不需要移动
} else if (oldEndVNode.key === newEndVNode.key) {
// 更新
patch(oldEndVNode, newEndVNode, container)
// 不需要移动
} else if (oldStartVNode.key === newEndVNode.key) {
// 更新
patch(oldStartVNode, newEndVNode, container)
// 移动DOM
// 锚点
const anchor = newEndVNode.el.nextSibling
insert(oldStartVNode.el, container, anchor)
} else if (oldEndVNode.key === newStartVNode.key) {
}
}
- 第四步:对比oldEndVNode、newStartVNode。两者key值相同,可以复用。需要更新,需要移动。
同样需要考虑应该移动到哪里?
需要移动的DOM处于新的一组子节点的开头,所以应该移动到旧的一组子节点第一个子节点对应DOM的前面。
let oldvnode = {
type: 'div',
children: [
{
type: 'div',
children: '1',
key: 1
},
{
type: 'div',
children: '2',
key: 2
},
{
type: 'div',
children: '3',
key: 3
}
]
}
let newvnode = {
type: 'div',
children: [
{
type: 'div',
children: '333',
key: 3
},
{
type: 'div',
children: '111',
key: 1
},
{
type: 'div',
children: '222',
key: 2
},
]
}
function patchKeyedChildren(n1, n2, container) {
// ...
if (oldStartVNode.key === newStartVNode.key) {
// 更新
patch(oldStartVNode, newStartVNode, container)
// 不需要移动
} else if (oldEndVNode.key === newEndVNode.key) {
// 更新
patch(oldEndVNode, newEndVNode, container)
// 不需要移动
} else if (oldStartVNode.key === newEndVNode.key) {
// 更新
patch(oldStartVNode, newEndVNode, container)
// 移动DOM
// 锚点
const anchor = newEndVNode.el.nextSibling
insert(oldStartVNode.el, container, anchor)
} else if (oldEndVNode.key === newStartVNode.key) {
// 更新
patch(oldEndVNode, newStartVNode, container)
// 移动DOM
// 锚点
const anchor = oldStartVNode.el
insert(oldEndVNode.el, container, anchor)
}
}
经过上面的if...else...处理后,与新的一组子节点的顺序并不一致,这是因为Diff算法还没有结束,还需要进行下一轮的更新。因此,我们需要将更新逻辑封装到一个while循环中。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 更新
patch(oldStartVNode, newStartVNode, container)
// 不需要移动
// 更新索引
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (oldEndVNode.key === newEndVNode.key) {
// 更新
patch(oldEndVNode, newEndVNode, container)
// 不需要移动
// 更新索引
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
// 更新
patch(oldStartVNode, newEndVNode, container)
// 移动DOM
// 锚点
const anchor = newEndVNode.el.nextSibling
insert(oldStartVNode.el, container, anchor)
// 更新索引
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
// 更新
patch(oldEndVNode, newStartVNode, container)
// 移动DOM
// 锚点
const anchor = oldStartVNode.el
insert(oldEndVNode.el, container, anchor)
// 更新索引
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
}
}
非理想状况的处理
上面一节中,处理的是理想情况,4步中总能命中一步。但实际上,并非都是这样的理性。看下面的例子,
let oldVNode = {
type: 'div',
children: [
{
type: 'p',
children: '1',
key: 1,
},
{
type: 'p',
children: '2',
key: 2,
},
{
type: 'p',
children: '3',
key: 3,
},
{
type: 'p',
children: '4',
key: 4,
},
],
}
let newVNode = {
type: 'div',
children: [
{
type: 'p',
children: '2',
key: 2,
},
{
type: 'p',
children: '4',
key: 4,
},
{
type: 'p',
children: '1',
key: 1,
},
{
type: 'p',
children: '3',
key: 3,
},
],
}
我们会发现4个步骤的条件,都不满足。这时,我们只能增加额外的步骤来处理这种非理想情况。我们拿新的一组子节点的头部去旧的一组子节点中找,看是否有可复用的节点,找到可复用的节点,则去更新,去移动。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 更新
patch(oldStartVNode, newStartVNode, container)
// 不需要移动
// 更新索引
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (oldEndVNode.key === newEndVNode.key) {
// 更新
patch(oldEndVNode, newEndVNode, container)
// 不需要移动
// 更新索引
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
// 更新
patch(oldStartVNode, newEndVNode, container)
// 移动DOM
// 锚点
const anchor = newEndVNode.el.nextSibling
insert(oldStartVNode.el, container, anchor)
// 更新索引
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
// 更新
patch(oldEndVNode, newStartVNode, container)
// 移动DOM
// 锚点
const anchor = oldStartVNode.el
insert(oldEndVNode.el, container, anchor)
// 更新索引
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
} else {
// 处理非理想情况
const idxInOld = oldChildren.findIndex(vnode => oldStartVNode.key === vnode.key)
if (idxInOld > 0) {
// 找到新一组子节点第一个节点在旧一组子节点非边缘位置可复用节点
const vnodeToMove = oldChildren[idxInOld]
// 更新
patch(vnodeToMove, oldStartVNode, container)
// 移动DOM
// 锚点
const anchor = oldStartVNode.el
insert(vnodeToMove.el, container, anchor)
// 更新索引
newStartVNode = newChildren[++newStartIdx]
// TODO:这里为什么要把旧的一组子节点idxInOld索引处节点置为undefined?
oldChildren[idxInOld] = undefined
}
}
}
添加新元素
上一节处理非理想情况,我们在旧的一组子节点中找到了可复用的节点。下面我们来看非理想情况下,找不到可复用节点的情况。
let oldVNode = {
type: 'div',
children: [
{
type: 'p',
children: '1',
key: 1,
},
{
type: 'p',
children: '2',
key: 2,
},
{
type: 'p',
children: '3',
key: 3,
},
],
}
let newVNode = {
type: 'div',
children: [
{
type: 'p',
children: '4',
key: 4,
},
{
type: 'p',
children: '1',
key: 1,
},
{
type: 'p',
children: '3',
key: 3,
},
{
type: 'p',
children: '2',
key: 2,
},
],
}
新的一组子节点的第一个子节点在旧的一组子节点找不到可复用的节点,则需要挂载。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 更新
patch(oldStartVNode, newStartVNode, container)
// 不需要移动
// 更新索引
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (oldEndVNode.key === newEndVNode.key) {
// 更新
patch(oldEndVNode, newEndVNode, container)
// 不需要移动
// 更新索引
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
// 更新
patch(oldStartVNode, newEndVNode, container)
// 移动DOM
// 锚点
const anchor = newEndVNode.el.nextSibling
insert(oldStartVNode.el, container, anchor)
// 更新索引
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
// 更新
patch(oldEndVNode, newStartVNode, container)
// 移动DOM
// 锚点
const anchor = oldStartVNode.el
insert(oldEndVNode.el, container, anchor)
// 更新索引
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
} else {
// 处理非理想情况
const idxInOld = oldChildren.findIndex(vnode => oldStartVNode.key === vnode.key)
if (idxInOld > 0) {
// 找到新一组子节点第一个节点在旧一组子节点非边缘位置可复用节点
const vnodeToMove = oldChildren[idxInOld]
// 更新
patch(vnodeToMove, oldStartVNode, container)
// 移动DOM
// 锚点
const anchor = oldStartVNode.el
insert(vnodeToMove.el, container, anchor)
// 更新索引
newStartVNode = newChildren[++newStartIdx]
// TODO:这里为什么要把旧的一组子节点idxInOld索引处节点置为undefined?
oldChildren[idxInOld] = undefined
} else {
// 没找到,则需要挂载
const anchor = oldStartVNode.el
patch(null, newStartVNode, container, anchor)
// 更新索引
newStartVNode = newChildren[++newStartIdx]
}
}
}
再来看另一种情况
let oldVNode = {
type: 'div',
children: [
{
type: 'p',
children: '1',
key: 1,
},
{
type: 'p',
children: '2',
key: 2,
},
{
type: 'p',
children: '3',
key: 3,
},
],
}
let newVNode = {
type: 'div',
children: [
{
type: 'p',
children: '4',
key: 4,
},
{
type: 'p',
children: '5',
key: 5,
},
{
type: 'p',
children: '1',
key: 1,
},
{
type: 'p',
children: '2',
key: 2,
},
{
type: 'p',
children: '3',
key: 3,
},
],
}
会发现,while循环结束后,p-4、p-5节点被遗漏了。需要额外的逻辑处理来挂载新的一组子节点中被遗漏了节点。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
}
if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
for (let i = newStartIdx; i <= newEndIdx; i++) {
// 原书中这句代码有bug,
// patch(null, newChildren[i], container, oldStartVNode.el)
const anchor = !newChildren[newEndIdx + 1] ? null : newChildren[newEndIdx + 1].el
patch(null, newChildren[i], container, anchor)
}
}
移除不存在的元素
看下面的例子,
let oldVNode = {
type: 'div',
children: [
{
type: 'p',
children: '1',
key: 1,
},
{
type: 'p',
children: '2',
key: 2,
},
{
type: 'p',
children: '3',
key: 3,
},
],
}
let newVNode = {
type: 'div',
children: [
{
type: 'p',
children: '1',
key: 1,
},
{
type: 'p',
children: '2',
key: 2,
}
],
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
}
if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
// ...
} else if (newStartIdx > newEndIdx && oldStartIdx <= oldEndIdx) {
// 卸载
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
unmount(oldChildren[i])
}
}