9. 简单 Diff 算法
9.1 减少 DOM 操作的性能开销
在更新 dom 时简单的卸载重挂载方式会造成性能开销,因此需要比较新旧子节点从而复用旧节点
js
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 {
// 省略部分代码
}
}
在进行新旧两组子节点的更新时,不应该总是遍历旧的一组子节点或遍历新的一组子节点,而是应该遍历其中长度较短的那一组(假如新旧 Dom 标签都是一样的更容易理解patchChildren
函数)
js
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,说明有新子节点需要挂载
if (newLen > oldLen) {
for (let i = commonLength; i < newLen; i++) {
patch(null, newChildren[i], container);
}
} else if (oldLen > newLen) {
// 如果 oldLen > newLen,说明有旧子节点需要卸载
for (let i = commonLength; i < oldLen; i++) {
unmount(oldChildren[i]);
}
}
} else {
// 省略部分代码
}
}
9.2 DOM 复用与 key 的作用
如果没有 key,我们无法知道新子节点与旧子节点间的映射关系。而key
属性就像虚拟节点的“身份证”号,根据子节点的 key
属性,能够明确知道新子节点在旧子节点中的位置,从而实现复用。但 DOM 可复用并不意味着不需要更新。因为相同标签可能文本内容不一样。下面先完成打补丁操作
js
function patchChildren(n1, n2, container) {
if (typeof n2.children === "string") {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children;
const newChildren = n2.children;
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i];
// 遍历旧的 children
for (let j = 0; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j];
// 如果找到了具有相同 key 值的两个节点,说明可以复用,但仍然需要调用 patch 函数更新
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container);
break; // 这里需要 break
}
}
}
} else {
// 省略部分代码
}
}
9.3 找到需要移动的元素
不需要移动的情况
当新旧两组子节点的节点顺序不变时,就不需要额外的移动操作
新节点 旧节点 索引 p-1 p-1 0 p-2 p-2 1 p-3 p-3 2
注: p-1
指元素 p
的 key
值为 1
每一次寻找可复用的节点时,都会记录该可复用节点在旧的一组子节点中的位置索引。如果把这些位置索引值按照先后顺序排列,则可以得到一个序列:0、1、2。这是一个递增的序列,在这种情况下不需要移动任何节点
需要移动的情况
新节点 旧节点 索引 p-3 p-1 0 p-1 p-2 1 p-2 p-3 2 节点 p-1 在旧 children 中的索引是 0,它小于节点 p-3 在旧 children 中的索引 2。这说明节点 p-1 在旧 children 中排在节点 p-3 前面,但在新的 children 中,它排在节点 p-3 后面。因此节点 p-1 对应的真实 DOM 需要移动
在旧 children 中寻找具有相同 key 值节点的过程中,遇到的最大索引值
js
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; // 这里需要 break
}
}
}
} else {
// 省略部分代码
}
}
9.4 如何移动元素
js
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];
let j = 0;
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j];
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container);
if (j < lastIndex) {
// 代码运行到这里,说明 newVNode 对应的真实 DOM 需要移动
// 先获取 newVNode 的前一个 vnode,即 prevVNode
const prevVNode = newChildren[i - 1];
// 如果 prevVNode 不存在,则说明当前 newVNode 是第一个节点,它不需要移动
if (prevVNode) {
// 由于我们要将 newVNode 对应的真实 DOM 移动到 prevVNode 所对应真实 DOM 后面,
// 所以我们需要获取 prevVNode 所对应真实 DOM 的下一个兄弟节点,并将其作为锚点
const anchor = prevVNode.el.nextSibling;
// 调用 insert 方法将 newVNode 对应的真实 DOM 插入到锚点元素前面,
// 也就是 prevVNode 对应真实 DOM 的后面
insert(newVNode.el, container, anchor);
}
} else {
lastIndex = j;
}
break;
}
}
}
} else {
// 省略部分代码
}
}
9.5 添加新元素
js
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];
let j = 0;
// 在第一层循环中定义变量 find,代表是否在旧的一组子节点中找到可复用的节
// 初始值为 false,代表没找到
let find = false;
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j];
if (newVNode.key === oldVNode.key) {
// 一旦找到可复用的节点,则将变量 find 的值设为 true
find = true;
patch(oldVNode, newVNode, container);
if (j < lastIndex) {
const prevVNode = newChildren[i - 1];
if (prevVNode) {
const anchor = prevVNode.el.nextSibling;
insert(newVNode.el, container, anchor);
}
} else {
lastIndex = j;
}
break;
}
}
// 如果代码运行到这里,find 仍然为 false,
// 说明当前 newVNode 没有在旧的一组子节点中找到可复用的节点
// 也就是说,当前 newVNode 是新增节点,需要挂载
if (!find) {
// 为了将节点挂载到正确位置,我们需要先获取锚点元素
// 首先获取当前 newVNode 的前一个 vnode 节点
const prevVNode = newChildren[i - 1];
let anchor = null;
if (prevVNode) {
// 如果有前一个 vnode 节点,则使用它的下一个兄弟节点作为锚点元素
anchor = prevVNode.el.nextSibling;
} else {
// 如果没有前一个 vnode 节点,说明即将挂载的新节点是第一个子节点
// 这时我们使用容器元素的 firstChild 作为锚点
anchor = container.firstChild;
}
// 挂载 newVNode
patch(null, newVNode, container, anchor);
}
}
} else {
// 省略部分代码
}
}
// patch 函数需要接收第四个参数,即锚点元素
function patch(n1, n2, container, anchor) {
// 省略部分代码
if (typeof type === "string") {
if (!n1) {
// 挂载时将锚点元素作为第三个参数传递给 mountElement 函数
mountElement(n2, container, anchor);
} else {
patchElement(n1, n2);
}
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
}
}
// mountElement 函数需要增加第三个参数,即锚点元素
function mountElement(vnode, container, anchor) {
// 省略部分代码
// 在插入节点时,将锚点元素透传给 insert 函数
insert(el, container, anchor);
}
9.6 移除不存在的元素
js
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++) {
// 省略部分代码
}
// 上一步的更新操作完成后
// 遍历旧的一组子节点
for (let i = 0; i < oldChildren.length; i++) {
const oldVNode = oldChildren[i];
// 拿旧子节点 oldVNode 去新的一组子节点中寻找具有相同 key 值的节点
const has = newChildren.find((vnode) => vnode.key === oldVNode.key);
if (!has) {
// 如果没有找到具有相同 key 值的节点,则说明需要删除该节点
// 调用 unmount 函数将其卸载
unmount(oldVNode);
}
}
} else {
// 省略部分代码
}
}