React列表中的key属性

3,077 阅读17分钟

前言

最高效的学习方法就是把知识讲给别人听。

本来是个很简单的问题,但是感觉还是应该记一下,里面涉及到很多细节都可能成为一个知识盲区。

渲染多个组件

我们使用 map() 函数让数组中的每一项变双倍,然后我们得到了一个新的列表 doubled 并打印出来:

const numbers = [1, 2, 3, 4, 5]
const doubled = numbers.map((number) => number * 2)
console.log(doubled)

代码打印出 [2, 4, 6, 8, 10]。 在 React 中,把数组转化为元素列表的过程是相似的。

你可以通过使用 {} JSX 内构建一个元素集合。

下面,我们使用 Javascript 中的 map() 方法来遍历 numbers 数组。将数组中的每个元素变成 <li> 标签,最后我们将得到的数组赋值给 listItems

const numbers = [1, 2, 3, 4, 5]
const listItems = numbers.map((number) =>
  <li>{number}</li>
)

我们把整个 listItems 插入到 <ul> 元素中,然后渲染进 DOM

ReactDOM.render(
  <ul>{listItems}</ul>,
  document.getElementById('root')
)

这段代码生成了一个 1 5的项目符号列表。

基础列表组件

通常你需要在一个组件中渲染列表。

我们可以把前面的例子重构成一个组件,这个组件接收 numbers 数组作为参数并输出一个元素列表。

function NumberList(props) {
  const numbers = props.numbers;
  const listItems = numbers.map((number) =>
    <li>{number}</li>
  );
  return (
    <ul>{listItems}</ul>
  );
}

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(
  <NumberList numbers={numbers} />,
  document.getElementById('root')
)

当我们运行这段代码,将会看到一个警告 a key should be provided for list items,意思是当你创建一个元素时,必须包括一个特殊的 key 属性。

让我们来给每个列表元素分配一个 key 属性来解决上面的那个警告:

function NumberList(props) {
  const numbers = props.numbers;
  const listItems = numbers.map((number) =>
    <li key={number.toString()}>
      {number}
    </li>
  );
  return (
    <ul>{listItems}</ul>
  );
}

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(
  <NumberList numbers={numbers} />,
  document.getElementById('root')
)

key

key 帮助 React 识别哪些元素改变了,比如被添加或删除。因此你应当给数组中的每一个元素赋予一个确定的标识。

const numbers = [1, 2, 3, 4, 5];
const listItems = numbers.map((number) =>
  <li key={number.toString()}>
    {number}
  </li>
)

一个元素的 key 最好是这个元素在列表中拥有的一个独一无二的字符串。通常,我们使用数据中的 id 来作为元素的 key

const todoItems = todos.map((todo) =>
  <li key={todo.id}>
    {todo.text}
  </li>
)

当元素没有确定 id 的时候,万不得已你可以使用元素索引 index 作为 key

const todoItems = todos.map((todo, index) =>
  // Only do this if items have no stable IDs
  <li key={index}>
    {todo.text}
  </li>
)

如果列表项目的顺序可能会变化,我们不建议使用索引来用作 key 值,因为这样做会导致性能变差,还可能引起组件状态的问题。可以看看 Robin Pokorny深度解析使用索引作为 key 的负面影响这一篇文章。如果你选择不指定显式的 key 值,那么 React 将默认使用索引用作为列表项目的 key 值。

要是你有兴趣了解更多的话,这里有一篇文章深入解析为什么 key 是必须的可以参考。

缺少key常见警告如下图:

用 key 提取组件

元素的 key 只有放在就近的数组上下文中才有意义。

比方说,如果你提取出一个 ListItem 组件,你应该把 key 保留在数组中的这个 <ListItem /> 元素上,而不是放在 ListItem 组件中的 <li> 元素上。

例子:不正确的使用 key 的方式

