背景
我们都知道,使用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的对应关系发生变化。