快照、队列与批处理:一次点击里 state 到底是多少
问题:连续加三次为什么只增加一
count 不是可以在当前函数里原地改写的普通变量。组件每次被 React 调用时,会拿到那一轮的 state 快照;这一轮创建的事件处理器也固定读取这份快照。setter 把更新放进下一次渲染的队列。
完整实验:四种更新放在一起观察
import { useState } from 'react'
export default function App() {
const [count, setCount] = useState(0)
const [log, setLog] = useState([])
function record(message) {
setLog(current => [...current, message])
}
function addThreeWithValues() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
record(`值更新的处理器仍看到 ${count}`)
}
function addThreeWithUpdaters() {
setCount(current => current + 1)
setCount(current => current + 1)
setCount(current => current + 1)
record(`函数更新的处理器仍看到 ${count}`)
}
function replaceThenUpdate() {
setCount(count + 5)
setCount(current => current + 1)
record(`排入“替换为 ${count + 5},再加一”`)
}
function showDelayedSnapshot() {
const captured = count
setCount(current => current + 1)
setTimeout(() => record(`定时器捕获 ${captured}`), 1000)
}
return (
<main>
<h1>计数:{count}</h1>
<button onClick={addThreeWithValues}>三个值更新</button>
<button onClick={addThreeWithUpdaters}>三个函数更新</button>
<button onClick={replaceThenUpdate}>替换后再加一</button>
<button onClick={showDelayedSnapshot}>延迟读取快照</button>
<ol>{log.map((item, index) => <li key={index}>{item}</li>)}</ol>
</main>
)
}日志是只追加、从不重排的展示列表,所以这里用索引 key 不会把有状态行串位。业务列表仍应优先使用数据 id。
逐项推演队列
假设点击前 count 是 0。
“三个值更新”这一轮的 count 始终为 0,所以排入的是三次“替换为 1”:
初始 0 → 替换 1 → 替换 1 → 替换 1 = 1“三个函数更新”把函数交给 React。React 在处理队列时,将上一步待定值依次传入:
初始 0 → 0 + 1 → 1 + 1 → 2 + 1 = 3replaceThenUpdate 先根据当前快照排入替换值,再排入函数。若当前是 0:
初始 0 → 替换 5 → 5 + 1 = 6更新函数应是纯函数:只根据参数返回下一值,不修改外部变量。React 开发模式可能额外调用更新函数来帮助发现不纯逻辑,因此副作用会暴露为重复执行。
顺序反过来,结果也会不同。把 replaceThenUpdate 中的两次调用交换为下面两行,刷新到 0 再点击:
setCount(current => current + 1)
setCount(count + 5)队列是 0 → 更新为 1 → 替换为 5,最终为 5。第二次调用里的 count + 5 在事件执行时就按旧快照算成了 5,不会等第一个更新结束后再读取 1。可以把“传值”理解为忽略队列前一结果的替换操作,不能把两种写法都心算成对同一个变量连续赋值。
批处理不是“state 永远异步”
React 通常等当前事件处理器执行完,再处理其中排入的 state 更新。这避免界面出现只更新了一半的中间状态。不同的有意用户事件(例如两次点击)会分别处理,不会合并成同一个业务事件。
更精确的说法是:setter 为下一次渲染请求状态,不会改写当前渲染里的变量。至于 React 何时安排工作,不应被业务代码猜成一个固定延迟。
条件变化:一秒后为什么仍读到旧值
showDelayedSnapshot 创建定时器时捕获了当前 count。即使一秒内界面已经完成新渲染,旧处理器闭包里的 captured 仍是旧值。这种稳定性让“发送给当时选中的收件人”可以被准确表达。
若需求是基于队列中最新值计算下一值,用更新函数。若需求是让稍后回调读取届时最新值,则要重新设计数据流(后续会学习 ref),不能假装当前闭包会自动变化。
边界
- 使用更新函数并非所有 setter 的硬性要求;下一值完全替换且不依赖旧值时,
setStatus('sent')更直接。 setCount(count + 1)在一次点击只调用一次通常没有问题;当新值依赖旧值或同一队列多次更新时,函数式更新更稳健。- setter 后立刻
console.log(count)记录的是当前快照,不是 React“更新失败”。 - 批处理提高一致性,但不能替代防重复提交的业务状态与服务端幂等。
练习:比分回合队列
实现一个 App.jsx,初始比分为 10,提供三个按钮:
- “罚球三中”:同一次点击连续排入三个
+1更新,结果必须+3; - “判罚改为 20 后加 2”:先替换为
20,再用更新函数+2,结果必须为22; - “记录本回合”:把点击当时的 score 追加到日志,同时把 score
+1;日志必须保存旧快照。
日志只追加;不要在 setter 后假设局部变量已经变化。完成后对照答案 01。