function ListItem(props) {
  const value = props.value;
  return (
    // 错误!你不需要在这里指定 key:
    <li key={value.toString()}>
      {value}
    </li>
  );
}

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

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(
  <NumberList numbers={numbers} />,
  document.getElementById('root')
);

例子:正确的使用 key 的方式

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>
  );
}

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(
  <NumberList numbers={numbers} />,
  document.getElementById('root')
)

一个好的经验法则是:在 map() 方法中的元素需要设置 key 属性。

key 只是在兄弟节点之间必须唯一

数组元素中使用的 key 在其兄弟节点之间应该是独一无二的。然而,它们不需要是全局唯一的。当我们生成两个不同的数组时,我们可以使用相同的 key 值:

function Blog(props) {
  const sidebar = (
    <ul>
      {props.posts.map((post) =>
        <li key={post.id}>
          {post.title}
        </li>
      )}
    </ul>
  );
  const content = props.posts.map((post) =>
    <div key={post.id}>
      <h3>{post.title}</h3>
      <p>{post.content}</p>
    </div>
  );
  return (
    <div>
      {sidebar}
      <hr />
      {content}
    </div>
  );
}

const posts = [
  {id: 1, title: 'Hello World', content: 'Welcome to learning React!'},
  {id: 2, title: 'Installation', content: 'You can install React from npm.'}
];
ReactDOM.render(
  <Blog posts={posts} />,
  document.getElementById('root')
)

key 会传递信息给 React ,但不会传递给你的组件。如果你的组件中需要使用 key 属性的值,请用其他属性名显式传递这个值:

const content = posts.map((post) =>
  <Post
    key={post.id}
    id={post.id}
    title={post.title} />
)

上面例子中,Post 组件可以读出 props.id,但是不能读出 props.key

在 JSX 中嵌入 map()

在上面的例子中,我们声明了一个单独的 listItems 变量并将其包含在 JSX 中:

function NumberList(props) {
  const numbers = props.numbers;
  const listItems = numbers.map((number) =>
    <ListItem key={number.toString()}
              value={number} />
  );
  return (
    <ul>
      {listItems}
    </ul>
  );
}

JSX 允许在大括号中嵌入任何表达式,所以我们可以内联 map() 返回的结果:

function NumberList(props) {
  const numbers = props.numbers;
  return (
    <ul>
      {numbers.map((number) =>
        <ListItem key={number.toString()}
                  value={number} />
      )}
    </ul>
  );
}

这么做有时可以使你的代码更清晰,但有时这种风格也会被滥用。就像在 JavaScript 中一样,何时需要为了可读性提取出一个变量,这完全取决于你。但请记住,如果一个 map() 嵌套了太多层级,那可能就是你提取组件的一个好时机。

对子节点进行递归

在默认条件下,当递归 DOM 节点的子元素时,React 会同时遍历两个子元素的列表;当产生差异时,生成一个 mutation

在子元素列表末尾新增元素时,更新开销比较小。比如:

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

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

React 会先匹配两个 <li>first</li> 对应的树,然后匹配第二个元素 <li>second</li> 对应的树,最后插入第三个元素的 <li>third</li> 树。

如果只是简单的将新增元素插入到表头,那么更新开销会比较大。比如:

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React 不会意识到应该保留 <li>Duke</li><li>Villanova</li>,而是会重建每一个子元素 。这种情况会带来性能问题。

Keys

为了解决以上问题,React 支持 key 属性。当子元素拥有 key 时,React 使用 key 来匹配原有树上的子元素以及最新树上的子元素。以下例子在新增 key 之后使得之前的低效转换变得高效:

 <ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

现在 React 知道只有带着 '2014' key 的元素是新元素,带着 '2015' 以及 '2016' key 的元素仅仅移动了。

现实场景中,产生一个 key 并不困难。你要展现的元素可能已经有了一个唯一 ID,于是 key 可以直接从你的数据中提取:

