Menu

React 列表与 key:map()、key 属性与索引 key

在 React 中渲染列表,就是对数组调用 map(),为每一项返回 JSX,并给每一项一个 key prop。学习先 filter 再 map、key 应该从哪里来,以及为什么列表变化时用数组索引做 key 会让各项错乱。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

在 React 中渲染列表,就是对数组调用 map(),为每一项返回一段 JSX。给每一项的最外层元素加一个 key prop,值要能标识这一项,通常就是它的 ID,这样列表变化时 React 才能区分各项。

往数组里加上 { id: 'dat', name: 'Date', price: 4 },就会出现第四行。JSX 没有自己的循环语法:map 返回一个元素数组,而 React 渲染数组的方式就是按顺序渲染其中每个元素。

用 map 渲染数组

map 对每一项调用一次你的函数,并把结果收集成一个新数组。在 JSX 的花括号里,这个数组只是一个值,所以你可以像上面那样直接在标记中 map,也可以先把数组构建好:

const items = fruits.map((fruit) => <li key={fruit.id}>{fruit.name}</li>);
return <ul>{items}</ul>;

有两个细节经常让人出错。如果箭头函数用了花括号,就需要写 return:fruits.map((f) => { return <li key={f.id}>{f.name}</li>; })。不写的话函数返回 undefined,列表就是空的。另外,key 要放在 map 返回的元素上,而不是它内部的元素上。如果每一项是一个组件,就把 key 放在组件上:<FruitRow key={fruit.id} fruit={fruit} />。

先 filter,再 map

要只显示部分项,就先过滤数组,再对结果做 map。每个方法只做一件事,整条链读起来就像一句话:“匹配的水果,渲染成列表项”。

在搜索框里输入 er,列表会缩小为 Eraser、Ruler 和 Stapler;再勾选 In stock only,Ruler 也会消失。过滤后的列表是在渲染时根据这两份 state 计算出来的,所以 state 里没有需要保持同步的第二个数组。visible.length === 0 && 是 && 的安全用法,因为比较的结果是真正的布尔值(条件渲染一页展示了单独一个 0 会发生什么)。

key 的作用

列表重新渲染时,React 会把新的元素数组和上一次的进行比较。key 就是匹配它们的依据:现在 key 为 3 的元素,就是之前 key 为 3 的那个元素,即使它移动了位置。React 会保留该项的 DOM 节点和组件 state,只移动或更新发生变化的部分。新 key 对应的项会被创建,消失的 key 对应的项会被删除。

没有 key 时,React 唯一能用来匹配的就是位置。只要没有东西移动,这样没问题;一旦有东西移动,就会出错。

为什么索引 key 会出问题

map 会把索引作为第二个参数传入,所以 key={index} 很有诱惑力。但索引描述的是位置,而不是某一项。在顶部插入一项后,每一项的索引都会往后移一位,React 却以为第 0 项仍然是原来的第 0 项,于是把旧的 state 留在了那里。

下面的演示把同一个列表渲染两次,一次以索引为 key,一次以 ID 为 key。每一行都有一个输入框,各自保存自己的文字。

在两个列表中 Ada 旁边的输入框里都输入 math,然后点击 Add to top。在 ID 列表中,备注仍然跟着 Ada。在索引列表中,备注跳到了新加入的人那里,因为第一个输入框仍然是位置 0 上的输入框,而位置 0 现在是别人了。两个列表里的名字都是对的,因为名字来自数据;出错的只是保存在 DOM 和组件中的 state。真实应用中,复选框、焦点、动画以及任何持有 useState 的组件都会遇到这个问题。

只有同时满足以下条件时,索引 key 才是安全的:列表从不重排或过滤,除了在末尾之外从不插入或删除项,而且各项没有自己的 state。页脚的静态链接列表就符合条件。用户能编辑的内容通常都不符合。

key 从哪里来

