Why Array Index as Key Is Bad in React

Saturday, September 17, 2016

从上一篇笔记《Introduction to Assumptions Made in React Reconciliation》分析,key 属性是优化列表结点的主要方式,而稳定、唯一、可预测的 key 属性有助于 Diff 算法计算出准确的 DOM 操作,然而数组的索引虽然唯一,但是并不”稳定”,也难以预测

Diff 算法无法保证 DOM 操作的准确性

ReactJS 引入 Virtual DOM 以及高效的 Diff 算法来提高页面的渲染效率。虽然 Facebook 工程师把 Diff 算法的复杂度从 O(n3) 降低到 O(n),但是 Diff 算法的准确性不高,因为它并不知晓 state 所发生的变化,其对比的新旧 Virtual DOM 也互相独立,所以只能通过逐层比较来推测需要执行的 DOM 操作,因此这种算法无法保证最终的 DOM 操作总是准确的,例如在某一个 DOM 结点 Node 前插入一个同类型的结点,Diff 算法会认为结点 Node 发生了数据更新而非位置发生变化,从而对应执行了 update 操作。为此 ReactJS 引入 shouldComponentUpdatecomponentWillUpdate 等生命周期函数和手动维护的 key 属性来协助提高 Diff 算法最终产生的 DOM 操作的准确性。

错误地使用这些依赖会导致 ReactJS 误判差异,最终产生错误的 DOM 操作,致使性能退化,甚至会出现奇怪的渲染结果(可能是组件的 state 丢失)。

典型的错误用法:索引当作 key 使用

典型的错误用法就是把数组索引当做组件的 key,如以下代码:

data.map((item, index) => <Component key={ index } { ...item } />)

有时我们的数据并不一定有唯一的 key,这时就需要我们手动生成,可能是为了贪图方便或者想当然,把数组遍历的 index 作为 key 传入 React Component。看上去每个 index 唯一地对应了数组的元素,但是实际上 index 并非标识了元素本身,仅仅标识了“地理位置”。

举例来说,第一次我们把数组 [A, B, C, D] 进行渲染,其 index 作为 key,那么渲染的结点表示为 0-A1-B2-C3-D

+---+   +---+   +---+   +---+
| 0 |   | 1 |   | 2 |   | 3 |
+---+   +---+   +---+   +---+
  A       B       C       D

某一时刻,我们对数组执行 push 操作,添加 E 元素,新数组为 [A, B, C, D, E],重新渲染的结果为:0-A1-B2-C3-D4-E。目前依然看似没有问题,但可能你已经发现潜在的问题了。

+---+   +---+   +---+   +---+
| 0 |   | 1 |   | 2 |   | 3 |
+---+   +---+   +---+   +---+
  A       B       C       D
                                +---+
                                | 4 |
                                +---+
                                  E

接下来我们对数组执行 unshift 操作,在首部添加 F 元素,新数组为 [F, A, B, C, D, E],此时重新渲染的结果变成了 0-F1-A2-B3-C4-D5-E。此时虽然这些元素本身并没有发生任何数据变化而只是数组发生了变化,但是原本的 A、B、C、D、E 元素的 key 已经发生变化,出现了一个新的 key 5,ReactJS 会误判元素发生了变化,于是 ReactJS 会认定 E 元素是一个新的元素,然后遍历更新前五个元素:A --> FB --> AC --> BD --> CE --> D,最后插入 E 元素。

+---+   +---+   +---+   +---+   +---+
| 0 |   | 1 |   | 2 |   | 3 |   | 4 |
+---+   +---+   +---+   +---+   +---+
  A       B       C       D       E
  +       +       +       +       +
  |       |       |       |       |
+-v-+   +-v-+   +-v-+   +-v-+   +-v-+   +---+
| 0 |   | 1 |   | 2 |   | 3 |   | 4 |   | 5 |
+---+   +---+   +---+   +---+   +---+   +---+
  F       A       B       C       D       E

同样地,我们对数组执行 splice 操作,移除 A 元素,新数组为 [F, B, C, D, E],渲染的结果为 0-F1-B2-C3-D4-E。元素本身并没有发生变化,仅仅数组发生变化,但是由于少了一个 key 5,所以 ReactJS 认定之前的 E 元素消失了,所以会先移除 E 元素,然后遍历更新 F 之后的四个元素:A --> BB --> CC --> DD --> E

+---+   +---+   +---+   +---+   +---+   +---+
| 0 |   | 1 |   | 2 |   | 3 |   | 4 |   | 5 |
+---+   +---+   +---+   +---+   +---+   +---+
  F       A       B       C       D       E
  +       +       +       +       +       +
  |       |       |       |       |       |
+-v-+   +-v-+   +-v-+   +-v-+   +-v-+     v
| 0 |   | 1 |   | 2 |   | 3 |   | 4 |   XXXXX
+---+   +---+   +---+   +---+   +---+
  F       B       C       D       E

总结

实际只对数组进行修改而非元素本身,由于错误地使用了 index 作为 key,导致 key 不断发生变化,使得 ReactJS 误判为元素发生了变化,最终产生了很多不必要的 DOM 操作,甚至丢失了组件原始的 state,例如上述例子的 E 元素一直存在,但是因为 key 的改变而被销毁,导致原来的 state 丢失。

对于这种数据本身没有唯一可标识的情况,我们应该手动维护。因为 key 只需保证同一列表内唯一,所以我们可以手动维护一个自增的 ID 生成器,如下:

let idGen = 0;
render() {
  return data.map((item) => {
    const id = item.__id ? item.__id : (item.__id = ++idGen)
    return <Component key={ id } { ...item } />
  })
}

还可以使用 lodash#uniqueId 来生成 key:

const id = item.__id ? item.__id : (item.__id = lodash.uniqueId())