<li key={item.id}>{item.name}</li>

当以上情况不成立时,你可以新增一个 ID 字段到你的模型中,或者利用一部分内容作为哈希值来生成一个 key。这个key不需要全局唯一,但在列表中需要保持唯一。

最后,你也可以使用元素在数组中的下标作为 key。这个策略在元素不进行重新排序时比较合适,如果有顺序修改,diff 就会变得慢。

当基于下标的组件进行重新排序时,组件 state 可能会遇到一些问题。由于组件实例是基于它们的 key 来决定是否更新以及复用,如果 key 是一个下标,那么修改顺序时会修改当前的 key,导致非受控组件的 state(比如输入框)可能相互篡改导致无法预期的变动。

权衡

请谨记协调算法是一个实现细节。React 可以在每个 action 之后对整个应用进行重新渲染,得到的最终结果也会是一样的。在此情境下,重新渲染表示在所有组件内调用 render 方法,这不代表 React 会卸载或装载它们。React 只会基于以上提到的规则来决定如何进行差异的合并。

我们定期探索优化算法,让常见用例更高效地执行。在当前的实现中,可以理解为一棵子树能在其兄弟之间移动,但不能移动到其他位置。在这种情况下,算法会重新渲染整棵子树。

由于 React 依赖探索的算法,因此当以下假设没有得到满足,性能会有所损耗。

  1. 该算法不会尝试匹配不同组件类型的子树。如果你发现你在两种不同类型的组件中切换,但输出非常相似的内容,建议把它们改成同一类型。在实践中,我们没有遇到这类问题。
  2. Key 应该具有稳定,可预测,以及列表内唯一的特质。不稳定的 key(比如通过 Math.random() 生成的)会导致许多组件实例和 DOM 节点被不必要地重新创建,这可能导致性能下降和子组件中的状态丢失。

详解diff算法

《深入React技术栈》这本书中写道:diff 作为 Virtual DOM 的加速器,其算法上的改进优化是 React 整个界面渲染的基础和性能 保障,同时也是 React 源码中最神秘、最不可思议的部分。

React 中最值得称道的部分莫过于 Virtual DOM 模型与 diff 的完美结合,特别是其高效的 diff 算法,可以让用户无需顾忌性能问题而“任性自由”地刷新页面,让开发者也可以无需关心 Virtual DOM 背后的运作原理。因为 diff 会帮助我们计算出 Virtual DOM 中真正变化的部分,并只针对该部分进行原生 DOM 操作,而非重新渲染整个页面,从而保证了每次操作更新后页面 的高效渲染。因此,Virtual DOM 模型与 diff 是保证 React 性能口碑的幕后推手。 diff 算法也并非其首创。正是因为该算法的普适度高,就更应该认可 React 针对 diff 算法优 化所做的努力与贡献,这更能体现 React 创作者们的魅力与智慧!

传统diff算法

计算一棵树形结构转换成另一棵树形结构的最少操作,是一个复杂且值得研究的问题。传统 diff 算法通过循环递归对节点进行依次对比,效率低下,算法复杂度达到 O(n3),其中 n 是树中 节点的总数。O(n3) 到底有多可怕呢?这意味着如果要展示 1000 个节点,就要依次执行上十亿次的比较。这种指数型的性能消耗对于前端渲染场景来说代价太高了。如今的 CPU每秒钟能执行 大约30 亿条指令,即便是最高效的实现,也不可能在一秒内计算出差异情况。 因此,如果 React 只是单纯地引入 diff 算法而没有任何的优化改进,那么其效率远远无法满 足前端渲染所要求的性能。如果想要将 diff 思想引入 Virtual DOM,就要设计一种稳定、高效的 diff 算法,这个 React 做到了! 那么,diff 到底是如何实现的呢?

详解diff

React Virtual DOM 树转换成 actual DOM 树的最少操作的过程称为调和(reconciliation)。 diff 算法便是调和的具体实现。那么这个过程是怎么实现的呢?

