react 常考面试题-为什么map循环中要有key

105 阅读10分钟

引言

在React开发中,我们经常会遇到需要渲染列表的场景,例如展示商品列表、用户评论或者待办事项。此时,Array.prototype.map()方法是我们的首选。然而,在使用map方法遍历数组并渲染组件时,React官方会提示一个警告:Each child in a list should have a unique "key" prop.(列表中的每个子项都应该有一个唯一的“key”属性)。这个小小的key属性,在React的面试中几乎是必考题,它不仅关系到React的渲染性能,更深层次地揭示了React内部的协调(Reconciliation)机制,也就是我们常说的虚拟DOM(Virtual DOM)和Diff算法。本文将深入探讨key属性的奥秘,帮助你彻底理解它在React中的重要性。

虚拟DOM与Diff算法:React高效渲染的基石

要理解key的作用,我们首先需要了解React是如何高效更新UI的。React并没有直接操作真实的DOM,而是引入了虚拟DOM的概念。虚拟DOM是一个轻量级的JavaScript对象,它是真实DOM在内存中的一个抽象表示。当组件的状态发生变化时,React会执行以下步骤:

  1. 生成新的虚拟DOM树:组件的render方法被调用,生成一个新的虚拟DOM树。
  2. Diff算法比较:React会将新的虚拟DOM树与旧的虚拟DOM树进行比较,找出两者之间的差异。这个比较过程就是著名的Diff算法。
  3. 最小化DOM操作:Diff算法会计算出最小的更新集,然后React只对真实DOM中发生变化的部分进行操作,而不是重新渲染整个页面。这大大减少了对真实DOM的操作,从而提高了应用的性能。

传统的DOM操作非常昂贵,频繁地操作DOM会导致页面重排(reflow)和重绘(repaint),从而降低应用性能。虚拟DOM和Diff算法的引入,正是为了解决这个问题,它将复杂的DOM操作抽象化,并通过高效的比较算法,实现了性能上的优化。

Key在Diff算法中的作用:身份标识的重要性

在React的Diff算法中,当比较两棵虚拟DOM树时,key属性扮演着至关重要的角色。想象一下,你有一个列表,里面有多个相同的组件,比如多个<li>标签。当这个列表发生变化时(例如,添加、删除或重新排序),React需要知道哪些组件是新增的、哪些是删除的、哪些是移动的,以及哪些只是内容发生了变化。如果没有key,React会默认采用“同层比较”和“按顺序比较”的策略,这在某些情况下会导致性能问题和不正确的UI更新。

具体来说,key的作用体现在以下几个方面:

  1. 唯一标识key为列表中的每个元素提供了一个稳定的唯一标识。当列表项的顺序发生变化时,React可以根据key值快速识别出哪些元素是同一个,从而避免不必要的DOM操作。例如,如果一个元素从列表的开头移动到了末尾,有了key,React会知道这是同一个元素,只需要移动它在真实DOM中的位置,而不是销毁旧的元素并创建一个新的。
  2. 提高Diff效率:在没有key的情况下,React会按照列表的顺序进行比较。如果列表的中间插入或删除了一个元素,那么从插入/删除点之后的所有元素都会被认为是新的,即使它们的内容并没有改变,React也会重新渲染它们。而有了key,React可以跳过那些key值相同的元素,只对key值不同的元素进行比较和更新,大大提高了Diff算法的效率。
  3. 保持组件状态:当列表项的顺序发生变化时,如果组件内部有自己的状态(例如,一个输入框的值),key能够确保这些状态与正确的组件实例关联。如果没有key,或者key不稳定,组件的状态可能会错乱,导致不符合预期的行为。

代码示例:有无Key的区别

为了更好地理解key的作用,我们来看一个简单的例子。假设我们有一个待办事项列表,并且允许用户删除列表中的任意一项。

没有key的情况:

import React, { useState } from 'react';

