Skip to content
MiTu

MiTu

A Web developer who loves to Reading and photography

diff 算法和 key 的作用

背景

我们都知道,使用 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 的情况。

js
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 改动。

  1. 后面添加元素
js
// 列表
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
--------------------------
  1. 前面添加元素
js
// 列表
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
--------------------------
  1. 中间添加元素
js
// 列表
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
--------------------------
  1. 改变顺序
js
// 列表
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 前后的对应关系。

示例:

jsx
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>
      </>
    );
  }
}
js
// 删除头结点
{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 认为本来不同的节点是同一个节点,只是属性和内容不同,于是做的操作是更新属性。

这会带来两个问题:

  1. 性能损耗,本来只需要删除一个节点,结果 React 会删除节点,然后修改属性(删除头节点的情况),产生了多余操作。对于上面的例子,React 会将第一个节点的 innerText 替换成“二”,然后删除第二个节点,实际上只要删除第一个节点就行了。
  2. 非受控的表单的值可能有问题,当表单的值不受控时候,React 无法控制表单的值,也就没有更新到实际的值。对于删除头节点的情况,预期的结果是,input 标签的值是之前第二个节点输入的内容,但实际结果却是第一个的 input 输入的内容,根据上面说明的 React 对未加 key 的列表的删除头结点处理逻辑,不难分析出这个结果的原因。

对于使用 index 做 key 的情况,也是类似,本质上就是如果不加 key 或者使用 index 做 key,React 无法识别列表元素前后对应关系。

对于使用 index 做 key 的问题,和不加 key 类似,因为列表改变后(插入删除等),列表元素和 index 的对应关系发生变化。