渲染器(三):简单diff算法
我们将介绍渲染器的核心Diff算法。简单来说就是当新旧vnode的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点。
1.减少DOM操作的性能开销:
核心Diff算法只关心新旧虚拟节点都存在一组子节点的清空。前面我们针对这个,采用了简单粗暴的方式完成,即卸载全部旧子节点,再挂载全部新子节点。这么做确实可以完成更新,但是没有实现DOM复用,所以会产生较大的性能开销。
以下面新旧vnode为例:
// 旧 vnode
const oldVNode = {
type: 'div',
children: [
{ type: 'p', children: '1' },
{ type: 'p', children: '2' },
{ type: 'p', children: '3' }
]
}
// 新 vnode
const newVNode = {
type: 'div',
children: [
{ type: 'p', children: '4' },
{ type: 'p', children: '5' },
{ type: 'p', children: '6' }
]
}
按照之前的做法,当更新子节点时,需要执行6次DOM操作:
- 卸载所有旧子节点,3次DOM删除;
- 挂载所有新子节点,3次DOM添加。
但是观察上面新旧vnode的子节点,可以发现:
- 更新前后的所有子节点都是p标签,即标签元素不变;
- 只有p标签的子节点(文本节点)会发生变化。
所以最理想的更新方式:直接更新这个p标签的文本节点的内容,这样只需要3次DOM操作。
在动手完善代码之前,来思考一个问题:新旧两组子节点的数量未必相同。
- 当新vnode < 旧vnode时,多余旧vnode删除。
- 当新vnode > 旧vnode时,挂载新增节点。
- 在进行新旧vnode更新时,应该遍历其中长度较短的那一组。这样才能够尽可能多调用patch函数进行更新。
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c));
}
setElementText(container, n2.children);
} else if (Array.isArray(n2.children)) {
// 重新实现两组子节点的更新方式,新旧children
const oldChildren = n1.children;
const newChildren = n2.children;
const oldLen = oldChildren.length;
const newLen = newChildren.length;
// 两组子节点的公共长度,取两者中较短的一组
const commonLength = Math.min(oldLen, newLen);
// 遍历旧的children
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 (newLen < oldLen) {
// 有旧子节点需要卸载
for (let i = commonLength; i < oldLen; i++) {
unmount(oldChildren[i]);
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c));
} else if (typeof n1.children === 'string') {
setElementText(container, '');
}
}
}
这样便实现了无论新旧两组子节点的数量关系如何,渲染器都能够正确地挂载或卸载它们。
2.DOM复用与key的作用:
在前面,通过减少DOM操作的次数来提升更新性能,但是还有可优化空间。假设新旧两组子节点的内容如下:
// oldChildren
[
{ type: 'p' },
{ type: 'div' },
{ type: 'span' }
]
// newChildren
[
{ type: 'span' },
{ type: 'p' },
{ type: 'div' }
]
如果使用上一节介绍的算法来完成上述两组子节点的更新,需要移动6次DOM操作。
但是可以明显看到,上面的新旧vnode只是顺序不同,最优解:通过DOM的移动来完成子节点的更新,这更节约性能。那该如何移动呢?
得保证一个前提:新旧vnode确实存在可复用的节点。那如何确定新vnode有没有出现在旧vnode中呢?即如何保证上面例子中 新vnode中的第一个子节点 { type: 'span' }
与旧vnode中的第三个子节点相同?
一种解决方案是通过vnode.type的值是否相同来判断,但这种方式不可靠,举例:
// oldChildren
[
{ type: 'p', children: '1' },
{ type: 'p', children: '2' },
{ type: 'p', children: '3' }
]
// newChildren
[
{ type: 'p', children: '3' },
{ type: 'p', children: '1' },
{ type: 'p', children: '2' }
]
上面案例可以通过移动DOM的方式来完成更新。但是所有节点的 vnode.type
属性值都相同,这导致无法确定新旧vnode中节点的对应关系,也就不知道该进行怎样的DOM移动才能更新。
这时,key的作用旧体现出来了。
- key:vnode的唯一标识。
- 当两个vnode的 type 和 key都相同,才认为它们是相同的,就可以进行DOM复用了。
如下代码:
// oldChildren
[
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 },
{ type: 'p', children: '3', key: 3 }
]
// newChildren
[
{ type: 'p', children: '3', key: 3 },
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 }
]
下图展示了有key和无key时新旧两组子节点的映射情况:
![[)(]](https://img-blog.csdnimg.cn/966f016f895144e185aa59ec75313261.png)
这样,有key了就知道该如何进行相应的DOM移动操作了。
注意一点:DOM可复用并不意味着不需要更新,如:
const oldVnode = { type: 'p', key: 1, children: 'text1' }
const newVnode = { type: 'p', key: 1, children: 'text2' }
patchChildren
函数的修改:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c));
}
setElementText(container, n2.children);
} else if (Array.isArray(n2.children)) {
// 重新实现两组子节点的更新方式,新旧children
const oldChildren = n1.children;
const newChildren = n2.children;
// 遍历新vnode
for (let i = 0; i < newChildren.length; i++) {
const newVnode = newChildren[i];
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 {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c));
} else if (typeof n1.children === 'string') {
setElementText(container, '');
}
}
}
在上面代码中,重新实现了新旧vnode的更新逻辑。外层循环遍历新vnode,内层循环逐个对比新旧vnode的key值,试图在旧vnode中找到可复用的节点。一旦找到则调用patch函数进行打补丁。经过这一步操作之后,就能够保证所有可复用的节点本身都已经更新完毕了。接下来就需要通过移动节点来完成真实DOM顺序的更新。
3.找到需要移动的元素:
在前面,我们已经通过key找到可复用的节点了。接下来要思考的是如何判断一个节点是否需要移动,以及如何移动?
先来解决第一个问题,反过来想,那什么时候节点不需要移动?当新旧vnode的节点顺序不变时,就不需要额外的移动操作,如下图:
![[(]](https://img-blog.csdnimg.cn/8d341229f9c245f89348631709a05211.png)
来看更新算法:
- 取新vnode中的第一个节点p-1,key为1,尝试在旧vnode中寻找具有相同key值的可复用节点,找到了,并且该节点在旧vnode中的索引为0。
- 后两步一样。
在这个过程中,每一次寻找可复用vnode时,都会记录该可复用节点在旧vnode中的位置索引。如果把这些位置索引值按照先后顺序排列,就能得到一个序列:0、1、2。这是一个递增的序列,在这种情况下不需要移动任何节点。
再看另外一个例子:
执行更新算法:
- 第一步:取新vnode中的第一个节点p-3,key为3。在旧vnode中找到具有相同key的可复用节点,找到了,并且该节点在旧vnode中的索引为2。
- 第二步:同上寻找方式,该节点在旧vnode中的索引为0。
- 第三步:该节点在旧vnode中的索引为1。
这时发现,索引递增的顺序被打破了(2、0、1)。这时就需要进行移动DOM了。这就是Diff算法在执行更新的过程中,判断节点是否需要移动的方式。
其实可以将节点p-3在旧vnode中的索引定义为:在旧vnode寻找具有相同key值节点的过程中,遇到的最大索引值。如果在后续寻找过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动。
用 lastIndex
变量存储整个寻找过程中遇到的最大索引值,如下代码:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c));
}
setElementText(container, n2.children);
} 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) {
// 如果当前找到的节点在旧vnode中的索引小于最大索引值,
// 说明该节点对应的真实DOM需要移动
} else {
// 更新 lastIndex的值
lastIndex = j;
}
break; // 这里需要break
}
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c));
} else if (typeof n1.children === 'string') {
setElementText(container, '');
}
}
}
如上代码,变量lastIndex始终存储着当前遇到的最大索引值。
现在,已经找到了需要移动的节点,下面旧开始讨论如何移动节点从而完成节点顺序的更新。
4.如何移动元素:
在前面讨论了如何判断节点是否需要移动。移动节点:移动一个虚拟节点所对应的真实DOM节点,并不是移动虚拟节点本身。
既然移动的是真实DOM节点,就得取到对它的引用才行。在代码中,我们可以通过旧vnode中的vnode.el
属性取得它对应的真实DOM节点。
回顾一下之前的patchElement
函数:
function patchElement(n1, n2) {
// 新vnode也引用了真实DOM元素
const el = n2.el = n1.el;
}
这样无论是新vnode还是旧vnode,都存在对真实DOM的引用。在此基础上,就可以进行DOM移动操作了。
为了解释具体应该怎样移动DOM节点,采用上一节的更新案例:
更新步骤如下:
-
第一步:取新vnode中的第一个节点p-3,key为3,在旧vnode中找到具有相同key值的可复用节点,此时
lastIndex
值为0,旧vnode索引为2,2>0,所以节点p-3对应的真实DOM不需要移动。然后更新lastIndex
为2。 -
第二步:新vnode第二个节点p-1,key为1,在旧vnode中找到具有相同key值的可复用节点,此时
lastIndex
值为2,旧vnode索引为0,0<2,所以节点p-1对应的真实DOM需要移动。- 那么该如何移动呢?节点p-1对应的真实DOM需要移动,并且我们知道 新vnode的顺序其实就是更新后真实DOM节点应有的顺序。所以把节点p-1所对应的真实DOM移动到节点p-3所对应的真实DOM后面。
- 具体如下图:
-
第三步与第二步类似,就不进行分析了。
开始着手实现代码:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c));
}
setElementText(container, n2.children);
} 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) {
// 代码运行到这里,说明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插入到锚点前面
insert(newVnode.el, container, anchor);
}
} else {
lastIndex = j;
}
break;
}
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c));
} else if (typeof n1.children === 'string') {
setElementText(container, '');
}
}
}
在上面代码中,如果条件 j<lastIndex
成立,则说明当前 newVnode
对应的真实DOM需要移动。获取其前一个虚拟节点,然后调用insert函数完成节点的移动即可,其中insert函数依赖浏览器原生API,故需要抽离:
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag);
},
setElementText(el, text) {
el.textContent = text;
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor);
},
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
let invokers = el._vei || (el._vei = {});
let invoker = invokers[key];
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
// e.timeStamp:事件发生的时间
// 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
if (e.timeStamp < invoker.attached) return;
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e));
} else {
invoker.value(e);
}
}
invoker.value = nextValue;
// 添加 invoker.attached属性,存储事件处理函数被绑定的时间
invoker.attached = performance.now();
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
}
} else if (invoker) {
el.removeEventListener(name, invoker);
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
}
else {
el.setAttribute(key, nextValue);
}
},
createText(text) {
return document.createTextNode(text);
},
setText(el, text) {
el.nodeValue = text;
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor);
}
})
5.添加新元素:
这节讨论添加新节点的情况,如图:
观察可知,在新vnode中,多出一个p-4,key值为4,该节点在旧vnode中不存在,因此将其视为新增节点。
对于新增节点,在更新时要将它挂载,这主要分为两步:
- 想办法找到新增节点;
- 将新增节点挂载到正确位置。
先来看一下如何找到新增节点,根据前面实现的简单diff算法来模拟下图中的例子:
此时的更新逻辑:前两步解释和前面一样。当第三步时,新vnode中的第三个节点为p-4,它的key值为4,在旧vnode中寻找可复用的节点,发现找不到,因此渲染器会把节点p-4看作新增节点并挂载它。那么应该挂载到哪里呢?
观察p-4在新vnode中的位置,它在p-1节点的后面,所以应该把它挂载到节点p-4所对应的真实DOM后面。第
四步移动操作完后真实DOM顺序是:p-3、p-1、p-4、p-2。
代码实现:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c));
}
setElementText(container, n2.children);
} 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];
// 在第一层循环中定义find,代表是否在旧vnode中找到可复用节点
// 初始值为false,则代表没找到
let find = false;
for (let j = 0; 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,
// 说明新vnode没有在旧vnode中找到可复用的节点,此时说明新vnode是新增节点,执行挂载
if (!find) {
// 为了将节点挂载到正确位置,需要先获取锚点元素
// 获取当前新vnode的前一个vnode节点
const prevVnode = newChildren[i - 1];
let anchor = null;
if (prevVnode) {
// 如果有前一个vnode节点,则使用它的下一个兄弟节点作为锚点元素
anchor = prevVnode.el.nextSibling;
} else {
// 如果没有前一个vnode节点,说明即将挂载的新vnode是第一个子节点
// 这时使用容器元素的 findChild 作为锚点
anchor = container.firstChild;
}
// 挂载新vnode
patch(null, newVnode, container, anchor);
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c));
} else if (typeof n1.children === 'string') {
setElementText(container, '');
}
}
}
上面代码的解释都写在注释里了,并且由于目前实现的patch函数不支持传递第四个参数,修改patch函数:
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (typeof type === 'string') {
if (!n1) {
// 挂载时将锚点元素作为第三个参数传递给 mountElement
mountElement(n2, container, anchor);
} else {
patchElement(n1, n2);
}
} else if (typeof type === 'Text') {
if (!n1) {
const el = n2.el = createTextNode(n2.children);
insert(el, container);
} else {
const el = n2.el = n1.el;
if (n2.children !== n1.children) {
setText(el, n2.children);
}
}
} else if (type === 'Fragment') {
if (!n1) {
n2.children.forEach(c => patch(null, c, container));
} else {
patchChildren(n1, n2, container);
}
}
}
// 它需要增加第三个参数,即锚点元素
function mountElement(vnode, container, anchor) {
// 省略部分代码
// 插入节点时,将锚点元素传递给insert函数
insert(el, container, anchor);
}
6.移除不存在的元素:
在更新子节点时,不仅会遇到新增元素,还有可能遇到元素被删除的情况,如图:
在新vnode中,节点p-2不存在,这说明该节点被删除了。那么渲染器该如何找到那些需要删除的节点并正确地删除呢?
先看如何找到需要删除的节点,模拟执行更新的过程:
- 第一步:跟前面一样。
- 第二步:新vnode中的第二个节点p-1,在旧vnode中找到对应的,进行移动。
此时真实DOM的状态如图所示:
至此更新便结束了,但是p-2对应的真实DOM仍然存在,所以需要增加额外的逻辑来删除遗留节点。
思路很简单,当更新结束后,再遍历一遍旧vnode,然后去新vnode中寻找具有相同key的节点,找不到则删除该节点。如下代码:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c));
}
setElementText(container, n2.children);
} 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 find = false;
for (let j = 0; 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;
}
}
if (!find) {
const prevVnode = newChildren[i - 1];
let anchor = null;
if (prevVnode) {
anchor = prevVnode.el.nextSibling;
} else {
anchor = container.firstChild;
}
// 挂载新vnode
patch(null, newVnode, container, anchor);
}
}
// 上一步的更新操作完成后,遍历旧vnode
for (let i = 0; i < oldChildren.length; i++) {
const oldVnode = oldChildren[i];
// 拿旧node去信vnode中寻找有相同key值的节点
const has = newChildren.find(
vnode => vnode.key === oldVnode.key
)
if (!has) {
// 如果没有中找到则删除该节点
unmount(oldVnode);
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c));
} else if (typeof n1.children === 'string') {
setElementText(container, '');
}
}
}
如以上代码及注释所示。
7.总结:
在这一章中,我们讨论了Diff算法作用,主要是用来计算新旧vnode的差异,并尝试最大程度复用DOM元素。并解释了为什么需要Diff算法,以及DOM复用带来的好处,以及key的作用。
并通过几个例子讲解了渲染器是如何移动、添加、删除vnode所对应的DOM元素的。