react 面试必问 - react key

3,202 阅读12分钟

前言

「react key」相关问题几乎是面试的老生常谈了。也许你会说这是面试的八股文问题而已,有什么好说的。但是,实际上,正确去理解它对日常的开发工作还是有帮助的。一来能够帮助我们避免踩到坑里面,二来还有妙用之处。

什么是 react key 呢?

「react key」是一个组件实例的唯一标识,服务于 react 内部实现的一个特性。从 jsx 上来看, key属性似乎是一个组件 prop,但是在官方定义上,它不是(类似的,还有 ref属性)。因为,在我们组件实现的内部,你是无法通过props.key来访问到它的。

react key 有什么用?

上面说,「react key 服务于 react 内部实现」。更具体地来说,组件的key属性是为了提高 diff算法在渲染列表时候的性能。有了它,react 内部就知道相比上一个渲染周期,当前的渲染周期插入,移动或者删除哪些节点。然后,我们就通过复用相应的组件实例来复用之前的 DOM 对象,减少不必要的 DOM 操作所产生的开销,从而提高界面更新的性能。

拿官方的示例代码举例子。假设我们现在有以下的 react element tree:

旧列表1

<ul>
  <li>first</li>
  <li>second</li>
</ul>

现在我们往列表的尾部追加一个节点 <li>third</li>,得到一个新列表:

新列表1

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

在没有引入 key 这个特性之前,react 内部是采用「按节点顺序比对」的。这种实现方式碰上当前的业务场景,是没有问题的。因为,我们遍历新列表的第一个节点,我们就拿它跟旧数组的第一个节点进行比对,结果发现完全一样。所以,react 就决定复用之前的组件实例和 DOM 对象。

接着往下走,我们会遍历新列表的第二个节点,我们就拿它跟旧数组的第二个节点进行比对,结果发现也是完全一样。所以,react 也决定复用之前的组件实例和 DOM 对象。

到了新列表的第三个节点,react 在旧列表上找不到对应的节点。所以,react 决定新建组件实例和 DOM 对象。

以上流程,显然没有什么性能问题 - 因为我们保证了最小的界面更新动作。

但是,如果某种业务场景下,我们往旧列表的头部插入一个节点,例如这样:

新列表2

<ul>
  <li>third</li>
  <li>first</li>
  <li>second</li>
</ul>

这种情况下,如果还是按照「按节点顺序比对」的话,那么结果是:

  1. 新列表 <li>third</li> 跟 旧列表 <li>first</li> 内容不同 -》需要进行 DOM 更新
  2. 新列表 <li>first</li> 跟 旧列表 <li>second</li> 内容不同 -》需要进行 DOM 更新
  3. 旧列表中没有 <li>second</li> -》需要进行创建组件实例和 DOM 对象

上面示例中,新旧节点的子树只是文本而已,如果是更复杂的自定义组件,那么 react 的性能损耗会更加严重。比如,我们的新旧列表是这样的:

// 旧列表
<ul>
  <li><LargeComponentA /></li>
  <li><LargeComponentB /></li>
</ul>


// 新列表
<ul>
  <li><LargeComponentC /></li>
  <li><LargeComponentA /></li>
  <li><LargeComponentB /></li>
</ul>

上面的示例中,在渲染新列表,<LargeComponentA /><LargeComponentB />又会被重新创建一直对应的组件实例和 DOM 对象。显然,这造成了巨大的,且不必要的性能浪费。

此时,react key就应运而生。通过这个key属性,react 让开发者来告诉 react 自己,哪些节点是在新旧渲染周期上的数据表现上是一模一样的,从而来在新的渲染周期去复用旧的组件实例和 DOM 对象。简单来说,我们现在是依据 「key属性值的比较」而不是「节点在列表的顺序」来判断在新的渲染周期里面所复用的组件实例和 DOM 对象是什么。

如果我们给上面的示例都附上了一个列表内唯一的 key 值之后,我们的代码是这样的:

// 旧列表
<ul>
  <li key="A"><LargeComponentA /></li>
  <li key="B"><LargeComponentB /></li>
</ul>

// 新列表
<ul>
  <li key="C"><LargeComponentC /></li>
  <li key="A"><LargeComponentA /></li>
  <li key="B"><LargeComponentB /></li>
</ul>

那么,在新的实现中,diff 过程是这样的:

  1. 旧列表中有 keyC 的节点吗?没有,那么我们就创建新的组件实例和 DOM 对象;
  2. 旧列表中有 keyA 的节点吗?有,我们接着递归比对它俩的子树吧。结果是完全一样的,那么复用之前旧的组件实例和 DOM 对象;
  3. 旧列表中有 keyB 的节点吗?有,我们接着递归比对它俩的子树吧。结果是完全一样的,那么复用之前旧的组件实例和 DOM 对象;

显而易见,引入了key特性后,我们实现了我们想要的性能表现 - 确实是只需要为节点 <li key="C"><LargeComponentC /></li> 创建组件实例和 DOM 对象,其他节点完全可以复用之前旧的组件实例和 DOM 对象。

不过,严谨点地说,决定是否复用之前旧的组件实例和 DOM 对象,key 值的比较只是其中一个条件而已,还有另外一个条件是「element type」的比较。下面我们从源码实现的视角看看。

从源码中看 key 的 作用

react-reconciler 这个包里面的 ReactChildFiber.old.js 文件中,我们可以看到「单节点 reconcile 」的源码。下面,我们只关注key 相关的代码,所以做了代码上的精简:

 // packages/react-reconciler/src/ReactChildFiber.old.js 
 
  function reconcileSingleElement(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    element: ReactElement,
    lanes: Lanes,
  ): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    while (child !== null) {
      if (child.key === key) {
       // 省略部分代码......
        if (child.elementType === elementType ) {
            const existing = useFiber(child, element.props);
            existing.ref = coerceRef(returnFiber, child, element);
            existing.return = returnFiber;
            
            return existing;
          }
      } else {
        deleteChild(returnFiber, child);
      }
      child = child.sibling;
    }

    // 省略部分代码......
  }

从上面源码可以看出,在 diff 算法的实现过程中,如果 keyelement type 都是相同的话,则通过 useFiber() 函数,基于旧 fiber 节点和新 element 的 props 来复用旧 fiber 节点,否则直接删除旧 fiber 节点,创建新的 fiber 节点。而复用或者创建新的 fiber 节点就意味着复用旧的 DOM 对象或者创建新的 DOM 对象( HostComponent 类型的 fiber 节点所关联的 DOM 对象存放在它的 stateNode 属性上)。

为什么不建议使用数组的 index 值作为 key?

react 官方关于 key 的说法是「react key 最好是稳定的,在列表范围内唯一标识当前的列表元素数据」。这里,就衍生了一个面试常遇到的问题:“为什么不用使用数组的 index 值作为 key 的值呢?”。简单来回答:“因为这么做会很容易踩到坑”。注意这里的措辞:“容易踩到坑”。也就说,这么做并不是 100% 错误的,得看应用场景。如果面试官信誓旦旦地训导你说:“使用数组的 index 值作为 key 一定会导致程序出错”,那么你看着这篇文章后,你就可以反驳他这种不严谨的说法。

回到这个问题上。为什么不建议使用数组的 index 值作为 key 呢? 因为:

  • 一旦遇到数组重排(直接重排或者因为插入,删除元素导致的被动重排)
  • 且展示数组元素的 UI 组件里面包含了「非受控组件」

的时候,那么界面上就会出现渲染逻辑上的 bug。

下面,我们手写一个 demo 来验证一下:

import * as React from 'react';
const { useState, useEffect, useRef } = React;

const map4FirstRender = (window.map4FirstRender = {});
const map4SecondRender = (window.map4SecondRender = {});

