Chapter 03
状态:快照、队列、不可变
第 2 章说状态绑在“树中位置”上。这一章钻进 state 本身:它存在哪、为什么 set 之后立刻读还是旧值、为什么连写三次 +1 只加了 1。这是整份教程最反直觉、也最值钱的一章。
本章你将建立的 schema
- state 是快照:每次渲染捕获那一帧的值,函数里的 const 不是活变量
- 真正的 state 归 React 持有(存在 fiber 上),跨渲染保留
- 批处理 + 更新队列:何时用函数式更新;不可变性与
Object.is
3.1state 是快照,不是活变量
useState 返回的 const 只是“这一帧的快照”。真正的 state 存在 React 内部,跨渲染保留;这一帧里它的值固定不变。
新手把 const [count] = useState(0) 里的 count 当普通变量,于是困惑两件事:“明明 setCount 了,为什么下一行 count 还是旧的”“为什么 setTimeout 里读到的是点击时的值,不是最新值”。这两个困惑同一个根源——count 是快照。
组件函数每次渲染都整个重新执行,里面的 const count 每次都是新建的局部常量。useState 做的事是:从 React 为这个组件保存的“记忆槽”里取出当前值,赋给这一帧的 count。setCount 不修改这一帧的 count(它是 const,也改不了),而是通知 React“把记忆槽更新成新值,并安排一次重渲染”。下次渲染函数重跑,useState 又从记忆槽取出新值。所以——count 是某一帧的快照,React 的记忆槽才是真 state。
const count 只是这一帧从槽里取出的快照。注意:setCount 改的是上面那个槽、并安排下一帧——它无法、也不会改动当前这一帧里已经定下的 count。“快照”最直接的后果:在一次渲染产生的所有闭包里——事件回调、setTimeout、effect——读到的 count 都是这一帧那个固定的值。这不是 React 的魔法,就是 JavaScript 闭包:函数捕获它定义时所在作用域里的变量。
function handleClick() {
setCount(count + 1); // 安排:下一帧 count 变 1
setTimeout(() => {
alert(count); // 弹出 0 —— 捕获的是“点击那一刻”的快照
}, 3000); // 3 秒后即使已经渲染过很多次,这里仍是 0
}
3.2批处理与更新队列
React 把一个事件里的多次 setState 攒成一批、只渲染一次。要基于“上一次更新后的值”累加,必须用函数式更新 setCount(c => c + 1)。
承接上一节的快照,一个经典谜题:下面连写三次 +1,结果只加到 1,不是 3。理解它,就同时理解了批处理和“为什么要函数式更新”。
function handleClick() {
setCount(count + 1); // 本帧 count = 0 → 入队“把 state 设为 1”
setCount(count + 1); // 本帧 count 仍 = 0 → 入队“把 state 设为 1”
setCount(count + 1); // 本帧 count 仍 = 0 → 入队“把 state 设为 1”
} // 三条都是“设为 1”,最终 count = 1
批处理:一个事件处理函数里的多次 setState 不会各触发一次渲染,而是攒进一个队列,事件跑完后 React 统一处理、只渲染一次。
队列里放什么,决定结果:放“值”——setCount(count+1) 入队的是“把 state 设为 1”,后一条覆盖前一条,三次只剩 1。放“函数”——setCount(c => c+1) 入队的是“拿上一个结果加 1”,React 依次把上一步结果喂进去:0→1→2→3,真的加了 3。
3.3不可变性:为什么必须给一个新对象
改 state 必须产生一个新对象/新数组,而不是原地修改旧的。React 用 Object.is 比较新旧引用,来决定要不要重渲染。
这正是第 1 章那个挑战的答案:子组件(或任何人)push 进数组再 setState,界面却纹丝不动。问题不在 push 改了内容,而在——React 怎么知道“变了”?
// ❌ 原地修改:todos 还是同一个数组引用
todos.push(newTodo);
setTodos(todos); // 新引用 === 旧引用 → React 判定“没变” → 不重渲染
// ✅ 产生新数组:新引用
setTodos([...todos, newTodo]); // 新引用 ≠ 旧引用 → 触发重渲染
setState 决定要不要重渲染,靠的是 Object.is(旧值, 新值) 浅比较引用。原地 push 改的是数组内部,引用没变,于是 setTodos(todos) 传进去的新旧引用相同,React 判定“没变”而跳过渲染(这叫 bailout)。代价:你得养成不可变更新的习惯(展开运算符、map/filter,或 Immer 库)。收益:引用相等 = 一次廉价的变化检测,它也是第 5 章 memo 化和并发渲染能成立的基础——整套优化都建立在“引用没变就一定没变”这个约定上。
Object.is 比较新旧引用。注意:原地 push 后引用没变,React 判定“没变”而跳过——这就是“明明改了数组,界面却没动”的真因,不是 bug,是约定。3.4把第 1 章的挑战补完
现在可以完整回答第 1 章末尾那个“子组件能不能 push 数组 prop”的挑战了,串起前三章:子组件通过调用父传下来的回调(§1.4 事件向上)请求添加;父组件用 setTodos([...todos, item]) 产生新数组(§3.3 不可变,新引用才触发渲染);新引用让 React 决定重渲染,父组件这个函数 f 重跑(§1.2),新的 todos 快照(§3.1)作为 props 流给子组件(§1.4);协调阶段按 key 精准更新真实 DOM(§2.3)。每一步都不是孤立的 API,而是同一个数据循环的环节。
§本章 self-check
先合上教程,把答案写下来,再展开对照。第 3 题务必自己先在纸上推一遍队列。
const [count] = useState(0)里的 count,和“真正的 state”是同一个东西吗?分别存在哪里?- 点击时执行
setCount(count + 1)后紧接着console.log(count),打印的是新值还是旧值?为什么? - 本帧 count = 0,依次执行
setCount(count + 1)、setCount(c => c + 1)、setCount(count + 1),最终 count 是多少?逐条推队列。 - (设计题)为什么 React 要求不可变更新、而不是直接监听对象内部变化?这个约定为第 5 章的哪类优化打下了基础?
答案(先做完再展开)
- 不是同一个。函数里的 count 是这一帧从记忆槽取出的快照(局部常量,渲染结束即废);真正的 state 在 React 为该组件保存的记忆槽(fiber)上,跨渲染保留。
- 旧值。count 是本帧快照,在这一帧里固定不变;
setCount安排的是下一帧的值,不会改动当前帧的 count。 - 最终是 1。队列:①“设为 1”(基于本帧 0)→ ②“在上一个结果 1 上 +1 = 2” → ③“设为 1”(又基于本帧快照 0,把队列结果覆盖为 1)。最后一条值式覆盖掉了前面的累加,结果 1。
- 因为监听任意对象的深层变化代价高且不可靠;改用“引用变了才算变了”的约定,把变化检测降成一次
Object.is引用比较——廉价且确定。正是这个约定让memo化(靠引用相等跳过重渲染)和并发渲染(可安全复用未变的子树)成为可能。
值式与函数式混用,结果是多少?
本帧 count = 3。一个事件里依次执行:
setCount(count + 5);
setCount(c => c + 1);
setCount(10);
setCount(c => c + 2);
渲染后 count 是多少?把队列一步步折叠出来。
提示(卡住再展开)
队列从空开始,依次应用,函数式拿“队列当前结果”、值式直接覆盖:① count+5 = 3+5 = 8(值,队列结果 8);② c=>c+1 = 8+1 = 9;③ 10 直接覆盖 = 10;④ c=>c+2 = 10+2 = 12。最终 count = 12。关键:值式只看本帧快照(count=3),函数式只看队列里上一步的结果。