列表、key 与状态身份:草稿究竟属于哪一行
问题:删除第一行,第二行的草稿为什么换了商品
一行组件可以有自己的局部状态。列表删除或重排后,React 必须判断新树里的这一行是否就是旧树里的那一行。数组位置只能说明“现在排第几”,业务 id 才能说明“它是谁”。
完整实验:先运行正确版本
import { useState } from 'react'
const initialProducts = [
{ id: 'tea', name: '乌龙茶' },
{ id: 'cup', name: '玻璃杯' },
{ id: 'bag', name: '帆布袋' },
]
function ProductRow({ product, onRemove }) {
const [draft, setDraft] = useState(product.name)
return (
<li>
<strong>{product.id}</strong>{' '}
<input
aria-label={`${product.name} 草稿`}
value={draft}
onChange={event => setDraft(event.target.value)}
/>
<button onClick={() => onRemove(product.id)}>删除</button>
</li>
)
}
export default function App() {
const [products, setProducts] = useState(initialProducts)
function removeProduct(id) {
setProducts(current => current.filter(product => product.id !== id))
}
return (
<main>
<h1>编辑商品</h1>
<button onClick={() => setProducts(current => [...current].reverse())}>
倒序
</button>
<button onClick={() => setProducts(initialProducts)}>恢复商品列表</button>
<ul>
{products.map(product => (
<ProductRow
key={product.id}
product={product}
onRemove={removeProduct}
/>
))}
</ul>
</main>
)
}运行后,把乌龙茶草稿改成“浓香乌龙”,再点“倒序”。草稿会跟着 tea 移到末尾。删除别的商品也不会改变它。
现在编辑 tea,删除它,再点“恢复商品列表”:它重新出现时,草稿回到“乌龙茶”。被删除的组件已经离开树,原来的局部 state 也被丢弃。这个按钮只恢复商品数组,仍留在树中的行保留各自草稿,并不会全部重置。useState(product.name) 的参数用于首次初始化,不是每次接收新 prop 都覆盖草稿。
React 如何匹配列表
一次重排前后,React 在同一个父元素的直接子节点中比较:
旧:tea → cup → bag
新:bag → cup → tea当 key 是业务 id 时,React 能把 tea 的组件实例、局部 state 和对应 DOM 与新位置的 tea 关联起来。key 必须满足两件事:
- 在当前兄弟列表中唯一;不同列表可以重复使用同一个值;
- 在同一业务项的生命周期里稳定,不能每次渲染重新生成。
key 是 React 的匹配提示,不会进入 ProductRow 的 props。若组件也要使用 id,仍要通过 product.id 或单独的 id prop 传入。
制造反例:改成索引 key
把映射改成:
{products.map((product, index) => (
<ProductRow key={index} product={product} onRemove={removeProduct} />
))}再次编辑第一行并倒序。位置 0 的旧组件会被拿来显示新的第一件商品,所以旧草稿留在位置上,看起来就像“草稿串台”。这不是 useState 随机出错,而是你告诉 React:身份等于位置。
再试 key={Math.random()},编辑后点击“倒序”。每次父组件重新计算列表,key 都不同,所有行都会被当成新组件,输入草稿被重置。只在某一行输入时,更新的是该行的 state,不一定重新运行父组件的 map,因此不要把反例的预期写成“每次按键都重置”。随机 UUID 只能在“创建数据项时”生成并保存到数据中,不能在渲染期间生成。
条件变化:什么时候索引可以接受
若列表从不插入、删除、排序,项目没有局部状态,顺序也不会影响 DOM 身份,索引 key 的风险较低。但这些条件很容易在需求增长后失效。能从数据得到稳定 id 时,直接使用 id 通常更便宜。
key 还可以有意重置状态。把某个编辑器写成 <Editor key={selectedId} />,选择另一条记录时组件身份改变,局部草稿会从头初始化。是否重置是产品语义,不是性能技巧。
筛掉之后还要保留草稿,数据应该住在哪里
稳定 key 只能关联前后两次都存在、类型相同的同级组件。过滤时没有返回某一行 JSX,就是把这一行从树中移除;以后即使用相同 key 加回来,也是重新挂载。若产品要求“隐藏座位后备注仍在”,备注的所有者必须活得比那一行更久。
沿用上一篇状态提升的方法:父组件存一个按 id 索引的对象,例如 { 'B-01': '靠窗' };每行接收自己的 note 和 onNoteChange。修改一个键时,要保留其他键:
setNotes(current => ({ ...current, [id]: nextText }))这段更新可放在父组件的事件处理函数中。子组件用 value={note} 显示,父组件读取不存在的键时用 notes[id] ?? '' 提供空字符串。过滤只改变“返回哪些行”,不删除 notes 里的数据。此时 key 负责行的匹配,父组件 state 负责跨卸载保存内容,各有一份明确职责。
边界
- key 只需在同一父节点的兄弟之间唯一,不要求全站唯一。
- key 放在
map直接返回的最外层元素上;若返回多个并列节点,使用带 key 的<Fragment>,短写<>不能接收 key。 - 稳定 key 能保住 React 身份,却不能修复你在数据层把两个对象错误复用成同一 id。
- key 不是“禁止重新渲染”的优化开关。props 改变时,同一身份的组件仍会重新计算 UI。
练习:可筛选的座位备注
完成一个 App.jsx:数据固定为三个 { id, label, zone } 座位;按钮可切换“全部”“A 区”和没有数据的“C 区”,另一个按钮把当前列表倒序。为了让被筛掉的座位再次出现时仍有备注,把备注按 id 保存在列表的共同父组件,SeatRow 保持受控。
契约:
- 先给
B-01输入备注,再筛选、恢复和倒序,备注始终属于B-01; - key 必须来自座位
id,不得使用索引或渲染时生成的值; - 空筛选结果显示“没有座位”;
SeatRow必须是模块顶层组件。
完成后对照答案 02。