REACT · Vol.I · LESSON 07 · 核心原语
列表渲染与 key:diff 的正确打开方式
用 map 把数组变成界面
后端程序员对「从集合生成一堆东西」再熟悉不过了。React 里渲染列表,就是用你天天写的 map:
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Carol" },
];
function UserList() {
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li> {/* 注意 key */}
))}
</ul>
);
}
等价于 Java 里的:
List<String> names = users.stream()
.map(u -> "<li>" + u.name + "</li>")
.collect(Collectors.toList());
同样的「把集合映射成另一个集合」,只不过 React 里映射出来的是一堆JSX 元素。这里唯一的「新东西」,就是每个元素上的那个 key —— 而它恰恰是这一篇的重点。
顺带一个工程细节:别在 JSX 里手动拼 for 循环或用 forEach 往数组里 push。forEach 返回 undefined,塞进 {} 里什么都不显示——这是新手常见翻车点。map 的「一进一出」特性正好契合 JSX 的表达式本质:一段 JSX 就是一个值,可以返回、可以放进数组。
key:React 识别「谁是谁」的唯一凭据
回顾第 5 篇:列表更新时,React 要拿新旧两个虚拟 DOM 树做 diff,判断哪些项是「新增、删除、还是移动」。问题来了——它靠什么把「旧的 A」和「新的 A」对应起来?
答案就是 key。没有 key,React 只能按位置(下标)比对:
// 旧列表(下标是隐式 key)
[0] A [1] B [2] C
// 在头部插入 X 之后
[0] X [1] A [2] B [3] C
// React 按下标比对:
// [0] A → X (认为 A 变成了 X)
// [1] B → A (认为 B 变成了 A)
// [2] C → B (认为 C 变成了 B)
// 还要新建 C (新增)
结果:只是「插入一个 X」,React 却误以为每一项都变了,于是重新构建整个列表——性能浪费,而且更糟的是:如果列表项里含有内部状态(比如输入框里用户正在输入的文字、或一个展开/收起的状态),这种「错位匹配」会把这些状态错误地张冠李戴。
而有了正确的 key:
// 用 id 做 key
<li key="a">A</li> <li key="b">B</li> <li key="c">C</li>
// 插入 X(key="x")后,React 一眼看出:
// a/b/c 都在,只是多了个 x → 只新增 x,其余全部复用 ✅
这跟数据库是一回事:更新一张表,你是靠 WHERE id = ? 精准定位行,还是靠「第几行」去改?靠行号做定位,一旦中间插了一行,后面所有更新全错位——所以才有主键。key 就是列表项的主键,React 的 diff 就是那条 UPDATE ... WHERE key = ?。
key 该怎么选:记住「稳定 + 唯一」
| 做法 | 评价 | 原因 |
|---|---|---|
key={user.id}(数据库主键) | ✅ 最佳 | 稳定、唯一,完美的身份标识 |
key={user.name}(业务唯一字段) | ✅ 可用 | 唯一即可,但确保不会重复 |
key={index}(数组下标) | ⚠️ 谨慎 | 列表一旦增删/排序就会错位,见下文实例 |
key={Math.random()} | ❌ 禁止 | 每次渲染都变,等于让 React 全部重建 |
index 做 key 也算「凑合能用」?——当列表永远不会增删、排序、重排(纯静态展示),且项内没有内部状态时,index 才勉强安全。但为了省心,能用业务唯一 id 就永远用唯一 id。「项内有内部状态 + 用了 index 做 key」会炸成什么样?看一个真实可复现的场景:
function Drafts({ drafts }) {
return drafts.map((d, index) => (
<li key={index}>
{/* input 是「非受控」的:用户打的字存在 DOM 自己身上 */}
<input defaultValue={d.text} />
<button onClick={() => remove(index)}>删除</button>
</li>
));
}
// 列表:[0] 周报 [1] 会议纪要
// 你在第 0 行输入框里敲了「本周完成了…」
// 然后点击第 0 行的「删除」,删掉「周报」
//
// 你以为:「周报」整行消失
// 实际:「本周完成了…」这段字留在了第 0 行,
// 挂在了「会议纪要」头上!💀
原因顺着 diff 推一遍就通:删除后列表从两项变一项,React 按下标比对——[0] 还在(只是内容从「周报」变成「会议纪要」),[1] 没了。于是它「更新第 0 行的文字、删掉第 1 行」,而第 0 行那个输入框 DOM 被原样保留,用户敲进去的字自然纹丝不动——文字换了壳,输入留了下来。换成 key={d.id},React 才知道是 key=周报id 的整个节点被删除,连同它肚子里的输入框一起销毁。这类「删了 A 却把 A 的输入留在 B 身上」的 bug,是前端面试和线上事故里的高频常客。
把 key 放在哪一层也有讲究:key 应该写在 map 直接生成的那个元素上,而不是元素内部的某个子元素上:
// ✅ key 加在 map 出来的最外层元素
users.map(u => <li key={u.id}><b>{u.name}</b></li>)
// ❌ 别加在里面
users.map(u => <li><b key={u.id}>{u.name}</b></li>)
原因和 diff 的「同层比较」规则一脉相承:key 只在同一层兄弟节点之间起识别作用。把 key 藏在里层,等于在最需要身份认证的那一层没带身份证。
key 的隐藏用法:换一个身份,强制「重生」
理解了「key 是身份」,反过来还能主动利用它:故意改 key,就是命令 React「销毁旧节点、重建一个全新的」。这在想重置组件内部状态时极其顺手:
function Admin() {
const [userId, setUserId] = useState(1);
return (
<>
<select value={userId} onChange={e => setUserId(Number(e.target.value))}>
<option value={1}>Alice</option>
<option value={2}>Bob</option>
</select>
{/* key 随 userId 变:切换用户 = 销毁旧表单、建一个全新的 */}
{/* 表单里填了一半的内容、勾选状态,全部自动清零 ✅ */}
<UserForm key={userId} userId={userId} />
</>
);
}
不用 key 的话,你得在 UserForm 里写一堆 useEffect 监听 userId 变化、手动重置每个字段——又啰嗦又容易漏。一个 key 解决一切:key 变 = 组件实例重建 = 内部 state 全部归零。
这相当于「换一个 Bean 实例」而不是「往旧 Bean 里塞新数据」:与其给单例的各种字段做清空重置,不如直接 new 一个。销毁重建往往比小心翼翼地清理更不容易出错——这个判断在后端成立,在前端同样成立。
- 列表用
map渲染——后端最熟悉的集合映射。 key让 React 在 diff 时精准识别「谁是谁」,避免错位与全量重建。- key 三原则:稳定、唯一、加在 map 的最外层元素,优先用业务主键,慎用 index。
- key 只在兄弟间生效;反过来「改 key」还能强制销毁重建,是重置组件状态的利器。
一句话总结
本章回顾
key 别看只是个属性,它是「虚拟 DOM diff 是否正确高效」的关键:它是列表项的主键,让 React 像 WHERE id = ? 一样精准定位每一行。记住本篇的两个现场:没有稳定 key,头部插入会全量重建;用 index 做 key 配上带状态的列表项,状态会张冠李戴。
下一篇我们把第一章所有知识串起来,写一个完整的 Todo 组件实战——声明式、JSX、组件、state、事件、key,一个都不少。
Comments · 评论