减少DOM操作的性能开销
假设有以下新旧虚拟节点
//oldVNode
const oldVNode = {
type: "div",
children: [
{ type: "p", children: "1" },
{ type: "p", children: "2" },
{ type: "p", children: "3" },
],
};
//newVNode
const newVNode = {
type: "div",
children: [
{ type: "p", children: "4" },
{ type: "p", children: "5" },
{ type: "p", children: "6" },
],
};
若卸载全部 DOM之后再重新挂载 DOM ,一共需要6次DOM操作,若只更新 p 标签的文本节点内容,DOM操作次数就可以减半到3次
// 对比新旧vnode的children数组,再调用函数逐个对比新旧vnode的children
function patchChildren(n1, n2, container) {
if (typeof n2.children === "string") {
// ...
} else if (Array.isArray(n2.children)) {
//实现两组子节点的更新方式
// 新旧 children
const oldChildren = n1.children;
const newChildren = n2.children;
// 遍历旧的 children
for (let i = 0; i < oldChildren.length; i++) {
// 调用patch 函数逐个更新子节点
patch(oldChildren[i],newChildren[i])
}
}else{
// ...
}
}
//新旧节点个数不同,则对应挂载、卸载。我们不应该总是遍历旧的子节点,而应该遍历长度较短的那一组
//修改实现如下:(注释下为修改部分)
function patchChildren(n1, n2, container) {
if (typeof n2.children === "string") {
// ...
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children;
const newChildren = n2.children;
// 新旧两组子节点的长度
const oldLen = oldChildren.length;
const newLen = newChildren.length;
// 取出较短那一组
const commonLength = Math.min(oldLen, newLen);
// 遍历 commonLength 次
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i], container);
}
// 如果 newLen > oldLen 说明新的子节点需要挂载
// 如果 newLen < oldLen 说明旧的子节点需要卸载
if (newLen > oldLen) {
for (let i = commonLength; i < newLen; i++) {
patch(null, newChildren[i], container);
}
} else if (newLen < oldLen) {
for (let i = commonLength; i < oldLen; i++) {
unmount(newChildren[i]);
}
}
} else {
// ...
}
}
DOM复用与 key 的作用
上一节中代码考虑了两组子节点的数量关系,但是对于仅仅顺序改变的节点diff却完全没有生效
// oldChildren
const oldVNode = {
type: "div",
children: [
{ type: "p", children: "1" },
{ type: "div", children: "2" },
{ type: "span", children: "3" },
],
};
// newChildren
const newVNode = {
type: "div",
children: [
{ type: "div", children: "2" },
{ type: "span", children: "3" },
{ type: "p", children: "1" },
],
};
// 调用patch函数在旧子节点 { type: "p", children: "1" },于新子节点 { type: "div", children: "2" },
// 之间补丁,由于两者之间是不同的标签,所以会卸载旧节点挂载新节点,才有上一节的算法会产生6次DOM操作
// 最优的处理方法是通过移动来完成子节点的更新,但是这就需要保证一个前提:新旧两组子节点中存在可复用的节点
// 对子节点移动,为了保证识别子节点,需要引入一个key值来作为唯一标识
// 增加了key属性
const oldVNode = {
type: "div",
children: [
{ type: "p", children: "1", key: 1 },
{ type: "div", children: "2", key: 2 },
{ type: "span", children: "3", key: 3 },
],
};
const newVNode = {
type: "div",
children: [
{ type: "div", children: "222", key: 2 },
{ type: "span", children: "333", key: 3 },
{ type: "p", children: "111", key: 1 },
],
};
// 两个虚拟节点拥有相同的key值和 vnode.type 属性值。这意味着可以通过移动来完成更新,但仍需要对这两个节点进行打补丁的操作,因为他们的内容已经改变了。
// 在讨论如何移动DOM之前,我们需要先完成打补丁的操作
function patchChildren(n1, n2, container) {
if (typeof n2.children === "string") {
// ...
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children;
const newChildren = n2.children;
const oldLen = oldChildren.length;
const newLen = newChildren.length;
// 遍历新的 children
for (let i = 0; i < newLen; i++) {
const newVNode = newChildren[i];
// 遍历旧的 children
for (let j = 0; j < oldLen; j++) {
const oldVNode = oldChildren[j];
// 寻找相同key,若有则可复用,但仍需调用patch函数更新
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container);
break;
}
}
patch(oldChildren[i], newChildren[i], container);
}
} else {
// ...
}
}
// 首次挂载
renderer.render(oldVNode, document.querySelector("#app"));
setTimeout(() => {
// 1 秒钟后更新
renderer.render(newVNode, document.querySelector("#app"));
}, 1000);
3. 找到需要移动的元素
第一步:取新子节点的第一个节点p-3,key值为3,在旧数子节点中可以找到相同key值的可复用节点,在旧子节点数组中索引为2
第二步:取p-1,key为1,对应旧节点索引为0,小于2
在这一步,索引的递增顺序被打破:p-1对应的真实DOM需要移动
第三步:取p-2,key为2,对应旧节点索引为1,小于2;p-2对应的真实DOM也需要移动
将p-3在旧children中的索引定义为:在旧children中寻找相同key值节点的过程中,遇到的最大索引值。如果在后续寻找过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动。
使用 lastIndex 变量存储整个寻找过程中遇到的最大索引值
function patchChildren (n1, n2,container) {
if (typeof n2.children === 'string') {
// ...
}
else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
// 用来存储寻找过程中遇到的最大索引值
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i];
for (let j = 0; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j];
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 如果当前找到的节点在旧 children 中的索引小于最大索引值 lastIndex
// 说明该节点对应的真实 DOM 需要移动
} else {
// 如果当前找到的节点在旧 children 中的索引不小于最大索引值,则更新 lastIndex 的值
lastIndex = j
}
break;
}
}
}
}
else {
// ...
}
}
新节点在旧节点中对应的index比 lastIndex 大,说明该新节点本来就在lastIndex节点下面,不需要移动,否则说明该新节点原本在lastIndex上面的,跑到 lastIndex 下面去了,要移动。
4. 如何移动元素
当一个虚拟节点被挂载后,其对应的真实 DOM 节点会存储在它的 vnode.el 属性中,因此可以通过旧子节点的 vnode.el 属性取得它对应的真实 DOM 节点。
当更新操作发生时,渲染器会调用 patchElement 函数在新旧虚拟节点之间进行打补丁。
function patchElement(n1, n2) {
// 新的 vnode 也引用了真实 DOM 元素
const el = n2.el = n1.el
// ...
}
patchElement 函数首先将旧节点的 n1.el 属性赋值给新节点的 n2.el 属性。这个赋值语句的真正含义是将 DOM 元素的复用:
接下来实现代码: