背景

我们都知道,使用React开发web应用时候,基本不需要开发者对DOM进行操作。React的虚拟DOM用来实现对应用的组件树的抽象。它还提供了从应用到DOM树的一个映射。如果应用的改动导致了DOM的实际变化,React就会自动更新相应的DOM。

组件的更新是否导致了DOM的更新,哪些DOM需要更新,这需要进行相关的计算。组件树更新后,需要对比新老组件树的更新。这就用到了React的diff算法。

我们经常提到的React组件中的key属性,也和diff算法紧密相关。

diff算法

说明

React的diff算法基于两个假设:

  1. 不同元素的类型会产生不同的树
  2. 开发者可以通过key prop标识一个元素在不同的渲染下可以保持稳定(及相同key的元素不需要更新整个元素,不同的key需要更新整个元素)

基于上述假设,React认为在视图更新前后,如果两个节点的Tag名(对于原生标签)或者组件(对于自定义组件)相同,则它们是同一个节点,而且如果是不同的根节点,那么子节点也不需要对比,直接用新树替换掉旧的树即可。

对比的过程

  1. 当一个组件触发更新(setState),则React会diff以这个组件为根节点的整个虚拟DOM树,对比更新后的虚拟DOM和更新前的虚拟DOM。
  2. 如果两个节点不同,则用新的节点替换掉旧的节点;如果两个节点相同(假设为oldNode和newNode),则对比它们的属性、innerText和子节点。
  3. 如果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的情况。

  1. import React from 'react';
  2. const a = [
  3. {id: '1', content: '1'},
  4. {id: '2', content: '2'},
  5. {id: '3', content: '3'},
  6. ];
  7. const b = [
  8. {id: '1', content: '1'},
  9. {id: '2', content: '2'},
  10. {id: '3', content: '3'},
  11. {id: '4', content: '4'},
  12. ];
  13. export default class TestStore extends React.Component {
  14. state = {
  15. list: a
  16. };
  17. componentDidMount() {
  18. observeDOM();
  19. }
  20. handleClick = () => {
  21. this.setState({list: b});
  22. };
  23. render() {
  24. return (
  25. <div className="demo-container">
  26. <button
  27. onClick={this.handleClick}
  28. >
  29. 点击
  30. </button>
  31. <ul>
  32. {
  33. this.state.list.map(({id, content}) => (
  34. <li key={id}>{content}</li>
  35. ))
  36. }
  37. </ul>
  38. </div>
  39. );
  40. }
  41. }
  42. // 监听DOM变化并打印更新情况
  43. function observeDOM() {
  44. const config = { attributes: true, childList: true, subtree: true };
  45. const callback = (mutationsList) => {
  46. mutationsList.forEach((mutation, index) => {
  47. console.log(`loop ${index}`);
  48. const {addedNodes, removedNodes} = mutation;
  49. addedNodes.forEach(node => {
  50. console.log('added: ', node.innerHTML);
  51. });
  52. removedNodes.forEach(node => {
  53. console.log('removed: ', node.innerHTML);
  54. });
  55. console.log(`--------------------------\n`);
  56. });
  57. };
  58. const observer = new MutationObserver(callback);
  59. observer.observe(document, config);
  60. };

上面demo中展示了当列表变化后,实际的DOM更新情况。下面看下不同的a、b list对应的DOM改动。

  1. 后面添加元素
  1. // 列表
  2. const a = [
  3. {id: '1', content: '1'},
  4. {id: '2', content: '2'},
  5. {id: '3', content: '3'},
  6. ];
  7. const b = [
  8. {id: '1', content: '1'},
  9. {id: '2', content: '2'},
  10. {id: '3', content: '3'},
  11. {id: '4', content: '4'},
  12. ];
  13. // 输出结果
  14. loop 0
  15. added: 4
  16. --------------------------
  1. 前面添加元素
  1. // 列表
  2. const a = [
  3. {id: '1', content: '1'},
  4. {id: '2', content: '2'},
  5. {id: '3', content: '3'},
  6. ];
  7. const b = [
  8. {id: '4', content: '4'},
  9. {id: '1', content: '1'},
  10. {id: '2', content: '2'},
  11. {id: '3', content: '3'},
  12. ];
  13. // 输出结果
  14. loop 0
  15. added: 4
  16. --------------------------
  1. 中间添加元素
  1. // 列表
  2. const a = [
  3. {id: '1', content: '1'},
  4. {id: '2', content: '2'},
  5. {id: '3', content: '3'},
  6. ];
  7. const b = [
  8. {id: '1', content: '1'},
  9. {id: '4', content: '4'},
  10. {id: '2', content: '2'},
  11. {id: '3', content: '3'},
  12. ];
  13. // 输出结果
  14. loop 0
  15. added: 4
  16. --------------------------
  1. 改变顺序
  1. // 列表
  2. const a = [
  3. {id: '1', content: '1'},
  4. {id: '2', content: '2'},
  5. {id: '3', content: '3'},
  6. ];
  7. const b = [
  8. {id: '2', content: '2'},
  9. {id: '1', content: '1'},
  10. {id: '3', content: '3'},
  11. ];
  12. // 输出结果
  13. loop 0
  14. removed: 1
  15. --------------------------
  16. loop 1
  17. added: 1
  18. --------------------------

从上面结果可以看出,当我们在列表的前面、后面、中间添加一个元素时候,React都只会添加相应的元素,而不会重新渲染整个列表。

而当我们改变列表顺序时候,React也只是通过移动元素来使列表更新,并没有更新整个列表。

为什么需要key

React的列表中推荐加唯一标识key,并且尽量不要用index作为key。


不加key或者key使用index赋值,都会列表变动后导致React无法辨别item前后的对应关系。

示例:

  1. import React from 'react';
  2. export default class Test extends React.Component {
  3. state = {
  4. arr: [{id: '1', text: '一'}, {id: '2', text: '二'}]
  5. };
  6. deleteHead = () => {
  7. this.setState(({arr}) => ({
  8. arr: arr.slice(1)
  9. }));
  10. };
  11. insert = () => {
  12. this.setState(({arr}) => ({
  13. arr: [arr[0], {id: '3', text: '三'}, arr[2]]
  14. }));
  15. };
  16. render() {
  17. return (
  18. <>
  19. <ul>
  20. {
  21. this.state.arr.map(({text}) => (
  22. <li>{text}<input /></li>
  23. ))
  24. }
  25. </ul>
  26. <button onClick={this.deleteHead}>删除头部元素</button>
  27. <button onClick={this.insert}>插入元素</button>
  28. </>
  29. );
  30. }
  31. }
  1. // 删除头结点
  2. {id: '1', text: '一'} <=> {id: '2', text: '二'}
  3. {id: '2', text: '二'} <=> null
  4. // 插入节点
  5. {id: '1', text: '一'} <=> {id: '1', text: '一'}
  6. {id: '2', text: '二'} <=> {id: '3', text: '三'}
  7. 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的对应关系发生变化。