最好的 key 是数据中已有的 ID:数据库 ID、商品 SKU、slug、用户名。来自服务器的数据几乎总会有一个。

对于在浏览器中创建的项,在创建时给每一项一个 ID,并和该项一起保存:可以像上面的演示那样用计数器,也可以用 crypto.randomUUID()。绝不要在渲染时生成 key:

// Wrong: a new key on every render, so React remounts every item every time
{todos.map((todo) => <Todo key={Math.random()} todo={todo} />)}

// Right: the ID is created once, in the event that adds the item
function addTodo(text) {
    setTodos([...todos, { id: crypto.randomUUID(), text }]);
}

每次渲染都会变的 key 比索引更糟:每一项每次都会被销毁并重新创建,输入时输入框会失去焦点,什么都保不住自己的 state。不过同样的技巧也可以有意使用:改变组件的 key 会重置它,useState 一页就用这种方法来清空表单。

key 只需在兄弟元素之间唯一

key 只需要在一个列表内唯一,而不是在整个应用中唯一。两个独立的列表可以重复使用相同的 ID,因为 React 只在同一个父元素的子元素之间比较 key。

两个分组里的菜品 key 都是 1 和 2,这是正确的:每个 ul 都是独立的列表。真正的问题是同一个列表内有重复的 key:React 无法区分冲突的项,列表更新时可能会重复显示或丢掉它们。

当一项要渲染多个相邻元素时,比如一个 <dt> 和一个 <dd>,就用 <Fragment key={item.id}> 把它们包起来。简写的 <> 语法不能带 key。

“unique key prop”警告

如果漏写 key,React 在开发环境中会在浏览器控制台打印:

Each child in a list should have a unique "key" prop.

Check the render method of `App`.

第二行指出了渲染这个列表的组件,告诉你该修哪个 map。两个兄弟元素使用相同 key 时,会得到另一条以“Encountered two children with the same key”开头的消息。本页的预览以生产构建的方式运行,会跳过这项检查,所以即使你删掉一个 key,这里也看不到警告。不妨在第一个例子里删掉一个试试:列表仍然能渲染,因为缺少 key 是正确性的隐患,而不会导致崩溃。要修复这个警告,就给 map 返回的元素加上稳定的 key;加 key={index} 虽然能让警告消失,却会带回上面演示的 bug。

还要注意,key 不会传给你的组件。在 FruitRow 内部,props.key 是 undefined。如果组件需要这个 ID,就再把它作为普通 prop 传一次:<FruitRow key={f.id} id={f.id} />。

常见问题

为什么 React 列表项需要 key?

key 告诉 React 在多次渲染之间哪一项对应哪一项。数组变化时,React 按 key 匹配新旧项,这样就能让每一项的 state 和 DOM 节点跟着正确的数据走,而不是按位置去猜。

可以用数组索引作为 key 吗?

只适用于从不改变顺序、也从不在中间插入或删除项的列表。如果项会移动,索引 key 会把 state 绑在位置上,输入的文字、复选框和焦点就会跑到错误的项上。

应该用什么作为 key?

数据本身的 ID:数据库 ID、商品 SKU、用户名。对于在浏览器中创建的项,在创建时生成一次 ID(计数器或 crypto.randomUUID()),绝不要在渲染时生成。

key 必须在整个应用中唯一吗?

不需要。key 只需要在同一个列表的兄弟元素之间唯一。两个不同的列表可以使用相同的 key。

组件能读取自己的 key 吗?

不能。key 由 React 使用,不会作为 prop 传给组件。如果组件需要这个 ID,就用另一个名字再传一次,比如 id={item.id}。

如何修复“Each child in a list should have a unique key prop”?

给 map 返回的最外层元素加上 key,使用该项稳定的 ID。如果这个元素是 Fragment,就用 <Fragment key={id}>,因为 <> 不能带 key。

Coddy 编程语言插图

和 Coddy 一起学编程

开始