diff 算法和 key 的作用
背景
我们都知道,使用 React 开发 web 应用时候,基本不需要开发者对 DOM 进行操作。React 的虚拟 DOM 用来实现对应用的组件树的抽象。它还提供了从应用到 DOM 树的一个映射。如果应用的改动导致了 DOM 的实际变化,React 就会自动更新相应的 DOM。
组件的更新是否导致了 DOM 的更新,哪些 DOM 需要更新,这需要进行相关的计算。组件树更新后,需要对比新老组件树的更新。这就用到了 React 的 diff 算法。
我们经常提到的 React 组件中的 key 属性,也和 diff 算法紧密相关。
diff 算法
说明
React 的 diff 算法基于两个假设:
- 不同元素的类型会产生不同的树
- 开发者可以通过 key prop 标识一个元素在不同的渲染下可以保持稳定(及相同 key 的元素不需要更新整个元素,不同的 key 需要更新整个元素)
基于上述假设,React 认为在视图更新前后,如果两个节点的 Tag 名(对于原生标签)或者组件(对于自定义组件)相同,则它们是同一个节点,而且如果是不同的根节点,那么子节点也不需要对比,直接用新树替换掉旧的树即可。
对比的过程
- 当一个组件触发更新(setState),则 React 会 diff 以这个组件为根节点的整个虚拟 DOM 树,对比更新后的虚拟 DOM 和更新前的虚拟 DOM。
- 如果两个节点不同,则用新的节点替换掉旧的节点;如果两个节点相同(假设为 oldNode 和 newNode),则对比它们的属性、innerText 和子节点。
- 如果 oldNode 有某个子节点 someOldChild,而 newNode 没有这个子节点(即 newNode 没有一个子节点,和 someOldNode 有相同的 tag 名、自定义组件引用或者 key 值),则删掉 someOldChild;如果 newNode 有某个子节点 somNewChild,而 oldNode 没有,则添加 someNewChild;如果 oldNode 和 newNode 都有某个节点 someChild,则将其移动到正确的位置,并递归地进行对比工作,即以 someChild 为根节点对比新旧两棵虚拟 DOM 树。
从上面的对比过程可以看出,React 的 diff(其实 Vue 也是一样)并不会考虑到节点跨层移动的情况,因此有些观点认为 React 的对比过程可以描述为“按层比较”(level by level)。实际上这种说法并不准确,因为 React 的 diff 过程不是严格的层序遍历,只是限定两个节点能够进行对比的前提条件是,新旧节点有相同的父节点,或者新旧节点都是根节点。
列表的 key
说明
React 对于列表的更新,也是尽可能地少做 DOM 操作。因此对于有子节点的情况,会有比较复杂的比较,以保证尽量小的更新。比如对于列表[1,2,3]变成[0,1,2,3]时候,虽然前 3 个元素完全不同了,但是 React 并不会把之前的列表完全替换,而是会在之前的列表前面添加“0”。当然实现这个效果需要开发者给列表添加“key prop”,以便 React 可以进行必要的对比操作,已确定列表的真实改动。
示例
我们先通过一个 demo 看下 React 的列表渲染逻辑,看列表改变后,实际更新的 DOM 的情况。
import React from 'react';
const a = [
{ id: '1', content: '1' },
{ id: '2', content: '2' },
{ id: '3', content: '3' }
];
const b = [
{ id: '1', content: '1' },
{ id: '2', content: '2' },
{ id: '3', content: '3' },
{ id: '4', content: '4' }
];
export default class TestStore extends React.Component {
state = {
list: a
};
componentDidMount() {
observeDOM();
}
handleClick = () => {
this.setState({ list: b });
};
render() {
return (
<div className="demo-container">
<button onClick={this.handleClick}>点击</button>
<ul>
{this.state.list.map(({ id, content }) => (
<li key={id}>{content}</li>
))}
</ul>
</div>
);
}
}
// 监听DOM变化并打印更新情况
function observeDOM() {
const config = { attributes: true, childList: true, subtree: true };
const callback = mutationsList => {
mutationsList.forEach((mutation, index) => {
console.log(`loop ${index}`);
const { addedNodes, removedNodes } = mutation;
addedNodes.forEach(node => {
console.log('added: ', node.innerHTML);
});
removedNodes.forEach(node => {
console.log('removed: ', node.innerHTML);
});
console.log(`--------------------------\n`);
});
};
const observer = new MutationObserver(callback);
observer.observe(document, config);
}上面 demo 中展示了当列表变化后,实际的 DOM 更新情况。下面看下不同的 a、b list 对应的 DOM 改动。
- 后面添加元素
// 列表
const a = [
{id: '1', content: '1'},
{id: '2', content: '2'},
{id: '3', content: '3'},
];
const b = [
{id: '1', content: '1'},
{id: '2', content: '2'},
{id: '3', content: '3'},
{id: '4', content: '4'},
];
// 输出结果
loop 0
added: 4
--------------------------- 前面添加元素
// 列表
const a = [
{id: '1', content: '1'},
{id: '2', content: '2'},
{id: '3', content: '3'},
];
const b = [
{id: '4', content: '4'},
{id: '1', content: '1'},
{id: '2', content: '2'},
{id: '3', content: '3'},
];
// 输出结果
loop 0
added: 4
--------------------------- 中间添加元素
// 列表
const a = [
{id: '1', content: '1'},
{id: '2', content: '2'},
{id: '3', content: '3'},
];
const b = [
{id: '1', content: '1'},
{id: '4', content: '4'},
{id: '2', content: '2'},
{id: '3', content: '3'},
];
// 输出结果
loop 0
added: 4
--------------------------- 改变顺序
// 列表
const a = [
{id: '1', content: '1'},
{id: '2', content: '2'},
{id: '3', content: '3'},
];
const b = [
{id: '2', content: '2'},
{id: '1', content: '1'},
{id: '3', content: '3'},
];
// 输出结果
loop 0
removed: 1
--------------------------
loop 1
added: 1
--------------------------从上面结果可以看出,当我们在列表的前面、后面、中间添加一个元素时候,React 都只会添加相应的元素,而不会重新渲染整个列表。
而当我们改变列表顺序时候,React 也只是通过移动元素来使列表更新,并没有更新整个列表。
为什么需要 key
React 的列表中推荐加唯一标识 key,并且尽量不要用 index 作为 key。
不加 key 或者 key 使用 index 赋值,都会列表变动后导致 React 无法辨别 item 前后的对应关系。
示例:
import React from 'react';
export default class Test extends React.Component {
state = {
arr: [
{ id: '1', text: '一' },
{ id: '2', text: '二' }
]
};
deleteHead = () => {
this.setState(({ arr }) => ({
arr: arr.slice(1)
}));
};
insert = () => {
this.setState(({ arr }) => ({
arr: [arr[0], { id: '3', text: '三' }, arr[2]]
}));
};
render() {
return (
<>
<ul>
{this.state.arr.map(({ text }) => (
<li>
{text}
<input />
</li>
))}
</ul>
<button onClick={this.deleteHead}>删除头部元素</button>
<button onClick={this.insert}>插入元素</button>
</>
);
}
}// 删除头结点
{id: '1', text: '一'} <=> {id: '2', text: '二'}
{id: '2', text: '二'} <=> null
// 插入节点
{id: '1', text: '一'} <=> {id: '1', text: '一'}
{id: '2', text: '二'} <=> {id: '3', text: '三'}
null <=> {id: '2', text: '二'}如果不给列表加 key 属性,React 会认为前后不同的节点是同一个节点。比如删除头结点后,React 会认为后来居上的节点 id=2 是原来的第一个节点,只是 innerText 变成"二"了。
通过上面说明我们知道,React 认为本来不同的节点是同一个节点,只是属性和内容不同,于是做的操作是更新属性。
这会带来两个问题:
- 性能损耗,本来只需要删除一个节点,结果 React 会删除节点,然后修改属性(删除头节点的情况),产生了多余操作。对于上面的例子,React 会将第一个节点的 innerText 替换成“二”,然后删除第二个节点,实际上只要删除第一个节点就行了。
- 非受控的表单的值可能有问题,当表单的值不受控时候,React 无法控制表单的值,也就没有更新到实际的值。对于删除头节点的情况,预期的结果是,input 标签的值是之前第二个节点输入的内容,但实际结果却是第一个的 input 输入的内容,根据上面说明的 React 对未加 key 的列表的删除头结点处理逻辑,不难分析出这个结果的原因。
对于使用 index 做 key 的情况,也是类似,本质上就是如果不加 key 或者使用 index 做 key,React 无法识别列表元素前后对应关系。
对于使用 index 做 key 的问题,和不加 key 类似,因为列表改变后(插入删除等),列表元素和 index 的对应关系发生变化。