React 通过制定大胆的策略,将 O(n3) 复杂度的问题转换成 O(n) 复杂度的问题。

O(n)是一个完全线性的算法,广度优先的分层比较,从根节点开始比较

1. diff 策略

下面介绍 React diff 算法的 3 个策略。

  • 策略一:Web UIDOM 节点跨层级的移动操作特别少,可以忽略不计。
  • 策略二:拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
  • 策略三:对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

基于以上策略,React 分别对 tree diffcomponent diff 以及 element diff 进行算法优化。事实 也证明这 3 个前提策略是合理且准确的,它保证了整体界面构建的性能。

2. tree diff

基于策略一,React 对树的算法进行了简洁明了的优化,即对树进行分层比较两棵树只会对同一层次的节点进行比较。 既然 DOM 节点跨层级的移动操作少到可以忽略不计,针对这一现象,React 通过updateDepthVirtual DOM 树进行层级控制,只会对相同层级的 DOM 节点进行比较,即同一个父节点下的所有子节点。当发现节点已经不存在时,则该节点及其子节点会被完全删除掉,不会用于进一步 的比较。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。

// updateChildren 方法对应的源码如下:
updateChildren: function(nextNestedChildrenElements, transaction, context) { 
  updateDepth++;
  var errorThrown = true;
  try {
    this._updateChildren(nextNestedChildrenElements, transaction, context);
    errorThrown = false; 
  } finally {
    updateDepth--;
    if (!updateDepth) {
      if (errorThrown) { 
        clearQueue();
      } else {
        processQueue();
      } 
    }
  } 
}

你可能存在这样的疑问:如果出现了 DOM 节点跨层级的移动操作,diff 会有怎样的表现呢?

如下图所示,A 节点(包括其子节点)整个被移动到 D 节点下,由于 React 只会简单地 考虑同层级节点的位置变换,而对于不同层级的节点,只有创建和删除操作。当根节点发现子节点中A消失了,就会直接销毁 A;当 D 发现多了一个子节点 A,则会创建新的 A(包括子节点) 作为其子节点。此时,diff 的执行情况:create A → create B → create C → delete A

由此可以发现,当出现节点跨层级移动时,并不会出现想象中的移动操作,而是以 A 为根节 点的整个树被重新创建。这是一种影响 React 性能的操作,因此官方建议不要进行 DOM 节点跨 层级的操作。

注意 在开发组件时,保持稳定的 DOM 结构会有助于性能的提升。例如,可以通过 CSS 隐藏 或显示节点,而不是真正地移除或添加 DOM 节点。

3. component diff

React 是基于组件构建应用的,对于组件间的比较所采取的策略也是非常简洁、高效的。

  • 如果是同一类型的组件,按照原策略继续比较Virtual DOM树即可。
  • 如果不是,则将该组件判断为 dirty component,从而替换整个组件下的所有子节点。
  • 对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切知道这点,那么就可以节省大量的 diff 运算时间。因此,React 允许用户通过shouldComponentUpdate() 来判断该组件是否需要进行 diff 算法分析。

如下图,当组件D变为组件 G 时,即使这两个组件结构相似,一旦 React 判断 DG 是不同类型的组件,就不会比较二者的结构,而是直接删除组件 D,重新创建组件 G 及其子节 点。虽然当两个组件是不同类型但结构相似时,diff 会影响性能,但正如 React 官方博客所言: 不同类型的组件很少存在相似 DOM 树的情况,因此这种极端因素很难在实际开发过程中造成重大的影响。

4. element diff

