持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第 19 天,点击查看活动详情
15. vue2源码学习 (15) 虚拟DOM-5.patchVnode
start
- 上一节介绍的
patch
中有销毁旧节点,有直接创建元素。 - 但是对相同的节点,会执行
patchVnode
。整个patchVnode
就是 diff算法的核心了。 - 今天就来研究一下这
patchVnode
。
patchVnode
简化后的patchVnode
// \src\core\vdom\patch.js
// 两个节点 值得比较,则我们开始比较。
function patchVnode(
oldVnode, // 旧节点
vnode, // 新节点
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
// 两个节点完全相同,直接 return
if (oldVnode === vnode) {
return;
}
// 更新节点的 elm 属性
const elm = (vnode.elm = oldVnode.elm);
// 1. 存储旧的子节点
const oldCh = oldVnode.children;
// 2. 存储新的子节点
const ch = vnode.children;
// 新节点存在文本
if (isUndef(vnode.text)) {
// 旧子节点存在 新子节点存在
if (isDef(oldCh) && isDef(ch)) {
// 3. 旧子节点 !== 新子节 开始更新子节点 ()
if (oldCh !== ch)
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
} else if (isDef(ch)) {
// 4 .旧子节点不存在 新子节点存在 (前一步已经判断了两者是否同事存在)
// 清空旧的节点的文本
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, "");
// 添加新节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 5. 旧子节点存在,新子节点不存在,删除旧节点
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);
}
}
整理一下patchVnode
的逻辑。能到 patchVnode
,说明 vnode 是相同的。
patchVnode
主要逻辑是更新节点属性 、更新节点文本、更新节点的子节点
子节点更新
首先分别获取到新旧子节点
。
- 旧子节点存在,新子节点存在,且两者不同,
updateChildren
。
都存在新旧节点,就需要深入对比了。
如果两者相同,不需要深入对比,所以不做任何处理。
- 旧子节点不存在,新子节点存在, 创建新的节点。
这里会创建新节点。
也会
- 旧子节点存在,新子节点不存在,删除旧节点。
- 两个子节点都不存在,只需要处理节点的文本。
首先会走上述的四条分支,新旧子节点其中有一项不存在的情况,比较好处理,直接全量替换即可。但是针对新旧子节点都存在的情况,需要额外的比较
updateChildren
updateChildren
的主干逻辑
这里的逻辑直接看源码会有一点绕,也是我觉得很有意思的地方。
其实核心需求是这样的,我简化一下,做一下说明。
模拟目标
需求,有两个数组,两个数组里面存储多个对象,快速对比两个数组,相同swqswq
模拟数据
var newArr = [
{ key: 1, tag: 'div' }, // 新前
{ key: 2, tag: 'div' },
{ key: 3, tag: 'div' },
{ key: 4, tag: 'div' }, // 新后
]
var old = [
{ key: 3, tag: 'div' }, // 旧前
{ key: 2, tag: 'h1' },
{ key: 4, tag: 'div' },
{ key: 1, tag: 'div' }, // 旧后
]
为了高效的对比,使用了双指针的思路
新的子节点, 最前面的项简称:新前s
,最后面的项简称:新后e
;
旧的子节点, 最前面的项简称:旧前oldS
,最后面的项简称:旧后oldE
;
上述的四个名词,简单来说,就是四个变量,别存储着对应的索引,方便后续用来对比。
s
是 start(开始)的简写;e
是 end(结束)的简写;
具体的对比步骤
- 旧前
=>
新前 - 旧后
=>
新后 - 旧前
=>
新后 - 旧后
=>
新前 - 如果以上都匹配不到,再以新
vnode
(新前)为准,依次遍历老节点。- 找到相同的节点调用
patchVnode
; - 没有相同的节点,直接创建新的元素;
- 找到相同的节点调用
为什么要声明四个变量,弄这么麻烦,直接拿到新数组的第一项,和旧数组的每一项去对比不好吗?
因为在使用Vue的场景中:在开头或结尾插入内容;单纯的修改某一项;这些场景可能出现的频率很高。加入了
1-4
的步骤可以避免重复遍历,对性能提升很大。
简易版实现
var newArr = [
{ key: 1, tag: 'div' },
{ key: 2, tag: 'div' },
{ key: 3, tag: 'div' },
{ key: 4, tag: 'div' },
]
var oldArr = [
{ key: 3, tag: 'div' },
{ key: 2, tag: 'h1' },
{ key: 4, tag: 'div' },
{ key: 1, tag: 'div' },
]
/* 1. 存储索引 */
// 新前的索引
var s = 0
// 新后的索引
var e = newArr.length - 1
// 旧前的索引
var oldS = 0
// 旧后的索引
var oldE = oldArr.length - 1
/* 2.存储对应的对象 */
// 新前对应的对象
var sNode = newArr[0]
// 新后对应的对象
var eNode = newArr[e]
// 旧前对应的对象
var oldSNode = oldArr[0]
// 旧后对应的对象
var oldENode = oldArr[oldE]
/* 2. 简易版的节点对比 */
function sameVnode(a, b) {
return a.key === b.key && a.tag === b.tag && a.isComment === b.isComment
}
/* 3.多次对比,肯定是需要循环的,但是循环如何设计? */
/* 4. 使用 while条件语句, 只要:旧前小于等于旧后,新前小于等于新后。 (从两端向中间遍历) */
while (oldS <= oldS && s <= e) {
/*
1. 旧前 `=>` 新前
2. 旧后 `=>` 新后
3. 旧前 `=>` 新后
4. 旧后 `=>` 新前
*/
// 简易版本的 sameVnode
// 4.1 旧前 `=>` 新前
if (sameVnode(oldSNode, sNode)) {
// patchVnode
// ++oldS, ++s (为什么要加加?因为两端的索引需要向中间移动,当两端的索引重合,结束while遍历)
// 更新 oldSNode, sNode
} else if (sameVnode(oldENode, eNode)) {
// 4.2 旧后 `=>` 新后
// patchVnode
// --oldE, --e
// 更新 oldENode, eNode
} else if (sameVnode(oldSNode, eNode)) {
// 4.3 旧前 `=>` 新后
// patchVnode
// --oldS, --e
// insertBefore
// 更新 oldSNode, eNode
} else if (sameVnode(oldENode, sNode)) {
// 4.4 旧后 `=>` 新前
// patchVnode
// --oldE, ++s
// insertBefore
// 更新 oldENode, sNode
} else {
// 4.5 新节点和所有的子节点进行对比
}
}
updateChildren
源码
// diff算法的核心逻辑
function updateChildren(
parentElm,
oldCh,
newCh,
insertedVnodeQueue,
removeOnly
) {
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;
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
// removeOnly是一个特殊的标志,只能被 <transition-group>;
// 确保被删除的元素保持在正确的相对位置
// 在离开过渡时
const canMove = !removeOnly;
if (process.env.NODE_ENV !== "production") {
checkDuplicateKeys(newCh);
}
// 循环数组,这里规定退出条件
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 旧前 为空,修改索引
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
// 旧后 为空
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 节点相同
//1. 旧前 新前
patchVnode(
oldStartVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
// ++a 表示先增加a,再使用a; a++标识先使用a,在增加a
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
//2. 旧后 新后
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(
oldEndVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
//3. 旧前 新后
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(
oldStartVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
//4. 旧后 新前
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(
oldEndVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 5.其他情况
// ?
if (isUndef(oldKeyToIdx))
// 旧前 旧后 组成的 "节点的key":"节点的索引"
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
// 新节点是否有key,
// 有key就去oldKeyToIdx中寻找,
// 没有key从旧前到旧后,开始遍历,依次拿 每一项旧节点和新节点对比,对比成功直接返回对应的索引
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
if (isUndef(idxInOld)) {
// 5.1 没有索引,说明是全新的节点,直接创建新的元素
// New element
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
} else {
// 5.2 有key,且相同元素
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(
vnodeToMove,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldCh[idxInOld] = undefined;
canMove &&
nodeOps.insertBefore(
parentElm,
vnodeToMove.elm,
oldStartVnode.elm
);
} else {
// 有key,不相同元素,视为新元素。
// same key but different element. treat as new element
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
// 旧的先diff完, 剩下的就是:新项,添加到真实 dom 中
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
refElm,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
// 新的先diff完毕,旧的全部删除
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
}
可以看到updateChildren
源码,主干逻辑和我们 简易版实现
很相同。
注意,他这里会递归的调用 patchVnode
。
其他注意事项
patchVnode
主要逻辑是更新节点属性 、更新节点文本、更新节点的子节点;遍历的思路,双指针的方式。两端的指针向中间移动。
对比的顺序
旧前+新前
,旧后+新后
,旧前+新后
,旧后+新前
我们在使用 v-for的时候,为什么一直强调需要加key?
- 在对比的时候,相同key,可以快速对比。
为什么不建议使用v-for的索引(index)当做key呢?
- 因为当在开头差入数据的时候,key会因为index的变化而变化,所有的节点都会重新渲染,达不到复用的目的。
end
到这里 虚拟DOM相关的主线逻辑都梳理完毕了