Skip to content

列表、key 与状态身份:草稿究竟属于哪一行 ​

问题:删除第一行,第二行的草稿为什么换了商品 ​

一行组件可以有自己的局部状态。列表删除或重排后,React 必须判断新树里的这一行是否就是旧树里的那一行。数组位置只能说明“现在排第几”,业务 id 才能说明“它是谁”。

完整实验:先运行正确版本 ​

jsx
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 在同一个父元素的直接子节点中比较:

text
旧:tea → cup → bag
新:bag → cup → tea

当 key 是业务 id 时,React 能把 tea 的组件实例、局部 state 和对应 DOM 与新位置的 tea 关联起来。key 必须满足两件事:

  • 在当前兄弟列表中唯一;不同列表可以重复使用同一个值;
  • 在同一业务项的生命周期里稳定,不能每次渲染重新生成。

key 是 React 的匹配提示,不会进入 ProductRow 的 props。若组件也要使用 id,仍要通过 product.id 或单独的 id prop 传入。

制造反例:改成索引 key ​

把映射改成:

jsx
{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。修改一个键时,要保留其他键:

js
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。

下一章 ​

状态快照与不可变更新

用心记录每一步成长