function TodoListWithoutKey() {
  const [todos, setTodos] = useState([
    { id: 1, text: '学习React' },
    { id: 2, text: '写博客' },
    { id: 3, text: '锻炼身体' },
  ]);

  const handleDelete = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <div>
      <h2>待办事项 (无Key)</h2>
      <ul>
        {todos.map((todo) => (
          <li>
            {todo.text}
            <button onClick={() => handleDelete(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoListWithoutKey;

注意:在实际开发中,即使没有显式指定key,React也会默认使用元素的索引作为key。但这种隐式行为在某些情况下会导致问题,我们将在下一节详细讨论。

使用key的情况:

import React, { useState } from 'react';

function TodoListWithKey() {
  const [todos, setTodos] = useState([
    { id: 1, text: '学习React' },
    { id: 2, text: '写博客' },
    { id: 3, text: '锻炼身体' },
  ]);

  const handleDelete = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <div>
      <h2>待办事项 (有Key)</h2>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}> {/* 使用todo.id作为key */} 
            {todo.text}
            <button onClick={() => handleDelete(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoListWithKey;

当你在没有key的情况下(或者使用index作为key)删除列表中的某个元素时,如果你在列表项中添加了输入框等可变状态的元素,你会发现删除中间的元素会导致后续元素的状态错乱。而使用稳定的id作为key时,这种问题就不会出现。

为什么不建议使用索引(index)作为Key?=

尽管React在没有显式指定key时会默认使用元素的索引作为key,但强烈不建议在列表顺序可能发生变化的情况下使用index作为key。这主要有以下几个原因:

  1. 性能问题:当列表项的顺序发生变化时(例如,列表项被添加、删除或重新排序),如果使用index作为key,React会认为key值相同的元素是同一个元素。然而,由于列表顺序的变化,同一个index对应的元素可能已经不是原来的那个元素了。这会导致React无法正确识别元素的移动,从而进行不必要的DOM操作(销毁旧的DOM元素,然后重新创建新的DOM元素),而不是简单地移动DOM元素,这会大大降低渲染性能。

    举例说明

    假设我们有一个列表 [A, B, C],使用index作为key,它们对应的key分别是 0:A, 1:B, 2:C

    现在我们删除了列表中的 B,列表变为 [A, C]。此时,React会进行如下比较:

    • 旧列表的 key=0 对应 A,新列表的 key=0 对应 A,两者匹配,A保持不变。
    • 旧列表的 key=1 对应 B,新列表的 key=1 对应 C。React会认为 B 变成了 C,而不是 B 被删除,C 移动了位置。因此,它会销毁 B 对应的DOM,然后创建一个新的DOM来表示 C
    • 旧列表的 key=2 对应 C,新列表中没有 key=2 的元素,C 被销毁。

    可以看到,原本只需要删除 B,并将 C 移动到 B 的位置,但由于index作为key,React进行了两次销毁和一次创建的操作,这显然是低效的。

当使用id作为key

现在我们删除了列表中的 B,列表变为 [A, C]。React 会进行如下比较:

  • 旧列表的 key=1 对应 A,新列表的 key=1 也对应 A,两者匹配,A 保持不变。
  • 旧列表的 key=2 对应 B,新列表中没有 key=2 的元素,所以 B 被销毁。
  • 旧列表的 key=3 对应 C,新列表的 key=3 也对应 C,两者匹配,React 会将 C 移动到 B 原来的位置。
  1. 状态错乱问题:如果列表项是组件,并且这些组件内部维护着自己的状态(例如,一个带有输入框的表单项),使用index作为key会导致状态错乱。当列表顺序发生变化时,React会根据index来复用组件实例,但此时index对应的组件可能已经不是原来的那个组件了,导致组件内部的状态与实际内容不匹配,从而出现难以调试的bug。

    举例说明

    假设我们有一个列表,每个列表项都是一个带有输入框的组件:

    function Item({ content }) {
      const [value, setValue] = useState("");
      return (
        <li>
          {content}
          <input value={value} onChange={(e) => setValue(e.target.value)} />
        </li>
      );
    }
    
    function App() {
      const [list, setList] = useState(["A", "B", "C"]);
    
      const handleRemoveB = () => {
        setList(["A", "C"]); // 移除B
      };
    
      return (
        <div>
          <ul>
            {list.map((item, index) => (
              <Item key={index} content={item} />
            ))}
          </ul>
          <button onClick={handleRemoveB}>移除B</button>
        </div>
      );
    }
    

    如果你在AB的输入框中分别输入内容,然后点击“移除B”,你会发现C的输入框中显示的是原来B输入框中的内容,而不是C自己的内容。这是因为React复用了key=1的组件实例,而这个实例原本是B,现在却被赋予了C的内容,但其内部状态依然是B的状态。

Key的最佳实践

为了避免上述问题,我们应该遵循以下key的最佳实践:

  1. 使用稳定且唯一的ID作为Key:最理想的key是数据项本身具备的、在整个应用生命周期中都不会改变的唯一标识符,例如数据库中的id字段。如果你的数据源提供了这样的id,请务必使用它作为key

    const todoItems = todos.map((todo) =>
      <li key={todo.id}>
        {todo.text}
      </li>
    );
    
  2. 避免使用index作为Key:除非你的列表是静态的,且永不改变顺序、添加或删除,否则不要使用index作为key。即使在这种情况下,使用稳定的id也是更好的选择。

  3. key只在兄弟节点之间唯一key的唯一性只需要在同一层级的兄弟节点之间保证即可,不需要全局唯一。这意味着在不同的map循环中,你可以使用相同的key值,只要它们不属于同一个父级下的兄弟节点。

  4. key不传递给组件key是React内部使用的特殊属性,它不会作为props传递给你的组件。如果你需要在组件内部访问key的值,你需要显式地将其作为另一个prop传递。

    const content = posts.map((post) =>
      <Post
        key={post.id} // key不会作为props传递
        id={post.id} // 如果需要显式传递id
        title={post.title} />
    );
    
  5. key应该在map()方法中的元素上指定:当你在map()方法中渲染元素时,key属性应该直接添加到map()回调函数返回的最外层元素上,而不是其内部的子元素。

    错误示例

    function ListItem(props) {
      return (
        // 错误!key不应该在这里指定
        <li key={props.value.toString()}>
          {props.value}
        </li>
      );
    }
    
    function NumberList(props) {
      const numbers = props.numbers;
      const listItems = numbers.map((number) =>
        // 错误!key应该在这里指定
        <ListItem value={number} />
      );
      return (
        <ul>
          {listItems}
        </ul>
      );
    }
    

    正确示例

    function ListItem(props) {
      // 正确!这里不需要指定key
      return <li>{props.value}</li>;
    }
    
    function NumberList(props) {
      const numbers = props.numbers;
      const listItems = numbers.map((number) =>
        // 正确!key应该在数组的上下文中被指定
        <ListItem key={number.toString()} value={number} />
      );
      return (
        <ul>
          {listItems}
        </ul>
      );
    }
    

总结

key属性在React的map循环中扮演着至关重要的角色,它是React高效更新UI的关键。通过为列表中的每个元素提供一个稳定且唯一的标识,key能够帮助React的Diff算法准确地识别元素的增删改查,从而避免不必要的DOM操作,提高渲染性能,并确保组件状态的正确性。虽然在某些情况下,不使用key或使用index作为key可能不会立即导致错误,但在面对动态列表和复杂组件时,这无疑会埋下性能隐患和难以排查的bug。因此,作为一名合格的React开发者,理解key的原理并遵循最佳实践,是提升应用性能和开发效率的必备技能。