function Item(props: { id: number; text: string; len: number }) {
  const inputRef = useRef(null);

  useEffect(() => {
    if (props.len === 2) {
      map4FirstRender[props.id] = inputRef.current;
    } else {
      map4SecondRender[props.id] = inputRef.current;
    }
  }, [props.len]);
  
  return (
    <div style={{marginBottom: 10}}>
      <label>ID : {props.id} </label> + <span>非受控组件 input </span>
      // 注意,是 `defaultValue` prop 让 `<input />` 组件成为了非受控组件
      <input ref={inputRef} defaultValue={props.text} />
    </div>
  );
}


export default function App() {
  const [list, setList] = useState([
    { id: 1, text: '第 1 项' },
    { id: 2, text: '第 2 项' },
  ]);
  return (
    <div>
      <button
        style={{marginBottom: 20}}
        onClick={() => {
          const newList = list.slice();
          newList.splice(1, 0, { id: 1.1, text: '第 1.1 项' });
          setList(newList);
        }}
      >
        在数组中间添加一个数组元素
      </button>
      {list.map((item, index) => (
        <Item key={index} {...item} len={list.length} />
      ))}
    </div>
  );
}

可以看到,在这个 demo 中做到了以下几点:

  • 第一,我们使用数组的 index 值作为 key 的值
  • 第二,渲染数组元素的 UI 组件里面包含非受控组件: <input ref={inputRef} defaultValue={props.text} />
  • 第三,我们通过往数组的中间插入了一个元素来触发列表的重渲染

那点击「在数组中间添加一个数组元素」按钮,结果是怎样的呢?结果如下:

image.png

我们期待新增节点的 <input > 框的初始值为「第 1.1 项」,但是实际上却是「第 2 项」,为什么会这样子呢?

相信你也猜到了。原因就是,新插入的节点的 key 值为 1,而这个值正是上一个渲染周期中 ID 为 2 的节点的 key 值。所以,react 在这里复用了之前的 DOM 对象,只是更新 DOM 对象上的属性。你也许会问, 那为什对于<input > 框来说,defaultValue 的值没有得到预期的更新呢? 这是因为defaultValue的语义决定了它的值只会在 DOM 节点初次创建的时候才会生效。而此时,react 复用了之前的 DOM 节点,并没有发生 DOM 节点的创建。所以,react 就会忽略掉这个值。最终,导致了界面的更新达不到我们的预期值。

为了验证,react 真的复用上一次渲染周期中 ID 为 2 的节点所关联的 DOM 对象,我在代码中加入了两个全局变量: map4FirstRendermap4SecondRender。点击按钮后,我们不妨在浏览器的控制台打印一下,看看 ID 为 1.1 的节点所关联的 DOM 对象是否就是上一轮渲染周期中ID 为 2 的节点所关联的 DOM 对象:

image.png

还是不相信?我们不妨手动操作一下 window.map4FirstRender[2] DOM 对象的 value 属性,看看新插入的节点是否会得到更新:

image.png

可以看到,我们成功修改了新增节点里面的 input 框的值。从而证明了第二轮渲染周期中,新增节点(即 ID 为 1.1 的节点)复用了上个渲染周期中,跟它具有相同 key 值(1)的 DOM 对象。而这个复用,造成了界面显示上出现了 bug。

值得指出的是,我在上面强调的是「渲染数组元素的 UI 组件里面包含非受控组件」。如果我们把 defaultValue 去掉,它还是「非受控组件」,还是会出现 bug。不信的话,我们在第一渲染中,在 ID 为 2 的节点中输入一些文字,然后再点击按钮,看看这些文字是不是也被「转移」到了新节点中来。

那假如,「渲染数组元素的 UI 组件里面包含的是受控组件」呢,比如,我们把defaultValue 改成 value。结果是,界面显示是符合预期的。因为这是「受控组件」,React 会根据最新的 value 值做出 DOM 属性的更新。

小结

