← 返回文章

JavaScript / 前端原理 / React

React 状态与 key:理解组件为什么被保留或重置

切换两个用户资料时,输入框为什么保留了上一个人的内容?列表排序后,勾选状态为什么跑到了另一行?给组件加一个随机 key,问题似乎消失了,但页面每次渲染都会丢失焦点。

这些现象都来自同一个规则:React 的状态不是“存放在 JSX 标签里面”,而是和组件在渲染树中的位置及身份关联。理解位置、组件类型和 key,才能有意识地决定状态应该保留还是重置。

一、状态绑定在渲染树的位置

React 官方文档强调,状态与渲染树中的位置关联。下面两个 Counter 各自拥有独立状态:

1
2
3
4
5
6
7
8
export default function App() {
return (
<main>
<Counter />
<Counter />
</main>
);
}

虽然调用的是同一个组件函数,但它们出现在父组件的两个不同位置。点击第一个不会改变第二个。

如果同一个位置在下一次渲染仍是相同组件类型,React 通常会保留状态:

1
2
3
4
5
6
7
function Panel({ isCompact }) {
return (
<section className={isCompact ? 'compact' : 'regular'}>
<Counter />
</section>
);
}

isCompact 变化只改变 class,Counter 的类型和位置不变,计数不会重置。

二、JSX 写在哪个分支不是关键

下面的代码在两个分支里都写了 <Counter />

1
2
3
4
5
6
7
function Scoreboard({ isPlayerA }) {
return isPlayerA ? (
<Counter person="Taylor" />
) : (
<Counter person="Sarah" />
);
}

从 React 的渲染树看,父节点同一个位置仍然是 Counter,所以切换 person prop 时状态会保留。源码中写了两份 JSX,不代表存在两个状态槽位。

如果希望两个玩家拥有独立分数,就要给身份:

1
2
3
4
5
6
7
function Scoreboard({ isPlayerA }) {
return isPlayerA ? (
<Counter key="taylor" person="Taylor" />
) : (
<Counter key="sarah" person="Sarah" />
);
}

key 改变后,React 把它们视为两个不同组件,旧状态被销毁,新状态从初始值创建。

三、组件类型改变也会重置状态

同一个位置从 Counter 变成提示文本时,原组件会卸载:

1
2
3
4
5
6
7
function Dashboard({ canEdit }) {
return (
<section>
{canEdit ? <Editor /> : <ReadOnlyNotice />}
</section>
);
}

再次变回 Editor,会得到新的状态。DOM 标签层级改变也可能使子树位置变化,进而重置内部状态。

一个隐蔽错误是在组件内部定义另一个组件:

1
2
3
4
5
6
7
8
function ProfilePage() {
function ProfileForm() {
const [name, setName] = useState('');
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

return <ProfileForm />;
}

ProfilePage 每次渲染都会创建新的 ProfileForm 函数,React 会把它视为不同组件类型,状态可能不断重置。组件定义应放在模块顶层:

1
2
3
4
5
6
7
function ProfileForm() {
// ...
}

function ProfilePage() {
return <ProfileForm />;
}

四、key 是兄弟节点中的稳定身份

列表没有 key 时,React 主要按位置匹配。插入、删除和排序会让位置变化:

1
2
3
4
5
function TodoList({ todos }) {
return todos.map((todo) => (
<TodoRow key={todo.id} todo={todo} />
));
}

todo.id 告诉 React:即使这一行从第三位移动到第一位,它仍然是同一个 todo。内部输入值、展开状态和焦点更有机会跟随正确数据项。

key 只需在当前兄弟列表中唯一,不要求全站唯一。它也不会作为普通 prop 传给组件:

1
<TodoRow key={todo.id} todoId={todo.id} todo={todo} />

如果组件内部需要 ID,要显式传递 todoId

五、为什么不要随便用数组索引

1
2
3
todos.map((todo, index) => (
<TodoRow key={index} todo={todo} />
));

当列表只追加、永不排序删除时,索引可能暂时没有问题。但在可编辑列表中删除第一项后,原第二项占据 key 0,React 会复用原第一行的组件状态。于是文本输入、勾选或动画状态可能和数据错位。

应使用数据创建时就存在的稳定 ID。不要在渲染期间生成:

1
2
// 错误:每次渲染都得到新身份
<TodoRow key={crypto.randomUUID()} todo={todo} />

随机 key 会让所有行每次都卸载再挂载,丢失状态和焦点,还增加渲染成本。

六、用 key 有意重置表单

key 不只用于数组。切换编辑对象时,可以明确重置表单草稿:

1
2
3
4
5
6
7
8
function ContactEditor({ selectedContact }) {
return (
<EditContactForm
key={selectedContact.id}
contact={selectedContact}
/>
);
}
1
2
3
4
5
6
function EditContactForm({ contact }) {
const [name, setName] = useState(contact.name);
const [email, setEmail] = useState(contact.email);

// ...
}

如果没有 key,useState(contact.name) 只在首次挂载时使用初始值,切换 contact 后草稿仍可能保留旧数据。以 contact ID 作为 key 表达了业务规则:“每个联系人有一份独立表单状态。”

但不要把 key 当成同步 prop 到 state 的万能修复。若状态本来可以直接从 props 计算,就不需要复制到本地;若用户草稿应该在对象间保留,则应把草稿提升到父级或按 ID 存入 Map,而不是强制重置。

七、先决定状态所有者,再决定 key

遇到状态错位时,可以先问:

  1. 这份状态属于“这个位置”,还是属于某个业务实体?
  2. 切换实体时应该保留、分别保存,还是立即清空?
  3. 稳定身份来自数据库 ID、路由参数还是组合键?
  4. 状态是否应该提升到更稳定的父组件?

例如多标签编辑器希望切换标签后保留每份草稿,单纯给 Editor 换 key 只会卸载当前状态。更合适的结构是父级维护:

1
const [drafts, setDrafts] = useState(() => new Map());

每个文档 ID 对应一份草稿,key 负责组件身份,父级数据负责跨卸载持久化。

八、调试状态重置的顺序

  1. 在 React DevTools 中确认组件是否卸载后重新挂载。
  2. 查看同一位置的组件类型是否发生变化。
  3. 检查 key 是否变化、重复或来自数组索引。
  4. 检查组件是否定义在另一个组件函数内部。
  5. 确认条件渲染是否改变了父级树结构。
  6. 明确这是意外重置,还是业务上本应重置。

不要一看到状态不对就加 useEffect 同步。Effect 往往只是把身份问题变成更多更新时序问题。

九、行动清单

  • 可编辑列表是否使用稳定数据 ID 作为 key?
  • 是否存在随机 key 或会变化的拼接 key?
  • 切换路由参数后,表单草稿应该保留还是清空?
  • 组件是否被定义在另一个组件内部?
  • key 重置是否会意外丢失焦点、滚动和未保存输入?
  • 需要跨卸载保留的状态是否放到了更稳定的所有者?

key 不是为了消除控制台警告而随便填写的属性,它是 React 身份模型的一部分。先用“位置 + 类型”理解默认保留规则,再用稳定 key 表达业务实体,最后把需要持久化的状态放到正确所有者,组件的保留与重置就会从偶发现象变成可设计行为。

参考资料