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 的对应关系发生变化。