回到问题上来。为什么不建议使用数组的 index 值作为 key?因为特定情况下,这么做会导致上一个渲染周期中的组件实例和 DOM 对象被意外复用,从而导致了界面显示的 bug。

那哪种特定情况呢?答曰:“以下情况:”

  • 列表元素会发生重排(无论被动还是主动)
  • 渲染列表元素的 UI 组件包含「非受控组件」

什么情况下能使用数组的 index 值作为 key

读到这里,你肯定明白了为什么我们只是说「不建议」使用数组的 index 值作为 key 的值,而不是说「一定不能」使用数组的 index 值作为 key 的值了吧。

那么,理论上说,我们什么情况下能使用数组的 index 值作为 key 的值呢?以下的情况之一就可以这么做:

  • 列表只会渲染一次
  • 列表的元素不会发生重排

但是,实际上为数组元素找一个唯一标识的 ID 值也不难,大不了就生成一个。所以,拿一个「唯一标识的 ID 值」作为 react key 的值显然是一件「一劳永逸」且「属于最佳实践」的事情。

react key 妙用

可能你也知道,react key 并不只能用于列表渲染场景上,它也可以用于单个组件的渲染场景上!

react 在 reconcil 过程中,如果当前渲染周期的 key值跟上一轮渲染周期的 key值不相等的话,react 会卸载当前组件实例,重新从头开始创建一个新的组件实例。以下 demo 示例就可以验证这一点:

import * as React from 'react';

function Counter() {
  console.log('Counter called');

  const [count, setCount] = React.useState(() => {
    console.log('Counter useState initializer');
    return 0;
  });
  const increment = () => setCount((c) => c + 1);

  React.useEffect(() => {
    console.log('Counter useEffect callback');
    return () => {
      console.log('Counter useEffect cleanup');
    };
  }, []);

  console.log('Counter returning react elements');
  return <button onClick={increment}>{count}</button>;
}

export default function CounterParent() {
  const [counterKey, setCounterKey] = React.useState(0);
  return (
    <div>
      <button onClick={()=> {setCounterKey(c=> c +1)}}>reset</button>
      <Counter key={counterKey} />
    </div>
  );
}

先点击<Counter> 组件的按钮,再点击<CounterParent> 组件的按钮,控制台的打印如下:

// 点击`<Counter>` 组件的按钮
Counter called
Counter returning react elements

// 点击`<CounterParent>` 组件的按钮
// 组件开始渲染
Counter called
Counter useState initializer
Counter returning react elements

// 卸载旧的组件实例
Counter useEffect cleanup

// 新的组件实例已经挂载到 fiber 节点上
Counter useEffect callback

主动去改变组件的key属性值,我们能够达到「卸载旧的组件实例和 DOM 对象,重新创建新的组件实例和 DOM 对象」的效果。利用这一点,我们可以实现类似上面的「状态重置」类型的任务。

还有某些情况,我们在同一个组件上去更新不同的数据的时候,你会发现更新失效,界面还是显示上一次的旧数据。如果事发紧急,那么我们就可以一个能够区分不同渲染周期的 ID 值作为这个组件的 key 值。通过这样,我们就会重新挂载组件实例,从而达到预期的组件更新效果。

总结

  • 「react key」是一个组件实例的唯一标识,主要用于提高 diff算法应用在渲染列表时候的性能。

  • 当应用场景同时满足以下两个条件的时候,「使用数组 index 作为 key 值」的做法是会造成界面显示上的 bug:

    • 列表元素会发生重排(无论被动还是主动);
    • 渲染列表元素的 UI 组件包含「非受控组件」。
  • 在不同的渲染周期去改变组件的 key 值,能够卸载旧的组件实例,重新创建新的组件实例。利用这一点,我们能够实现「组件状态重置」和「正确的数据更新」等效果。

  • 无论何时,在列表渲染的业务场景下,为列表元素组件设置一个「唯一标识的 ID 值」作为 react key 的值显然是一件一劳永逸,且属于最佳实践的事情。

参考资料