题干与适用场景
一个管理页面渲染可编辑用户列表。每个 Row 内部保存尚未提交的输入草稿和展开状态。用户可以在列表顶部插入 记录、删除中间记录、排序和筛选。页面右侧还有详情表单,切换 userId 时应清空上一位用户未提交的本地状态。
请解释:
- React 在相邻两次渲染之间如何判断一个组件是否还是原来的组件。
- 为什么
key={index}会让草稿或展开状态跑到另一条记录。 - 为什么
key={Math.random()}会让组件反复重挂载。 - 何时应使用稳定业务 ID 保留状态,何时应改变 key 主动重置整个子树。
- 如何验证方案在插入、删除、排序、筛选和切换实体时都正确。
2026 年 3 月和 5 月发布的 React 面试资料都把 key、索引键、协调和重挂载列为直接考点。它表面上是列表语法题, 实际考察候选人能否建立“状态属于哪个逻辑实体”的模型,并把这个模型落到组件边界和测试用例上。
本题归类为 frontend,因为核心能力是理解 React 渲染树中的组件身份、状态生命周期和 DOM 复用行为。
面试官考察点
第一,候选人是否知道状态与 JSX 标签或业务对象本身没有自动绑定。React 把状态关联到渲染树中的位置;在同一父级 内,元素类型和 key 会共同影响 React 是否把新旧子节点视为同一个身份。
第二,能否区分“重新渲染”和“重挂载”。props 或父组件变化可以让同一个组件实例重新渲染并保留本地状态;组件 类型或 key 改变则会创建新身份,旧子树的本地状态被销毁,DOM 也可能被重新创建。
第三,能否从业务身份选择 key。数据库 ID、创建记录时生成并持久保存的 UUID,通常可以表达“这仍是同一条记录”。 数组位置、当前时间或渲染期间生成的随机值表达不了稳定身份。
第四,强回答不会把“永远不要改变 key”当规则。详情表单从用户 A 切换到用户 B 时,两份表单在业务上就是不同 实体;在正确边界使用 key={userId} 可以一次重置整个表单子树,比逐层用 Effect 清状态更完整。
最后,候选人还要说明重挂载的代价:焦点、滚动位置、未保存输入和子组件状态都会丢失。若产品要求切回实体时恢复 草稿,应把草稿提升到父级、按实体保存,或写入外部存储,而不是依赖被移除子树的本地状态。
回答前需要澄清的问题
- 列表是否会插入、删除、排序或筛选? 只要顺序或成员可能变化,数组索引就不能稳定表示业务实体。
- 每行是否持有本地状态或浏览器 DOM 状态? 输入框、展开状态、动画和非受控表单会让错误复用更容易暴露。
- 数据是否有稳定且同级唯一的 ID? key 只需在同一父级的兄弟节点之间唯一,不要求全局唯一。
- 切换实体后要清空还是恢复草稿? 清空适合改变 key;恢复需要把草稿放到更长寿命的状态层。
- 需要重置整个子树,还是只调整一个字段? 全量重置可用 key;局部调整应优先使用受控状态或显式数据模型。
- ID 在何时生成? 本地新建项可以在创建记录时生成 UUID 并保存;不能在每次 render 中重新生成。
30 秒回答框架
“React 把状态关联到渲染树位置。在同一父级下,组件类型和 key 帮助 React 判断新旧节点是不是同一身份。同一个 稳定 key 即使移动位置,React 仍能把行状态跟随业务记录;用数组索引时,插入或排序后同一位置会对应另一条记录, 本地草稿就可能错行。用随机 key 会让每次渲染都无法匹配,组件和 DOM 被反复重建。
列表应该使用数据中的稳定 ID。若从用户 A 的详情表单切换到用户 B,并且产品要求清空所有本地状态,我会在表单 子树边界使用 key={userId},明确告诉 React 这是新实体。若要保留每位用户的草稿,我会把草稿提升并按 userId 存储,而不是取消正确的 key。最后用插入、删除、重排、筛选、同 ID 重渲染和不同 ID 切换测试状态是否跟随预期 实体。”
分步骤深入解答
第一步:建立组件身份模型。
可以把 React 的判断简化为下面的面试模型:
| 新旧渲染关系 | 典型结果 | |---|---| | 同一父级、同一组件类型、同一 key | 保留该身份的本地状态,组件可以重新渲染 | | 同一父级、同一类型、不同 key | 旧身份被移除,新身份挂载,子树状态重置 | | 同一位置、不同组件类型 | 旧子树被替换,状态重置 | | 列表没有显式 key | React 退回按位置匹配,动态列表容易错配 |
这不是说 key 是组件收到的普通 prop。key 是 React 自己使用的提示,子组件读不到它;如果 Row 还需要业务 ID,必须另传 rowId={item.id}。
key 也只在当前父级中定义身份。两个不同列表都可以出现 key="user-42";同一列表的兄弟节点则不能重复。
第二步:用可编辑列表复现索引键错误。
错误实现:
function UserList({ users }: { users: User[] }) {
return users.map((user, index) => (
<EditableRow key={index} user={user} />
))
}假设初始顺序是 [Alice, Bob]。索引 0 对应的 EditableRow 保存了 Alice 的本地草稿。现在在顶部插入 Zoe, 新顺序变为 [Zoe, Alice, Bob]。React 仍会把 key 0 的旧行与新数组第 0 项匹配,于是原属于 Alice 的本地 状态可能出现在 Zoe 行;key 1 的状态也可能从 Bob 跑到 Alice。
问题不在于“索引不是字符串”,而在于索引表达的是槽位,业务需要表达的是用户。删除、排序和筛选都会改变槽位与 实体的对应关系。
正确实现:
function UserList({ users }: { users: User[] }) {
return users.map((user) => (
<EditableRow key={user.id} rowId={user.id} user={user} />
))
}插入 Zoe 后,Alice 的 key 仍是 Alice 的 ID。她可以从索引 0 移到 1,React 仍能把 Alice 的组件状态与 Alice 匹配。
如果一条记录要返回多个同级节点,短 Fragment 语法不能设置 key,应使用显式 Fragment:
import { Fragment } from 'react'
users.map((user) => (
<Fragment key={user.id}>
<UserHeading user={user} />
<EditableRow user={user} />
</Fragment>
))第三步:选择稳定 key,而不是在 render 中制造 key。
优先级通常是:
- 后端或数据库提供的稳定记录 ID。
- 数据进入客户端时已经携带的、在业务生命周期内不变的唯一标识。
- 仅存在本地的新记录,在“创建记录”事件中生成 UUID,然后随记录一起持久保存。
- 只有在组合字段确实构成不可变且同级唯一的业务身份时,才使用组合 key。
下面的写法会在每次 render 产生新身份:
<EditableRow key={Math.random()} user={user} />结果不是普通的重新渲染,而是旧行被移除、新行重新挂载。所有本地状态和用户输入都会丢失,DOM 也会重建。 Date.now() 或在 render 中调用 crypto.randomUUID() 有同样问题。UUID 可以用,但必须在创建数据项时生成并 保存,不能在渲染数据项时生成。
数组索引只在非常窄的边界内可接受:列表在整个生命周期中不会插入、删除、重排或筛选,顺序本身就是身份,而且 行内没有需要跟随业务实体的状态。只要未来会变化,就应补上真实 ID,而不是靠这组假设长期维持正确性。
第四步:用 key 表达“这是另一份表单”。
错误修复常见于详情页:
function Profile({ userId }: { userId: string }) {
const [comment, setComment] = useState('')
useEffect(() => {
setComment('')
}, [userId])
return <CommentForm value={comment} onChange={setComment} />
}这个 Effect 会先让组件树用旧 comment 渲染,再在 Effect 运行后触发第二次渲染。更重要的是,详情页深处可能还 有附件、校验错误和子表单状态,父组件清一个 comment 无法保证整个子树都重置。
如果产品定义是“不同用户拥有独立的新表单”,应在身份边界上设置 key:
function ProfilePage({ userId }: { userId: string }) {
return <ProfileForm key={userId} userId={userId} />
}当 userId 改变,React 将新的 ProfileForm 视为不同身份,表单及所有后代本地状态一起重置。同一个 userId 因父组件其他状态变化而重新渲染时,key 不变,本地状态仍被保留。
key 应放在真正需要重置的最小完整边界。如果把 key 放到整页根节点,导航栏、昂贵的数据展示和不相关的滚动状态 也会被重建;如果只放在一个输入框上,又可能漏掉同一表单的其他状态。
第五步:先决定产品要保留什么,再决定是否重挂载。
“切换实体”并不自动等于“丢弃草稿”。可以用决策表回答:
| 产品语义 | 推荐状态设计 | |---|---| | 切换到另一实体必须得到全新表单 | 在表单子树使用实体 ID 作为 key | | 切回实体要恢复各自草稿 | key 仍按实体区分;草稿提升到父级并按 ID 保存 | | 刷新页面后也要恢复草稿 | 持久化到外部存储,并定义过期与清理 | | 只重置单个字段,其余状态保留 | 使用受控状态或显式更新,不重挂载整个子树 | | 只因 props 改变而展示不同计算结果 | 直接在 render 中从 props 计算,避免复制状态 |
key 解决身份边界,不负责长期持久化。把状态提升为 drafts[userId] 后,即使对应表单子树被卸载,草稿仍由父级 保存;重新选择该用户时,再用保存值初始化或控制表单。
第六步:识别其他意外重置来源。
即使 key 正确,改变组件类型也会重置状态。例如在同一位置从 ProfileForm 切到 LoginPrompt,React 会替换 整个子树。
另一个常见错误是在组件内部定义组件:
function ProfilePage() {
function ProfileForm() {
const [name, setName] = useState('')
return <input value={name} onChange={(event) => setName(event.target.value)} />
}
return <ProfileForm />
}每次 ProfilePage 渲染都会创建新的 ProfileForm 函数对象,React 会把它视为不同组件类型,导致输入状态意外 重置。组件定义应放到顶层。面试中若只盯着 key,可能漏掉这个同样属于身份判断的问题。
第七步:把 key 的放置与虚拟列表结合起来。
虚拟列表会反复复用少量可见槽位。若虚拟化组件允许提供 itemKey,它也应返回业务实体 ID,而不是窗口内索引。 否则滚动或数据重排后,槽位中的本地状态可能被另一个实体继承。
不过,对大列表更稳妥的方案通常是减少行内不可持久的本地业务状态:编辑草稿按记录 ID 放在列表上层,行组件只 读取当前实体的草稿。这样即使虚拟化库按性能需要卸载不可见行,数据也不会随组件生命周期丢失。
第八步:用状态归属测试验收。
不要只检查 DOM 文本有没有更新。为每个场景先写清“状态应该属于哪个 ID”:
| 操作 | 预期结果 | |---|---| | 在 Alice 行输入草稿,再在顶部插入 Zoe | 草稿仍只属于 Alice | | 删除列表中间一项 | 其他行的展开和输入状态不迁移 | | 按姓名倒序,再恢复原排序 | 每行状态始终跟随记录 ID | | 筛掉 Alice,再恢复筛选 | 若状态仅在行内,卸载后会丢失;若产品要求恢复,应从上层草稿库恢复 | | 同一 userId 下触发父组件重渲染 | 表单本地状态保留 | | 从 user A 切到 user B | 以 userId 为 key 的表单完整重置 | | 将 key 临时改为随机值并触发父组件重渲染 | 可观察到输入丢失和 DOM 重建,证明错误机制 | | 数据出现重复 ID | 在数据边界告警或拒绝,避免兄弟节点 key 冲突 |
测试还应覆盖焦点和非受控输入。错误 key 有时不会改变 React state,却会让浏览器保留的 DOM 值或焦点落到错误 记录上。验收标准应围绕用户可见的实体连续性,而不是只统计 render 次数。
高质量示范回答
“我先把 key 当作组件身份的一部分,而不是消除 console warning 的属性。React 把状态关联到渲染树位置;在 同一父级下,同一组件类型和同一 key 通常表示同一身份,因此 props 更新或位置移动可以重新渲染并保留状态。 类型或 key 改变则表示新身份,旧子树状态被销毁,新子树挂载。
在可编辑列表中,索引代表位置,不代表用户。初始 [Alice, Bob] 用 key 0 和 1,若顶部插入 Zoe,旧 key 0 的行会被拿来表示 Zoe,Alice 的草稿或展开状态就可能跑到 Zoe。应使用 user.id,让 Alice 即使从位置 0 移动到位置 1,身份仍保持不变。随机 key、时间戳或 render 时生成 UUID 会让每次渲染都无法匹配,造成组件与 DOM 反复重建,并丢失输入。key 只需在同一父级唯一,也不会作为 prop 传给子组件。
详情表单要看产品语义。如果 user A 切到 user B 时必须清空整个表单,我会在 ProfileForm 边界使用 key={userId},让所有后代状态一次重置,而不是在多个 Effect 中逐个清理。若切回 A 要恢复草稿,key 仍按 userId 区分,但草稿要提升到父级或外部存储并按 userId 保存。
最后我会测试顶部插入、删除、重排、筛选、同 ID 重渲染和不同 ID 切换,并检查输入、展开状态、焦点和草稿都 跟随预期业务 ID。这样可以证明 key 表达了正确身份,而不只是页面看起来能更新。”
常见错误
- 把 key 只解释为性能优化 → 核心问题是新旧子节点的身份匹配与状态归属 → 先解释状态错配,再谈更新成本。
- 所有列表都使用数组索引 → 插入、删除、重排和筛选会改变位置与实体的关系 → 使用数据中的稳定 ID。
- 在 render 中生成 UUID 或随机数 → key 每次变化,组件与 DOM 反复重建 → 在创建数据项时生成并保存 ID。
- 认为 key 必须全局唯一 → React 只要求兄弟节点之间唯一 → 按当前父级和列表边界判断冲突。
- 在子组件中读取
props.key→ key 不会作为普通 prop 传入 → 另传rowId或userId。 - 用 Effect 逐个清空复杂表单 → 会出现旧状态渲染和二次渲染,也容易漏掉后代状态 → 在正确边界改变 key。
- 为了清一个字段重挂载整页 → 会丢失焦点、滚动和不相关状态 → 缩小 key 边界或显式更新局部状态。
- 用稳定 key 就承诺筛选后保留行内状态 → 被筛掉的组件可能已经卸载 → 需要恢复时把状态提升或持久化。
追问及应对
追问 1:数据没有后端 ID,应该用什么 key?
在创建本地记录的事件中生成 ID,例如 UUID,然后把它作为记录字段保存。后续渲染一直读取这一个 ID。可以使用 真正不可变且同级唯一的业务字段组合,但要先证明它不会修改或碰撞。不能在 map 的 render 过程中临时生成。
追问 2:什么时候数组索引可以作为 key?
只有列表成员和顺序在整个生命周期内固定、不会插入删除筛选或重排,并且位置本身就是身份时才相对安全。静态 展示的固定条目可能满足这个边界。动态业务数据应默认使用稳定实体 ID,因为需求一旦加入排序或编辑,索引假设 就会失效。
追问 3:只想在 userId 改变时清一个字段,还要改变整个表单的 key 吗?
不一定。key 会重置整个子树;如果其他本地状态必须保留,应使用受控字段、从 props 直接计算,或在明确的数据 更新路径中调整该字段。选择 key 的前提是“整个子树代表另一实体,全部本地状态都应重新开始”。
追问 4:改变 key 后,怎样让用户切回时恢复草稿?
把草稿提升到父级,例如保存为 drafts[userId],或写入具备过期和清理策略的外部存储。表单 key 继续用 userId 防止不同用户共享本地状态;新挂载的表单从对应用户的草稿读取初值。身份隔离和草稿持久化是两个独立 职责。
追问 5:虚拟列表已经复用 DOM,还需要稳定 key 吗?
需要。虚拟化解决可见节点数量,不能改变业务身份。若库提供 itemKey,应返回记录 ID。对必须跨卸载保留的编辑 状态,还应按 ID 存在列表上层;否则行滚出窗口被卸载时,本地状态仍会消失。