什么是diff算法
简单来说当新旧vnode的子节点都是一组子节点时,为了以最小的性能开销完成更新操作。需要比较两组子节点,而用于比较的算法就叫做diff算法。渲染器的核心diff算法就是为了解决频繁操作DOM操作开销大的问题。
为什么减少DOM操作的性能开销
如果没有diff算法的话,要想实现dom的变化必须要把旧的节点销毁掉,再挂载新的节点。由于没有复用任何的DOM元素,会产生极大的性能开销。以下面的虚拟节点为例:
const oldNode = {
type: 'div',
children: [
{type: 'p', children: '1'},
{type: 'p', children: '2'},
{type: 'p', children: '3'}
]
}
const newNode = {
type: 'div',
children: [
{type: 'p', children: '4'},
{type: 'p', children: '5'},
{type: 'p', children: '6'}
]
}
在没有diff算法的情况下,我们需要执行6次DOM操作。两组子节点之间差的只是children中每个子节点的内容,因此理论上我们只需要3次DOM就能够完成节点的更新,性能也会提升一倍。
/**
*
* @param {新节点} newNode
* @param {旧节点} oldNode
* @param {父节点} container
*/
const patchChildren = (newNode, oldNode, container) => {
const newChildren = newNode.children;
const oldChildren = oldNode.children;
const newLen = newChildren.length;
const oldLen = oldChildren.length;
const commonLen = Math.min(newLen, oldLen);
let i = 0;
while (i < commonLen) {
// 比对新旧节点
patchNode(newChildren[i], oldChildren[i]);
i++;
}
if (newLen > oldLen) {
while (i < newLen) {
// 插入新节点
patchNode(newChildren[i], null, container);
i++;
}
} else {
while (i < oldLen) {
// 卸载旧节点
unmount(oldChildren[i]);
i++;
}
}
};
DOM复用和key的作用
以上方法仍然存在优化的空间,比如以下两组子节点拿来patch
const oldNode = {
type: 'div',
children: [
{type: 'p'},
{type: 'span'},
{type: 'div'}
]
}
const newNode = {
type: 'div',
children: [
{type: 'div'},
{type: 'p'},
{type: 'span'}
]
}
两组子节点的区别就是标签的顺序不同而已,按照刚才的方法进行patch,也同样需要6次DOM操作,因为相同DOM下标不是一一对应的,那么这时key就可以登场了,它就像是虚拟节点的“身份证”号,当两个字节点key相等时,我们认为两个DOM是一样的,可以拿来复用,当然DOM可复用不代表可以不用更新,还是需要将两个节点patchNode一下。
// 旧node
const oldNode = {
type: 'div',
children: [
{type: 'p', key: '1'},
{type: 'span', key: '2'},
{type: 'div', key: '3'}
]
}
// 新node
const newNode = {
type: 'div',
children: [
{type: 'div', key: '3'},
{type: 'p', key: '1'},
{type: 'span', key: '2'}
]
}
// 双层循环 遍历两组子节点找到可复用的节点
.....
for (let i = 0; i < newChildren.length; i++) {
for (let j = 0; j < oldChildren.length; j++) {
if (newChildren[i].key === oldChildren[j].key) {
patchNode(newChildren[i], oldChildren[j])
break
}
}
}
....
找到需移动的节点
经过以上处理,已经可以通过key来找到可以复用的节点,接下来要考虑这些节点是否需要移动顺序以及如何移动。以上边的两组子节点举例,尝试以下思路:
- 声明变量maxIndex = 0,开始遍历newChildren数组。
- 遍历到newChildren[0]时,在oldChildren中找到key相同的元素,下标为2,大于maxIndex,这就说明newChildren[0]这个节点原先是排在oldChildren[maxIndex]之后的,将maxIndex赋值为2。
- 遍历到newChildren[1]时,在oldChildren中找到key相同的元素,下标为0,小于maxIndex,这就说明newChildren[1]这个节点原先是排在oldChildren[maxIndex]之前的,因此这个子节点需要移动。
- 遍历到newChildren[2]时,在oldChildren中找到key相同的元素,下标为1,小于maxIndex,这就说明newChildren[2]这个节点原先是排在oldChildren[maxIndex]之前的,因此这个子节点也需要移动。
// 双层循环 遍历两组子节点找到可复用的节点,并检验其是否需要移动
.....
let maxIndex = 0
for (let i = 0; i < newChildren.length; i++) {
for (let j = 0; j < oldChildren.length; j++) {
if (newChildren[i].key === oldChildren[j].key) {
patchNode(newChildren[i], oldChildren[j])
if (j < maxIndex) {
// 当前节点需要移动
....
} else {
// 当前节点不需要移动
maxIndex = j
}
break
}
}
}
....
如何移动节点
节点的移动指的是移动一个虚拟节点对应的真实DOM,并不是移动虚拟节点本身,当虚拟节点被挂载后,其对应的真实DOM会存储在vnode.el属性中。因此我们可以通过旧子节点的el属性来获得真实的DOM节点。当节点更新操作发生时,渲染器会调用patchElement方法在新旧虚拟节点之间打补丁,伪代码如下:
const patchElement = (newNode, oldNode) => {
// 此时新节点的el属性也引用了真实DOM元素
const el = newNode.el = oldNode.el
......
}
改进上一节陈述的步骤
- 声明变量maxIndex = 0,开始遍历newChildren数组。
- 遍历到newChildren[0]时,在oldChildren中找到key相同的元素,下标为2,大于maxIndex,这就说明newChildren[0]这个节点原先是排在oldChildren[maxIndex]之后的,将maxIndex赋值为2。
- 遍历到newChildren[1]时,在oldChildren中找到key相同的元素,下标为0,小于maxIndex,这就说明newChildren[1]这个节点原先是排在oldChildren[maxIndex]之前的,因此这个子节点对应的真实DOM需要移动到newChildren[0]对应的真实DOM之后。
- 遍历到newChildren[2]时,在oldChildren中找到key相同的元素,下标为1,小于maxIndex,这就说明newChildren[2]这个节点原先是排在oldChildren[maxIndex]之前的,因此这个子节点对应的真实DOM也需要移动到newChildren[1]对应的真实DOM之后。
// 双层循环 遍历两组子节点找到可复用的节点,并检验其是否需要移动
.....
let maxIndex = 0
for (let i = 0; i < newChildren.length; i++) {
for (let j = 0; j < oldChildren.length; j++) {
if (newChildren[i].key === oldChildren[j].key) {
// patchNode过程中newChildren[i].el已经被挂上了真实DOM
patchNode(newChildren[i], oldChildren[j])
if (j < maxIndex) {
/**
* 1. 取下标为i-1的node,赋值给prevNode,如果取不到就说明当前节点为首个子节点。
* 2. 查找到prevNode对应的真实DOM的下一个兄弟节点,即prevNode.el.nextSibling
* 3. 调用insert方法进行DOM操作,伪代码写在下面
*/
const prevNode = newChildren[i - 1]
if (prevNode) {
insert(newChildren[i], container, prevNode?.el.nextSibling || container.firstChild)
}
} else {
// 当前节点不需要移动
maxIndex = j
}
break
}
}
}
....
// insert方法,将el插入到anchor之前
const insert = (el, parent, anchor = null) => {
parent.insetrtBefore(el, anchor)
}
添加新元素
上一节的内容是建立在 遍历newChildren时能够找到新节点在oldChildren中的可复用DOM的基础上进行探究的,那么如果找不到呢?那就意味着需要有新的元素插入到原先的DOM列表中。首先我们需要找到新增的节点,然后将它挂载到正确的位置即可。
// 双层循环 遍历两组子节点找到可复用的节点,并检验其是否需要移动
.....
let maxIndex = 0
for (let i = 0; i < newChildren.length; i++) {
let find = false
for (let j = 0; j < oldChildren.length; j++) {
if (newChildren[i].key === oldChildren[j].key) {
find = true
.....
break
}
}
if (!find) {
const prevNode = newChildren[i - 1]
let anchor = null
if (prevNode) {
anchor = prevNode.el.nextSibling
} else {
anchor = container.firstChild
}
// 挂载新的DOM节点
patchNode(newChildren[i],null,container,anchor)
}
}
...
移除不存在的元素
等newChildren遍历结束后,可能还存在需要移除的DOM节点。遍历oldChildren,如果在newChildren中找不到oldChildren[i].key对应的节点就说明该节点对应的真实DOM应该被移除。
for (let i = 0; i < oldChildren.length; i++) {
const node = newChildren.find(vnode => vnode.key === oldChildren[i].key)
if (!node) {
unmount(newChildren[i])
}
}

diff算法是渲染引擎用于优化DOM操作的核心技术,通过对比新旧虚拟节点,减少实际DOM变更次数以提升性能。文章介绍了当子节点为一组时,如何通过diff算法高效地更新DOM,避免无谓的销毁和挂载操作。同时,引入key的概念,利用key实现更精确的节点匹配和复用,进一步优化了移动节点和添加、删除节点的效率。
1021

被折叠的 条评论
为什么被折叠?



