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。

React 记忆槽(在 fiber 上) count: 0 → 1 → 2 · 跨渲染保留 渲染 #1 const count = 0 (本帧快照) 渲染 #2 const count = 1 (本帧快照) 渲染 #3 const count = 2 (本帧快照) useState 取出
图 3.1真正的 state 在 React 的记忆槽里跨渲染保留;你函数里的 const count 只是这一帧从槽里取出的快照。注意:setCount 改的是上面那个槽、并安排下一帧——它无法、也不会改动当前这一帧里已经定下的 count。

“快照”最直接的后果:在一次渲染产生的所有闭包里——事件回调、setTimeout、effect——读到的 count 都是这一帧那个固定的值。这不是 React 的魔法,就是 JavaScript 闭包:函数捕获它定义时所在作用域里的变量。

snapshot-timeout.jsx JSX
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。理解它,就同时理解了批处理和“为什么要函数式更新”。

three-plus-one.jsx JSX
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。

值式 setCount(count + 1) ×3 count 本帧 ≡ 0 队列:设1 · 设1 · 设1 后者覆盖前者 count = 1 函数式 setCount(c => c + 1) ×3 拿上一个结果 +1 队列:c+1 · c+1 · c+1 依次折叠 0→1→2→3 = 3
图 3.2同样写三次,结果差在“队列里放值还是放函数”。注意:值式入队的是一个写死的目标值(都基于本帧的旧快照 0),互相覆盖;函数式入队的是“在上一个结果上 +1”的算法,React 依次折叠,才真正累加。需要连续累加时用函数式。

3.3不可变性:为什么必须给一个新对象

改 state 必须产生一个新对象/新数组,而不是原地修改旧的。React 用 Object.is 比较新旧引用,来决定要不要重渲染。

为什么需要它

这正是第 1 章那个挑战的答案:子组件(或任何人)push 进数组再 setState,界面却纹丝不动。问题不在 push 改了内容,而在——React 怎么知道“变了”?

immutability.jsx JSX
// ❌ 原地修改:todos 还是同一个数组引用
todos.push(newTodo);
setTodos(todos);            // 新引用 === 旧引用 → React 判定“没变” → 不重渲染

// ✅ 产生新数组:新引用
setTodos([...todos, newTodo]);   // 新引用 ≠ 旧引用 → 触发重渲染
底层机制 · 比文档深一层

setState 决定要不要重渲染,靠的是 Object.is(旧值, 新值) 浅比较引用。原地 push 改的是数组内部,引用没变,于是 setTodos(todos) 传进去的新旧引用相同,React 判定“没变”而跳过渲染(这叫 bailout)。代价:你得养成不可变更新的习惯(展开运算符、map/filter,或 Immer 库)。收益:引用相等 = 一次廉价的变化检测,它也是第 5 章 memo 化和并发渲染能成立的基础——整套优化都建立在“引用没变就一定没变”这个约定上。

原地改 push 后 setTodos 引用没变 Object.is(旧,新) = true 同引用 跳过渲染 UI 不动 新引用 [...todos, x] 新数组 Object.is(旧,新) = false 新引用 重新渲染 UI 更新
图 3.3是否触发渲染,取决于 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 题务必自己先在纸上推一遍队列。

  1. const [count] = useState(0) 里的 count,和“真正的 state”是同一个东西吗?分别存在哪里?
  2. 点击时执行 setCount(count + 1) 后紧接着 console.log(count),打印的是新值还是旧值?为什么?
  3. 本帧 count = 0,依次执行 setCount(count + 1)、setCount(c => c + 1)、setCount(count + 1),最终 count 是多少?逐条推队列。
  4. (设计题)为什么 React 要求不可变更新、而不是直接监听对象内部变化?这个约定为第 5 章的哪类优化打下了基础?
答案(先做完再展开)
  1. 不是同一个。函数里的 count 是这一帧从记忆槽取出的快照(局部常量,渲染结束即废);真正的 state 在 React 为该组件保存的记忆槽(fiber)上,跨渲染保留。
  2. 旧值。count 是本帧快照,在这一帧里固定不变;setCount 安排的是下一帧的值,不会改动当前帧的 count。
  3. 最终是 1。队列:①“设为 1”(基于本帧 0)→ ②“在上一个结果 1 上 +1 = 2” → ③“设为 1”(又基于本帧快照 0,把队列结果覆盖为 1)。最后一条值式覆盖掉了前面的累加,结果 1。
  4. 因为监听任意对象的深层变化代价高且不可靠;改用“引用变了才算变了”的约定,把变化检测降成一次 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),函数式只看队列里上一步的结果。