useState 到底怎么工作的?理解状态快照、更新队列和函数式更新
useState 不是把状态保存在函数变量里,而是让 React 在组件对应的 Fiber 上保存状态,并在下一次渲染时把最新状态传回组件。
返回值包含两部分:当前这次渲染的状态快照,以及用于提交更新的 setter 函数。
函数组件每次更新时都会重新执行。下面的局部变量不会跨渲染自动保留:
React 会为组件维护 Fiber,并在 Fiber 的 memoizedState 链表中保存 Hook 信息。可以用下面的简化模型理解:
每次渲染调用 useState 时,React 按 Hook 的调用顺序读取对应节点。因此 Hook 不能放在条件、循环或嵌套函数中,否则同一个位置在不同渲染中可能对应不同 Hook。
大致流程如下:
0,并返回 [0, setCount]。[count, setCount],组件使用新值计算 UI。setCount 不会直接修改当前已经执行完的函数中的 count。它请求 React 在后续渲染中计算一个新的状态。
事件处理函数捕获的是创建它的那次渲染中的变量。调用 setter 后,当前渲染不会被“就地修改”;React 会在下一次渲染时提供新的状态快照。
这也是下面两次更新最终只增加 1 的原因:
如果当前 count 是 0,这两个更新都相当于提交 1,而不是依次读取 0、1。
当新状态依赖旧状态时,使用函数式更新:
React 会在处理更新队列时依次执行两个 updater:
因此最终增加 2。函数式更新尤其适合:
如果新值与旧值无关,也可以直接传值:
React 可能把同一事件或同一批任务中的多个更新合并处理,以减少重新渲染次数。React 18 还扩展了自动批处理的范围,具体内容见批量更新。
即使这些更新最终只触发一次重新渲染,也不能因此认为 setter 会同步修改当前变量。需要在更新后读取最新值时,应通过下一次渲染、effect 或事件流程获取,而不是紧接着读取当前闭包中的变量。
React 通常使用 Object.is 比较新旧状态。如果新旧值是同一个引用,React 可能认为状态没有变化:
数组也应返回新引用:
注意,不是所有 setter 调用都会导致组件重新渲染;如果新状态与旧状态相同,React 可以跳过这次更新。
如果初始状态的计算成本较高,可以传入初始化函数:
初始化函数用于计算初始状态,而不是每次渲染都执行。不要把带副作用的操作放进初始化函数;在开发环境 Strict Mode 下,React 可能额外调用初始化逻辑来发现不纯代码,因此初始化函数应保持纯粹。
对于简单值,直接传值即可:
useState 与useReducer 如何选择?两者都用于保存状态,区别主要在更新逻辑的组织方式:
useState:状态结构简单,更新逻辑少,直接调用 setter 更直观。useReducer:状态更新类型较多,或者多个字段会一起变化时,用 action 和 reducer 集中管理逻辑更清晰。无论使用哪一种,都应遵守不可变更新,并使用函数式更新处理依赖旧状态的计算。
不会。setter 会提交更新,新的 state 要到后续渲染中才会成为当前快照。
不一定。React 可能进行批量更新,具体行为还与 React 版本和更新场景有关。
useState 会深度比较对象不会。状态比较不是深度比较。对象和数组应通过创建新引用表达变化。
不会。初始值只用于初始化对应 Hook;后续渲染会继续使用已有状态。若计算初始值成本高,使用惰性初始化函数。
可以这样回答:
useState的状态并不保存在函数局部变量中,而是由 React 保存在组件 Fiber 对应的 Hook 数据中。调用 setter 时,React 会把更新放入队列并安排重新渲染;下一次渲染时,React 按 Hook 调用顺序处理队列,生成新的状态快照。连续更新旧状态时应使用函数式更新,对象和数组则应通过返回新引用来更新。
记住四个关键词:Fiber、Hook 顺序、更新队列、状态快照。