likes
comments
collection
share

react 面试必问 - react key

作者站长头像
站长
· 阅读数 37

前言

「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 对象( 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} />
  • 第三,我们通过往数组的中间插入了一个元素来触发列表的重渲染

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

react 面试必问 - react key

我们期待新增节点的 <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 对象:

react 面试必问 - react key

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

react 面试必问 - react key

可以看到,我们成功修改了新增节点里面的 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 的值显然是一件一劳永逸,且属于最佳实践的事情。

参考资料

转载自:https://juejin.cn/post/7203254152189083705
评论
请登录