从 React 状态更新竞态问题,聊聊 JavaScript 事件循环那些事

67 阅读3分钟

从 React 状态更新竞态问题,聊聊 JavaScript 事件循环那些事

场景复现:一个诡异的表单选中问题

最近在开发一个多选题组件时,遇到了一个奇怪的问题:当用户快速连续点击选项时,最终显示的结果有时会"跳变"——明明最后点击的是 A 选项,却显示成了 B 选项。

简化后的代码如下:

function handleChange(type, id, optionId) {
  const setState = type === 'situation' ? setSituationAnswer : setConditionAnswer

  const repeatIndex = situationAnswer.findIndex(item => item.id === id)

  if (repeatIndex > -1) {
    setState(situationAnswer.map(item => (item.id === id ? { ...item, optionId } : item)))
  } else {
    setState([...situationAnswer, { id, optionId }])
  }
}

问题本质:状态更新的竞态条件

经过排查,发现问题出在 React 的状态更新机制上:

  • 用户快速点击触发多次 handleChange
  • 每次调用都会触发状态更新(setState)
  • React 的状态更新是异步且批量处理的
  • 由于计算新状态需要时间,后触发的更新可能先完成
  • 最终导致先点击的结果覆盖了后点击的结果

解决方案:setTimeout 的妙用

在尝试了多种方案后,最终用一个简单的 setTimeout 解决了问题:

function handleChange(type, id, optionId) {
  setTimeout(() => {
    // ...原有逻辑
  }, 0)
}

为什么这样能解决问题?

关键在于 setTimeout(fn, 0)将回调函数推迟到了下一个事件循环执行:

  1. 当前同步代码执行完毕(包括 React 事件处理)
  2. 执行所有微任务(包括 React 的状态更新和 DOM 渲染)
  3. 执行宏任务队列中的 setTimeout 回调

这样确保了前一次状态更新完全完成后才处理下一次点击。

深入理解:JavaScript 事件循环机制

1. 基本概念

JavaScript 是单线程语言,通过 ‌ 事件循环(Event Loop)‌ 机制处理异步操作。整个运行机制可以简化为:

  • ‌ 执行同步代码 ‌:按顺序执行调用栈中的代码
  • ‌ 处理微任务 ‌:执行所有微任务队列中的任务
  • ‌ 渲染更新 ‌:如有需要,执行 UI 渲染
  • ‌ 处理宏任务 ‌:从宏任务队列中取出一个任务执行
  • 重复上述过程

2. 宏任务 vs 微任务

  • ‌ 宏任务 ‌:setTimeout、setInterval、I/O 操作、UI 渲染等
  • ‌ 微任务 ‌:Promise.then、MutationObserver 等

举个 🌰

console.log('脚本开始')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

Promise.resolve().then(() => {
  console.log('Promise')
})

console.log('脚本结束')

输出顺序:

  1. '脚本开始'(同步代码)
  2. '脚本结束'(同步代码)
  3. 'Promise'(微任务)
  4. 'setTimeout'(宏任务)

React 中的状态更新机制

在 React 中,状态更新被归类为 ‌ 微任务 ‌。当调用 setState 时:

  1. React 会将状态更新加入更新队列
  2. 在当前事件循环的微任务阶段批量处理这些更新
  3. 计算新的虚拟 DOM
  4. 调度 DOM 更新

更现代的解决方案

虽然 setTimeout 能解决问题,但在 React 18+中,我们还有更好的选择:

1. 函数式更新

setState(prevState => {
  // 基于prevState计算新状态
  return newState
})

2. 自动批处理

React 18 默认启用自动批处理,多个状态更新会自动合并。

3. 使用 transition

const [isPending, startTransition] = useTransition()

startTransition(() => {
  // 非紧急更新
  setState(newState)
})

总结

  • 理解事件循环机制是解决异步问题的关键
  • setTimeout(fn, 0)通过将任务推迟到下一个事件循环,可以解决某些竞态问题
  • 现代 React 提供了更优雅的解决方案
  • 在开发复杂交互组件时,要特别注意状态更新的时序问题