JavaScript / 前端原理 / React
React 状态与 key:理解组件为什么被保留或重置
切换两个用户资料时,输入框为什么保留了上一个人的内容?列表排序后,勾选状态为什么跑到了另一行?给组件加一个随机 key,问题似乎消失了,但页面每次渲染都会丢失焦点。
这些现象都来自同一个规则:React 的状态不是“存放在 JSX 标签里面”,而是和组件在渲染树中的位置及身份关联。理解位置、组件类型和 key,才能有意识地决定状态应该保留还是重置。
一、状态绑定在渲染树的位置
React 官方文档强调,状态与渲染树中的位置关联。下面两个 Counter 各自拥有独立状态:
1 | export default function App() { |
虽然调用的是同一个组件函数,但它们出现在父组件的两个不同位置。点击第一个不会改变第二个。
如果同一个位置在下一次渲染仍是相同组件类型,React 通常会保留状态:
1 | function Panel({ isCompact }) { |
isCompact 变化只改变 class,Counter 的类型和位置不变,计数不会重置。
二、JSX 写在哪个分支不是关键
下面的代码在两个分支里都写了 <Counter />:
1 | function Scoreboard({ isPlayerA }) { |
从 React 的渲染树看,父节点同一个位置仍然是 Counter,所以切换 person prop 时状态会保留。源码中写了两份 JSX,不代表存在两个状态槽位。
如果希望两个玩家拥有独立分数,就要给身份:
1 | function Scoreboard({ isPlayerA }) { |
key 改变后,React 把它们视为两个不同组件,旧状态被销毁,新状态从初始值创建。
三、组件类型改变也会重置状态
同一个位置从 Counter 变成提示文本时,原组件会卸载:
1 | function Dashboard({ canEdit }) { |
再次变回 Editor,会得到新的状态。DOM 标签层级改变也可能使子树位置变化,进而重置内部状态。
一个隐蔽错误是在组件内部定义另一个组件:
1 | function ProfilePage() { |
ProfilePage 每次渲染都会创建新的 ProfileForm 函数,React 会把它视为不同组件类型,状态可能不断重置。组件定义应放在模块顶层:
1 | function ProfileForm() { |
四、key 是兄弟节点中的稳定身份
列表没有 key 时,React 主要按位置匹配。插入、删除和排序会让位置变化:
1 | function TodoList({ todos }) { |
todo.id 告诉 React:即使这一行从第三位移动到第一位,它仍然是同一个 todo。内部输入值、展开状态和焦点更有机会跟随正确数据项。
key 只需在当前兄弟列表中唯一,不要求全站唯一。它也不会作为普通 prop 传给组件:
1 | <TodoRow key={todo.id} todoId={todo.id} todo={todo} /> |
如果组件内部需要 ID,要显式传递 todoId。
五、为什么不要随便用数组索引
1 | todos.map((todo, index) => ( |
当列表只追加、永不排序删除时,索引可能暂时没有问题。但在可编辑列表中删除第一项后,原第二项占据 key 0,React 会复用原第一行的组件状态。于是文本输入、勾选或动画状态可能和数据错位。
应使用数据创建时就存在的稳定 ID。不要在渲染期间生成:
1 | // 错误:每次渲染都得到新身份 |
随机 key 会让所有行每次都卸载再挂载,丢失状态和焦点,还增加渲染成本。
六、用 key 有意重置表单
key 不只用于数组。切换编辑对象时,可以明确重置表单草稿:
1 | function ContactEditor({ selectedContact }) { |
1 | function EditContactForm({ contact }) { |
如果没有 key,useState(contact.name) 只在首次挂载时使用初始值,切换 contact 后草稿仍可能保留旧数据。以 contact ID 作为 key 表达了业务规则:“每个联系人有一份独立表单状态。”
但不要把 key 当成同步 prop 到 state 的万能修复。若状态本来可以直接从 props 计算,就不需要复制到本地;若用户草稿应该在对象间保留,则应把草稿提升到父级或按 ID 存入 Map,而不是强制重置。
七、先决定状态所有者,再决定 key
遇到状态错位时,可以先问:
- 这份状态属于“这个位置”,还是属于某个业务实体?
- 切换实体时应该保留、分别保存,还是立即清空?
- 稳定身份来自数据库 ID、路由参数还是组合键?
- 状态是否应该提升到更稳定的父组件?
例如多标签编辑器希望切换标签后保留每份草稿,单纯给 Editor 换 key 只会卸载当前状态。更合适的结构是父级维护:
1 | const [drafts, setDrafts] = useState(() => new Map()); |
每个文档 ID 对应一份草稿,key 负责组件身份,父级数据负责跨卸载持久化。
八、调试状态重置的顺序
- 在 React DevTools 中确认组件是否卸载后重新挂载。
- 查看同一位置的组件类型是否发生变化。
- 检查 key 是否变化、重复或来自数组索引。
- 检查组件是否定义在另一个组件函数内部。
- 确认条件渲染是否改变了父级树结构。
- 明确这是意外重置,还是业务上本应重置。
不要一看到状态不对就加 useEffect 同步。Effect 往往只是把身份问题变成更多更新时序问题。
九、行动清单
- 可编辑列表是否使用稳定数据 ID 作为 key?
- 是否存在随机 key 或会变化的拼接 key?
- 切换路由参数后,表单草稿应该保留还是清空?
- 组件是否被定义在另一个组件内部?
- key 重置是否会意外丢失焦点、滚动和未保存输入?
- 需要跨卸载保留的状态是否放到了更稳定的所有者?
key 不是为了消除控制台警告而随便填写的属性,它是 React 身份模型的一部分。先用“位置 + 类型”理解默认保留规则,再用稳定 key 表达业务实体,最后把需要持久化的状态放到正确所有者,组件的保留与重置就会从偶发现象变成可设计行为。
参考资料
- React:Preserving and Resetting State — https://react.dev/learn/preserving-and-resetting-state