先记住这个答案
React 调用函数组件时,为这次渲染提供一组 props 和 state,组件创建的事件处理器会捕获这组值。调用 setter 会安排后续状态计算与渲染,但当前处理器中的局部变量仍属于原来的快照。即使定时器稍后执行,它引用的也可能是创建定时器时那次渲染的值。这不是 setter 永远失效,也不应简单解释成“所有更新都是异步”;要分别判断当前闭包、状态更新队列和已经提交到界面的结果。
- 同一处理器中的 state 不会被 setter 原地改写
- 延时执行不会自动换成最新渲染的闭包
- 先决定业务需要点击时的值还是执行时的值
延时回调为什么不会自动读取新值
假设计数开始为零,点击时先提交加一,再安排一条延时日志。页面随后可以显示一,但日志中的 count 仍是零,因为这个定时器回调没有被后来那次组件调用重新创建。等待更久也不会改变它引用的词法环境。
下面再次点击时,新界面的处理器会捕获一,因此第二轮日志读取一。这正好说明每次渲染有自己的快照;旧回调没有更新,并不代表 React 没有为新界面安装对应的新处理器。
import { useState } from 'react';
export default function SnapshotCounter() {
const [count, setCount] = useState(0);
function increment() {
setCount(count + 1);
console.log('handler', count);
setTimeout(() => console.log('timer', count), 0);
}
return <button onClick={increment}>当前计数:{count}</button>;
}初始为零时点击一次,界面更新为一,handler 和 timer 两条日志都读取零。这里没有声称定时器一定在 DOM 提交之前或之后运行,只核对各自读取的状态快照。
有时保留点击时的状态才是正确行为
用户点击提交时选择的是某一份草稿,随后又切换到另一份。如果延时发送操作偷偷读取了最新选中的草稿,可能把用户没有提交的内容发出去。把提交时的对象标识和内容作为这次动作的输入,反而更符合用户意图。
因此修复旧值问题前要明确动作语义:是保存点击时的文本,还是在后台任务真正开始时读取当前文本。前者可显式保存本次输入;后者需要适当的实时读取或订阅设计,不能用延长定时器或再次调用 setter 来碰运气。
状态快照不等于深度冻结所有对象
如果 state 引用一个对象,而代码直接修改它的内部字段,旧快照也可能观察到这次修改,因为多次渲染仍共享同一个对象引用。这不是快照机制自动复制了对象,应通过不可变更新保留历史版本的含义。
需要基于此前更新累加时可使用函数式 updater;需要展示新结果时让下一次渲染承担。若同一处理器立即要使用一个已经确定的新值,可以先计算局部 next,再传给 setter 并使用 next,但这不适合代替复杂并发更新的队列语义。
容易答错的地方
- await setter 就能等待当前变量变成新值
- useState 的 setter 不提供这样的 Promise 契约,await 不会重绑定当前闭包里的变量;应从更新队列或下一次渲染理解结果。
- 旧值问题统一把所有 state 改成 ref
- ref 不会自动驱动界面更新,也会改变数据读取语义;先判断需要哪一个时刻的值,再选择状态、事件输入或命令式引用。
面试官还会怎么问?
为什么第二次点击能读到新的 count?
新状态提交后,界面使用新一轮渲染产生的处理器,它捕获的 count 已不同。仍在等待的旧定时器则继续保留自己那一轮的闭包。
对象字段被直接修改会破坏快照吗?
会破坏你对历史数据不变的预期,因为快照保存的是对象引用而非自动深拷贝。应创建新的变更路径对象,避免旧渲染和缓存看到被改写的数据。
立即打印 next 和打印 count 为什么不同?
next 是当前函数主动计算的新局部值,count 是本次渲染提供的状态。二者角色不同,打印 next 不意味着 React 已把对应界面提交到 DOM。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。