节点处于同一层级时,diff 提供了 3 种节点操作,分别为 INSERT_MARKUP(插入)MOVE_EXISTING(移动)REMOVE_NODE(删除)

  • INSERT_MARKUP:新的组件类型不在旧集合里,即全新的节点,需要对新节点执行插入操作。
  • MOVE_EXISTING:旧集合中有新组件类型,且 element 是可更新的类型,generateComponentChildren 已调用 receiveComponent,这种情况下 prevChild=nextChild,就需要做移动操 作,可以复用以前的 DOM 节点。
  • REMOVE_NODE:旧组件类型,在新集合里也有,但对应的 element 不同则不能直接复用和更新,需要执行删除操作,或者旧组件不在新集合里的,也需要执行删除操作。

如下图所示,旧集合中包含节点A、B、C 和 D,更新后的新集合中包含节点 B、A、D 和 C,此时新旧集合进行 diff 差异化对比,发现 B != A,则创建并插入 B 至新集合,删除旧集合 A; 以此类推,创建并插入 A、D 和 C,删除 B、C 和 D

React 发现这类操作烦琐冗余,因为这些都是相同的节点,但由于位置发生变化,导致需要 进行繁杂低效的删除、创建操作,其实只要对这些节点进行位置移动即可。

针对这一现象,React 提出优化策略:允许开发者对同一层级的同组子节点,添加唯一 key 进行区分,虽然只是小小的改动,性能上却发生了翻天覆地的变化!

新旧集合所包含的节点如下图所示,进行 diff 差异化对比后,通过 key 发现新旧集合中的节点都是相同的节点,因此无需进行节点删除和创建,只需要将旧集合中节点的位置进行移动, 更新为新集合中节点的位置,此时 React 给出的 diff 结果为:B、D 不做任何操作,A、C 进行移 动操作即可。

那么,如此高效的 diff 到底是如何运作的呢?

首先,对新集合中的节点进行循环遍历 for (name in nextChildren),通过唯一的 key 判断 新旧集合中是否存在相同的节点 if (prevChild === nextChild),如果存在相同节点,则进行移 动操作,但在移动前需要将当前节点在旧集合中的位置与 lastIndex 进行比较 if (child._mountIndex < lastIndex),否则不执行该操作。这是一种顺序优化手段,lastIndex 一直在更新,表示访问过的节点在旧集合中最右的位置(即最大的位置)。如果新集合中当前访问的节点比 lastIndex 大,说明当前访问节点在旧集合中就比上一个节点位置靠后,则该节点不会 影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作。只有当访问的节点比 lastIndex 小时,才需要进行移动操作。

在下图中,下面更为清晰直观地描述 diff 的差异化对比过程。

  • 从新集合中取得B,然后判断旧集合中是否存在相同节点 B,此时发现存在节点 B,接着通过对比节点位置判断是否进行移动操作。B 在旧集合中的位置 B._mountIndex = 1,此时 lastIndex = 0,不满足 child._mountIndex < lastIndex 的条件,因此不对 B 进行移动 操作。更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),其中 prevChild._ mountIndex 表示B在旧集合中的位置,则lastIndex = 1,并将B的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集合中 B._mountIndex = 0nextIndex++ 进入下一个节点的判断。
  • 从新集合中取得 A,然后判断旧集合中是否存在相同节点 A,此时发现存在节点 A,接着 通过对比节点位置判断是否进行移动操作。A 在旧集合中的位置 A._mountIndex = 0,此 时 lastIndex = 1,满足 child._mountIndex < lastIndex 的条件,因此对 A 进行移动操作 enqueueMove(this, child._mountIndex, toIndex),其中 toIndex 其实就是 nextIndex,表 示 A 需要移动到的位置。更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex), 则lastIndex = 1,并将 A 的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集合中 A._mountIndex = 1nextIndex++ 进入下一个节点的判断。
  • 从新集合中取得 D,然后判断旧集合中是否存在相同节点 D,此时发现存在节点 D,接着 通过对比节点位置判断是否进行移动操作。D 在旧集合中的位置 D._mountIndex = 3,此 时 lastIndex = 1,不满足 child._mountIndex < lastIndex 的条件,因此不对 D 进行移动操作。更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 3,并将 D 的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集6合中D._mountIndex = 2nextIndex++ 进入下一个节点的判断。
  • 从新集合中取得 C,然后判断旧集合中是否存在相同节点 C,此时发现存在节点 C,接着 通过对比节点位置判断是否进行移动操作。C 在旧集合中的位置 C._mountIndex = 2,此时 lastIndex = 3,满足 child._mountIndex < lastIndex的条件,因此对 C 进行移动操作 enqueueMove(this, child._mountIndex, toIndex)。更新lastIndex = Math.max(prevChild. 7 _mountIndex, lastIndex),则 lastIndex = 3,并将 C 的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集合中 A._mountIndex = 3nextIndex++ 进 入下一个节点的判断。由于 C 已经是最后一个节点,因此 diff 操作到此完成。

