最小化更新updateChildren
基本概念
文档中所用的虚拟节点模型
// 老节点
const oldVnode = h('ul', {}, [
h('li', { key: 'A'}, '我是A'),
h('li', { key: 'B'}, '我是B'),
h('li', { key: 'C'}, '我是C'),
])
// 模型一
const newVnode = h('ul', {}, [
h('li', { key: 'A'}, '我是AAA'),
h('li', { key: 'B'}, '我是BBB'),
h('li', { key: 'M'}, '我是M'),
h('li', { key: 'N'}, '我是N'),
h('li', { key: 'C'}, '我是CCC'),
])
// 模型二
const newVnode = h('ul', {}, [
h('li', { key: 'B'}, '我是BBB'),
h('li', { key: 'M'}, '我是M'),
h('li', { key: 'N'}, '我是N'),
h('li', { key: 'C'}, '我是CCC'),
])
// 模型三
const newVnode = h('ul', {}, [
h('li', { key: 'B'}, '我是BBB'),
h('li', { key: 'M'}, '我是M'),
h('li', { key: 'N'}, '我是N'),
h('li', { key: 'C'}, '我是CCC'),
h('li', { key: 'A'}, '我是AAA'),
])
// 模型四
const newVnode = h('ul', {}, [
h('li', { key: 'C'}, '我是CCC'),
h('li', { key: 'A'}, '我是AAA'),
h('li', { key: 'B'}, '我是BBB'),
h('li', { key: 'M'}, '我是M'),
h('li', { key: 'N'}, '我是N'),
])
// 模型五
const newVnode = h('ul', {}, [
h('li', { key: 'A'}, '我是AAA'),
])
// 模型6
const newVnode = h('ul', {}, [
h('li', { key: 'B'}, '我是BBB'),
h('li', { key: 'M'}, '我是M'),
h('li', { key: 'A'}, '我是AAA'),
h('li', { key: 'C'}, '我是CCC'),
h('li', { key: 'N'}, '我是N'),
])
四种指针
- 新前:新前指针(newStartIdx),指向新的虚拟节点未处理的第一个节点,初始化为0
- 新后:新后指针(newEndIdx),指向新的虚拟节点未处理的最后一个节点,初始化为 newVnode.children.length - 1
- 旧前:旧前指针(oldStartIdx),指向旧的虚拟节点未处理的第一个节点, 初始化为0
- 旧后:旧后指针(oldEndIdx),指向旧的虚拟节点未处理的最后一个节点,初始化为oldVnode.children.length-1
四种命中
- 新前与旧前:新前指针指向的元素与旧前指针指向同一节点(同一节点定义请看第二篇),如模型一新前和旧前指针指向的都是
h('li', {key: 'A'}, '')
, 当命中时更新该节点,并将新前和旧前指针下移 - 新后与旧后:新后指针和旧后指针指向同一节点,如模型二第一次循环时新后与旧后都指向
h('li', { key: 'C'}, ''),
,当命中时更新该节点,并将新后和旧后指针上移 - 新后与旧前:新后指针和旧前指针指向同一节点,如模型三第一次循环时新后与旧前都指向
h('li', {key: 'A'},
,当命中该规则时,更新该元素,然后将新后指向的元素移动到旧后的后方,同时移动指针(新后指针上移,旧前指针下移) - 新前与旧后:新前指针和旧后指针指向同一节点,如模型四第一次循环时新后与旧前都指向
h('li', { key: 'C'}, ''),
,当命中该规则时,更新该元素,然后将新前指向元素移动到旧前的前方,同时移动指针(新前指针下移,旧后指针上移)
循环方式
循环目的:将旧的DOM树变更为新的Vnode能生成的DOM,新DOM时目的,旧DOM是基础。 为了节约性能,需找到对应节点更新、删除、新建、移动以实现最小化更新。
循环条件:新/旧虚拟节点未处理完毕,即newStartIdx<=newEndIdx && oldStartIdx <= oldEndIdx
循环方式:四种命中依次比较
举个例子:
以模型一为例
第一次循环,新前旧前都指向h('li', {key: 'A'}, '')
, 更新这个节点(patchVnode),及将key=A的文案变更为AAA,同时移动指针,即新前和旧前指针下移,指向h('li', { key: 'B'}, ''),
,进入第二次循环。
第二次循环,新前和旧前指针都指向h('li', { key: 'B'}, ''),
,更新节点,同时移动指针,即新前和旧前指针下移,旧前指向h('li', { key: 'C'}, ''),
,新前指向h('li', { key: 'M'}, ''),
,进入第三次循环。
第三次循环,新前与旧前指向节点不同,比较新后与旧后,发现都指向h('li', { key: 'C'}, '')
,更新该节点,同时移动指针,即新后与旧后指针上移。此时旧后指针为1,旧前指针为2,不满足循环条件,结束循环。结束循环发现新前指针为2,新后指针为3 , 新前指针不大于新后指针,此时新前与新后之间的节点及为新增节点,这些新增节点需新增在 h('li', { key: 'B'}, '我是CCC'),
前方,及新后节点后一个节点的前方。
以模型五为例:
模型五循环结束时,旧前<=旧后,旧前与旧后节点之间的节点都需要删除。
根据以上,我们可以得到基本的最小化更新函数:
import patchVnode from "./patchVnode"
import createElement from "./createElement"
// 判断是否是同一节点
function checkSameVnode(a, b) {
return a.sel === b.sel && a.key === b.key
}
export default function updateChildren(parentElm, oldCh, newCh) {
console.log('我是upadate')
// 旧前
let oldStartIdx = 0
// 新前
let newStartIdx = 0
// 旧后
let oldEndIdx = oldCh.length - 1
// 新后
let newEndIdx = newCh.length - 1
// 旧前节点
let oldStartVnode = oldCh[0]
// 旧后节点
let oldEndVnode = oldCh[oldEndIdx]
// 新前节点
let newStartVnode = newCh[0]
// 新后节点
let newEndVnode = newCh[newEndIdx]
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ① 新前与旧前
if(checkSameVnode(oldStartVnode, newStartVnode)) {
console.log('①')
// 更新节点
patchVnode(oldStartVnode, newStartVnode)
// 新前与旧前节点下移
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
// ② 新后与旧后
else if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('②')
// 更新节点
patchVnode(oldEndVnode, newEndVnode)
// 新后与旧后节点上移
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
// ③ 新后与旧前
else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('③')
// 更新节点
patchVnode(oldStartVnode, newEndVnode)
// 如果给定的子节点是对文档中现有节点的引用,insertBefore() 会将其从当前位置移动到新位置(在将节点附加到其他节点之前,不需要从其父节点删除该节点),若第二个参数为null,等同于appendChild()。
// 把新后指向的vnode移动到旧后节点的后方
parentElm.insertBefore(newEndVnode.elm, oldEndVnode.nextSibling?.elm)
// 新后指针上移,旧前指针下移
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
// ④ 新前与旧后
else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('④')
// 更新节点
patchVnode(oldEndVnode, newStartVnode)
// 将新前指向的结点插入到旧前指向的节点前方
parentElm.insertBefore(newStartVnode, oldStartVnode)
// 新前指针下移,旧后指针上移
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
// ⑤ 四种指针命中都未命中
else {
}
}
// 新增节点->新节点仍有剩余
if (newEndIdx >= newStartIdx) {
let before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
for (let i = newStartIdx; i < newEndIdx + 1; i++) {
parentElm.insertBefore(createElement(newCh[i]), before)
}
}
// 删除节点->老节点仍有剩余
if (oldEndIdx >= oldStartIdx) {
for (let i = oldStartIdx; i < oldEndIdx + 1; i++) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
四种命中都未命中
上文讲述了四种命中命中其一的处理场景,但是如模型6所示,我们会遇到四种命中都未命中的场景。针对这种场景,首先我们需要确定是否是新增节点,如果是就新增,如果不是就找到新前节点对应的旧节点并移动它到旧前节点的前方。
// key的MAP,降低循环的时间复杂度
let oldKeyToIdx
// 四种未命中时新节点在老节点中的位置
let idxInOld
// 四种未命中时新前节点需要移动到的位置
let elmToMove
// 构建第一次四个都未命中时未处理的老节点的key-index对象
// 经个人测试,如果存在的旧节点只是在头部复制了一份,由于缓存原因第二次进入会有bug,源码也有这个问题,但是缓存机制降低时间复杂度很牛逼,在这里保留,下方注释的方案可以解决这个bug
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = oldKeyToIdx[newCh[newStartIdx].key]
if (isUndef(idxInOld)) {
// 新前指向的节点在未处理的老节点当中没有,需要创建这个节点并插入到旧前指向节点的上方
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
// 将新前节点下移
newStartVnode = newCh[++newStartIdx]
} else if (isUndef(oldKeyToIdx[newCh[newEndIdx].key])) {
// 新后指向的节点在未处理的老节点当中没有,需要创建这个节点并插入到旧后节点的后方
parentElm.insertBefore(createElement(newEndVnode), oldEndVnode.elm.nextSibling)
// 将新后节点上移
newEndVnode = newCh[--newEndIdx]
} else {
// 新前和新后节点在老节点中存在,只是被移动了位子,不在旧前和旧后指针指向的位置
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
// 非同一节点,处理方式同新前指向节点在旧的虚拟节点中不存在
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
// 同一节点
patchVnode(elmToMove, newStartVnode)
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
// 防止vnode树与Dom不匹配,如相同位置仍存在相同的DOM,且置为undefined后循环遇到该虚拟节点可以跳过
oldCh[idxInOld] = undefined
}
// 新前指针下移
newStartVnode = newCh[++newStartIdx]
}
// 存储老虚拟节点未处理的节点key值的map
function createKeyToOldIdx(children, beginIdx, endIdx) {
const map = {}
for(let i = beginIdx; i < endIdx; i++) {
const chVnode = children[i]
const key = chVnode === null || chVnode === 0 ? 0 : chVnode.key
map[key] = i
}
return map
}
// 判断一个数据是否为undefined
function isUndef(data) {
return data === undefined
}
综合以上的信息,得到一份基本的最小化更新代码:
import patchVnode from "./patchVnode"
import createElement from "./createElement"
// 判断是否是同一节点
function checkSameVnode(a, b) {
return a.sel === b.sel && a.key === b.key
}
// 存储老虚拟节点未处理的节点key值的map
function createKeyToOldIdx(children, beginIdx, endIdx) {
const map = {}
for(let i = beginIdx; i < endIdx; i++) {
const chVnode = children[i]
const key = chVnode === null || chVnode === 0 ? 0 : chVnode.key
map[key] = i
}
return map
}
// 判断一个数据是否为undefined
function isUndef(data) {
return data === undefined
}
export default function updateChildren(parentElm, oldCh, newCh) {
console.log('我是upadate')
// 旧前
let oldStartIdx = 0
// 新前
let newStartIdx = 0
// 旧后
let oldEndIdx = oldCh.length - 1
// 新后
let newEndIdx = newCh.length - 1
// 旧前节点
let oldStartVnode = oldCh[0]
// 旧后节点
let oldEndVnode = oldCh[oldEndIdx]
// 新前节点
let newStartVnode = newCh[0]
// 新后节点
let newEndVnode = newCh[newEndIdx]
// key的MAP,降低循环的时间复杂度
let oldKeyToIdx
// 四种未命中时新节点在老节点中的位置
let idxInOld
// 四种未命中时新前节点需要移动到的位置
let elmToMove
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 移除该节点,不需要比较直接跳过
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
}
else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
}
else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
}
else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
}
// ① 新前与旧前
else if(checkSameVnode(oldStartVnode, newStartVnode)) {
// 比对同一节点
console.log('①')
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
// ② 新后与旧后
else if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('②')
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
// ③ 新后与旧前
else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('③')
patchVnode(oldStartVnode, newEndVnode)
// 把新后指向的vnode移动到旧后节点的后方
// 如果给定的子节点是对文档中现有节点的引用,insertBefore() 会将其从当前位置移动到新位置(在将节点附加到其他节点之前,不需要从其父节点删除该节点)。
parentElm.insertBefore(newEndVnode.elm, oldEndVnode.nextSibling?.elm)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
// ④ 新前与旧后
else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('④')
patchVnode(oldEndVnode, newStartVnode)
// 将新前指向的结点插入到旧前指向的节点前方
parentElm.insertBefore(newStartVnode, oldStartVnode)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
else {
console.log('⑤')
// 都未命中
// 构建第一次四个都未命中时未处理的老节点的key-index对象
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = oldKeyToIdx[newCh[newStartIdx].key]
if (isUndef(idxInOld)) {
// 新前指向的节点在未处理的老节点当中没有,需要创建这个节点并插入到旧前指向节点的上方
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
// 将新前节点下移
newStartVnode = newCh[++newStartIdx]
} else if (isUndef(oldKeyToIdx[newCh[newEndIdx].key])) {
// 新后指向的节点在未处理的老节点当中没有,需要创建这个节点并插入到旧后节点的后方
parentElm.insertBefore(createElement(newEndVnode), oldEndVnode.elm.nextSibling)
// 将新后节点上移
newEndVnode = newCh[--newEndIdx]
} else {
// 新前和新后节点在老节点中存在,只是被移动了位子,不在旧前和旧后指针指向的位置
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
// 非同一节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
// 同一节点
patchVnode(elmToMove, newStartVnode)
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
// 防止vnode树与Dom不匹配,如相同位置仍存在相同的DOM
oldCh[idxInOld] = undefined
}
// 新前指针下移
newStartVnode = newCh[++newStartIdx]
}
}
}
// 新增节点->新节点仍有剩余
if (newEndIdx >= newStartIdx) {
let before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
for (let i = newStartIdx; i < newEndIdx + 1; i++) {
parentElm.insertBefore(createElement(newCh[i]), before)
}
}
// 删除节点->老节点仍有剩余
if (oldEndIdx >= oldStartIdx) {
for (let i = oldStartIdx; i < oldEndIdx + 1; i++) {
parentElm.removeChild(oldCh[i].elm)
}
}
}