虚拟 dom 之移动优化 (opens new window) 中介绍了虚拟 dom
的双端 diff
的算法,但是没有考虑当虚拟 dom
增加或者减少的情况,这篇文章介绍增删 dom
在各个场景下的的代码完善。
# 循环未找到
虚拟 dom 之移动优化 (opens new window) 中我们进行了头头、尾尾、头尾、尾头的比较后如果没找到对应 Vnode
,就开始通过循环查找,但如果是下边的情况:
此时头头、尾尾、头尾、尾头都没有匹配到,所以开始遍历 oldVnode
寻找 newStartIdx
对应的 e
节点,此时 oldVnode
遍历完肯定也找不到 e
节点。
这种情况,我们就需要创建一个 dom
,然后插到 oldStartIdx
指向的 a
节点的前边。
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(
newStartVnode,
oldCh,
oldStartIdx,
oldEndIdx
);
if (isUndef(idxInOld)) { // 没有找到对应的节点
// New element
createElm(newStartVnode, parentElm, oldStartVnode.elm);
} else {
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
...
} else { // 找到了可能 key 相同,但是 tag 名发生了变化
// same key but different element. treat as new element
createElm(newStartVnode, parentElm, oldStartVnode.elm);
}
}
除了上边图中情况,代码中还补充了一种情况。
因为我们找 oldVnode
的时候是通过 key
得到的,所以还有可能找到了相同 key
的 vnode
,但 tag
可能发生了变化,此时也需要创建节点进行插入。
# children 内增加节点
对于上边的情况,头头匹配,然后指针后移:
头头依旧匹配,继续后移:
头头还是匹配,继续后移:
此时 oldStartIdx
大于了 oldEndIdx
结束了 while
循环:
while (oldStartIdx <= oldEndIdx) {
...
}
当前情况下 newVnode
就可能有新增节点,我们调用 addNode
来增加。
if (oldStartIdx > oldEndIdx) {
addVnodes(parentElm, null, newCh, newStartIdx, newEndIdx);
}
将 newCh
的 newStartIdx
到 newEndIdx
的节点加入到 parentElm
末尾即可。第二个参数表示插入到谁的前边,传 null
表示插入到末尾。
addVnodes
具体代码如下:
function addVnodes(parentElm, refElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
createElm(vnodes[startIdx], parentElm, refElm);
}
}
function createElm(vnode, parentElm, refElm) {
const data = vnode.data; // dom 相关的属性都放到 data 中
const children = vnode.children;
const tag = vnode.tag;
if (isDef(tag)) {
vnode.elm = nodeOps.createElement(tag);
createChildren(vnode, children);
if (isDef(data)) {
invokeCreateHooks(vnode);
}
insert(parentElm, vnode.elm, refElm);
} else {
vnode.elm = nodeOps.createTextNode(vnode.text);
insert(parentElm, vnode.elm, refElm);
}
}
function insert(parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
if (nodeOps.parentNode(ref) === parent) {
nodeOps.insertBefore(parent, elm, ref);
}
} else {
nodeOps.appendChild(parent, elm);
}
}
}
上边是在尾部新增了节点,当然还可能在头部增加了节点,如下所示:
上边就会一直尾尾匹配,c
和 c
匹配,b
和 b
匹配,a
和 a
匹配,end
指针一直前移最终达到后边的状态:
此时又出现了 oldStartIdx
大于 oldEndIdx
的情况,因此会终结 while
循环:
while (oldStartIdx <= oldEndIdx) {
...
}
同样,我们需要将新增节点 d、e
插入,需要插入到 a
的前边,因此我们需要通过 newEndIdx
得到 a
节点作为参考节点传入。
if (oldStartIdx > oldEndIdx) {
refElm = newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
}
上边两种情况结合起来如下:
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1])
? null
: newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
}
# children 内减少节点
此时头头匹配,指针后移:
头头匹配,指针继续后移:
此时 oldStartIdx
和 oldEndIdx
相等, 因此 while
循环不会结束。但 newStartIdx
已经大于了 newEndIdx
,代表 newVnode
已经遍历结束,此时应该结束循环了。
所以 while
循环加一个条件:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
...
}
我们判断如果 newStartIdx
大于 newEndIdx
了,就去遍历 oldVnode
来删除节点。
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1])
? null
: newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) { // 尝试删除节点
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
function removeVnodes(vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch);
} else {
// Text node
removeNode(ch.elm);
}
}
}
}
function removeAndInvokeRemoveHook(vnode, rm) {
removeNode(vnode.elm);
}
function removeNode(el) {
const parent = nodeOps.parentNode(el);
// element may have already been removed due to v-html / v-text
if (isDef(parent)) {
nodeOps.removeChild(parent, el);
}
}
上边仅仅是简单的删除 dom
,第一篇 虚拟dom (opens new window) 我们已经介绍过 removeVnodes
了,Vue
源码中 removeAndInvokeRemoveHook
方法还会调用相应的 hook
,这里就省略了。
# 增加整个 children
除了 children
中的节点有增减,还可能增加了整个 children
,如下图:
如果旧 vnode
中的 children
不存在,新的 children
存在,我们调用 addVnode
添加节点即可。
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) {
return;
}
...
if (isUndef(vnode.text)) { // 非 text 节点或者 text 节点为空字符串
if (isDef(oldCh) && isDef(ch)) { // 新旧 children 都存在
if (oldCh !== ch) updateChildren(elm, oldCh, ch); // 进行 diff 算法
} else if (isDef(ch)) { // 新 children 存在,旧 children 不存在,本次新增情况
addVnodes(elm, null, ch, 0, ch.length - 1); // 添加新 children
}
}
}
测试程序构造如下:
data: {
list: null,
},
render(createElement) {
const vnode = createElement(
"div",
{
on: {
click: () => {
console.log(1);
this.list = ["a", "b"];
},
},
},
[
createElement(
"div",
{
key: "1",
},
this.list
),
"123",
]
);
return vnode;
},
一开始 list
为 null
,点击后变为两个 text
节点 ab
。
# 减少整个 children
如上图所示,div
的 children
被整个移除。
对于这种情况我们直接调用删除节点方法即可。
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch);
} else if (isDef(ch)) {
// if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch.length - 1);
} else if (isDef(oldCh)) { // old Children 存在,new Children 不存在
removeVnodes(oldCh, 0, oldCh.length - 1);
}
}
对应的测试程序如下:
data: {
list: ["a", "b"],
},
render(createElement) {
const vnode = createElement(
"div",
{
on: {
click: () => {
console.log(1);
this.list = null;
},
},
},
[
createElement(
"div",
{
key: "1",
},
this.list
),
"123",
]
);
return vnode;
},
# 完整代码
patchVnode
判断新旧两个 node
的情况:
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) {
return;
}
const elm = (vnode.elm = oldVnode.elm);
const oldCh = oldVnode.children;
const ch = vnode.children;
const data = vnode.data;
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
}
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch); // 进行 diff 更新
} else if (isDef(ch)) {
addVnodes(elm, null, ch, 0, ch.length - 1); // 增加整个 children
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1); // 减少整个 children
}
} else if (oldVnode.text !== vnode.text) { // 前后都是 text 节点,更新 text
nodeOps.setTextContent(elm, vnode.text);
}
}
updateChildren
,也就是核心 diff
算法:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx, idxInOld, vnodeToMove, refElm;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // 已经被置为 undefined 跳过
} else if (isUndef(oldEndVnode)) {
// 已经被置为 undefined 跳过
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode);
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode);
nodeOps.insertBefore(
parentElm,
oldEndVnode.elm,
oldStartVnode.elm
);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(
oldCh,
oldStartIdx,
oldEndIdx
);
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(
newStartVnode,
oldCh,
oldStartIdx,
oldEndIdx
);
if (isUndef(idxInOld)) {
// New element
createElm(newStartVnode, parentElm, oldStartVnode.elm);
} else {
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode);
oldCh[idxInOld] = undefined;
nodeOps.insertBefore(
parentElm,
vnodeToMove.elm,
oldStartVnode.elm
);
} else {
// same key but different element. treat as new element
createElm(newStartVnode, parentElm, oldStartVnode.elm);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
// children 内增删节点
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1])
? null
: newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
}
# 总
结合前边的 虚拟dom之移动 (opens new window)、虚拟dom之移动优化 (opens new window) 就是 Vue2
中核心的 虚拟 dom diff
算法了。核心思想就是为了提高 dom
的复用、减少 dom
的操作,在 Vue3
中 diff
算法再次进行了优化,感兴趣的同学可以去了解一下。
# 讨论
patchVnode
有两种情况没有介绍到:
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) {
return;
}
const elm = (vnode.elm = oldVnode.elm);
const oldCh = oldVnode.children;
const ch = vnode.children;
const data = vnode.data;
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
}
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch);
} else if (isDef(ch)) {
/*****************************************************************/
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, ""); // 源码中有但没有想到对应的例子
/*****************************************************************/
addVnodes(elm, null, ch, 0, ch.length - 1);
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1);
/*****************************************************************/
} else if (isDef(oldVnode.text)) { // 源码中有但没有想到对应的例子
nodeOps.setTextContent(elm, "");
}
/*****************************************************************/
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text);
}
}
上边的两种情况,没有想到 render
函数返回什么样 vnode
可以走到上边的逻辑,想到例子的同学欢迎和我交流。