上面主要分析新旧集合中存在相同节点但位置不同时,对节点进行位置移动的情况。如果新 集合中有新加入的节点且旧集合存在需要删除的节点,那么 diff 又是如何对比运作的呢?

  • 从新集合中取得B,然后判断旧集合中存在是否相同节点 B,可以发现存在节点 B。由于 B 在旧集合中的位置 B._mountIndex = 1,此时 lastIndex = 0,因此不对 B 进行移动操作。 更新lastIndex = 1,并将 B 的位置更新为新集合中的位置 B._mountIndex = 0nextIndex++ 进入下一个节点的判断。
  • 从新集合中取得 E,然后判断旧集合中是否存在相同节点 E,可以发现不存在,此时可以 创建新节点 E。更新 lastIndex = 1,并将 E 的位置更新为新集合中的位置,nextIndex++ 进入下一个节点的判断。
  • 从新集合中取得 C,然后判断旧集合中是否存在相同节点 C,此时可以发现存在节点 C。 由于 C 在旧集合中的位置 C._mountIndex = 2lastIndex = 1,此时 C._mountIndex > lastIndex,因此不对 C 进行移动操作。更新 lastIndex = 2,并将 C 的位置更新为新集 合中的位置,nextIndex++ 进入下一个节点的判断。
  • 从新集合中取得 A,然后判断旧集合中是否存在相同节点 A,此时发现存在节点 A。由于 A 在旧集合中的位置 A._mountIndex = 0lastIndex = 2,此时 A._mountIndex < lastIndex, 因此对 A 进行移动操作。更新 lastIndex = 2,并将A的位置更新为新集合中的位置, nextIndex++ 进入下一个节点的判断。
  • 当完成新集合中所有节点的差异化对比后,还需要对旧集合进行循环遍历,判断是否存 在新集合中没有但旧集合中仍存在的节点,此时发现存在这样的节点 D,因此删除节点 D, 到此 diff 操作全部完成。

创建、移动、删除节点 创建、移动、删除节点

当然,diff 还存在些许不足与待优化的地方。如下图所示,若新集合的节点更新为 D、A、 B、C,与旧集合相比只有 D 节点移动,而 A、B、C 仍然保持原有的顺序,理论上 diff 应该只需对 D执行移动操作,然而由于D在旧集合中的位置是最大的,导致其他节点的 _mountIndex < lastIndex,造成 D 没有执行移动操作,而是 A、B、C 全部移动到 D 节点后面的现象。

新集合的节点更新为D、A、B、C

在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作。当节点数量过大或更新操作过于频繁时,这在一定程度上会影响 React 的渲染性能。

总结

  1. diff算法的复杂度为O(n)是一个完全线性的算法,广度优先的分层比较,从根节点开始比较。
  2. 可以用索引index来用作 key 值,在不涉及到同级节点移动、删除、修改时可以这么做,但是不建议这么做,这样可能引起组件状态的问题。
  3. diff策略涉及到tree diff、component diff、element diff,我们所用的key就是element diff的相关内容。