从 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)将回调函数推迟到了下一个事件循环执行:
- 当前同步代码执行完毕(包括 React 事件处理)
- 执行所有微任务(包括 React 的状态更新和 DOM 渲染)
- 执行宏任务队列中的 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('脚本结束')
输出顺序:
- '脚本开始'(同步代码)
- '脚本结束'(同步代码)
- 'Promise'(微任务)
- 'setTimeout'(宏任务)
React 中的状态更新机制
在 React 中,状态更新被归类为 微任务 。当调用 setState 时:
- React 会将状态更新加入更新队列
- 在当前事件循环的微任务阶段批量处理这些更新
- 计算新的虚拟 DOM
- 调度 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 提供了更优雅的解决方案
- 在开发复杂交互组件时,要特别注意状态更新的时序问题