Skip to content

快照、队列与批处理:一次点击里 state 到底是多少 ​

问题:连续加三次为什么只增加一 ​

count 不是可以在当前函数里原地改写的普通变量。组件每次被 React 调用时,会拿到那一轮的 state 快照;这一轮创建的事件处理器也固定读取这份快照。setter 把更新放进下一次渲染的队列。

完整实验:四种更新放在一起观察 ​

jsx
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”:

text
初始 0 → 替换 1 → 替换 1 → 替换 1 = 1

“三个函数更新”把函数交给 React。React 在处理队列时,将上一步待定值依次传入:

text
初始 0 → 0 + 1 → 1 + 1 → 2 + 1 = 3

replaceThenUpdate 先根据当前快照排入替换值,再排入函数。若当前是 0:

text
初始 0 → 替换 5 → 5 + 1 = 6

更新函数应是纯函数:只根据参数返回下一值,不修改外部变量。React 开发模式可能额外调用更新函数来帮助发现不纯逻辑,因此副作用会暴露为重复执行。

顺序反过来,结果也会不同。把 replaceThenUpdate 中的两次调用交换为下面两行,刷新到 0 再点击:

js
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。

下一篇 ​

对象与数组的不可变更新

用心记录每一步成长