在 React 开发中,我们经常会遇到这样的场景:用户快速切换筛选条件,触发多个异步请求。由于网络延迟的不确定性,请求的返回顺序可能与发送顺序不一致,导致页面显示错误的数据。这就是经典的
竞态条件(Race Condition)问题。
一、问题场景
1.1 业务背景
我们有一个变更日历组件,用于展示每日的需求创建数和服务发布数。用户可以通过多个条件进行筛选:
// 筛选条件
- 年份
- 月份
1.2 原始代码
const useCalendar = () => {
const [calendarData, setCalendarData] = useState([])
const [loading, setLoading] = useState(false)
// 获取日历数据
useEffect(() => {
const loadData = async () => {
setLoading(true)
try {
const data = await fetchCalendarData(year, month)
setCalendarData(data)
} catch (error) {
console.error("获取日历数据失败:", error)
setCalendarData([])
} finally {
setLoading(false)
}
}
loadData()
}, [year, month])
// ...
}
1.3 问题复现
当用户快速从 1月 → 2月 → 3月 切换时:
时间线 ────────────────────────────────────────────────────────►
用户操作: 选1月 选2月 选3月
│ │ │
▼ ▼ ▼
发出请求: 请求1月 请求2月 请求3月
│ │ │
│ │ └────► 返回3月数据 (200ms)
│ │
│ └─────────────────► 返回2月数据 (500ms)
│
└──────────────────────────────► 返回1月数据 (800ms)
问题:用户最后选择的是 3月,但因为 1月的请求最后返回,页面最终显示的是 1月的数据!
这就是竞态条件:多个异步操作竞争同一个资源(state),结果取决于它们完成的顺序,而非发起的顺序。
二、解决方案
2.1 核心思路
我们需要一种机制来标记和忽略过时的请求。当用户切换条件时,把之前的请求标记为"已取消",即使它返回了数据,也不更新 state。
2.2 使用 isCancelled 标志位
useEffect(() => {
// 1️⃣ 每次 useEffect 执行时,创建一个新的标志位
let isCancelled = false
const loadData = async () => {
setLoading(true)
try {
const params = buildCalendarParams(year, month)
const response = await get("xxx", params)
// 3️⃣ 请求返回时,检查是否已被取消
if (isCancelled) {
console.log("请求已取消,忽略响应")
return
}
// 只有未取消的请求才更新数据
if (response.status === 0) {
setCalendarData(response.data || [])
} else {
setCalendarData([])
}
} catch (error) {
if (isCancelled) return
console.error("获取日历数据失败:", error)
setCalendarData([])
} finally {
if (!isCancelled) {
setLoading(false)
}
}
}
loadData()
// 2️⃣ 清理函数:依赖变化时,标记旧请求为已取消
return () => {
isCancelled = true
}
}, [year, month])
三、原理详解
3.1 useEffect 的清理函数
React 的 useEffect 可以返回一个清理函数。这个清理函数会在以下时机执行:
- 依赖变化时:先执行上一次 effect 的清理函数,再执行新的 effect
- 组件卸载时:执行最后一次 effect 的清理函数
useEffect(() => {
// effect 逻辑
console.log("effect 执行")
return () => {
// 清理逻辑
console.log("cleanup 执行")
}
}, [dependency])
当 dependency 从 A 变为 B 时,执行顺序是:
1. cleanup(A) ← 先清理旧的
2. effect(B) ← 再执行新的
3.2 闭包的作用
这个方案能够生效的关键是 JavaScript 闭包。
每次 useEffect 执行时,都会创建一个全新的、独立的 isCancelled 变量。清理函数和异步请求的回调函数通过闭包记住它们所属的那个 isCancelled。
// 第1次执行(选择1月)
useEffect(() => {
let isCancelled_1 = false // 独立的变量
// 请求1的回调闭包引用 isCancelled_1
fetch(...).then(() => {
if (isCancelled_1) return // 检查的是 isCancelled_1
setData(...)
})
return () => {
isCancelled_1 = true // 修改的是 isCancelled_1
}
}, [month])
// 第2次执行(选择2月)
useEffect(() => {
let isCancelled_2 = false // 另一个独立的变量
// 请求2的回调闭包引用 isCancelled_2
fetch(...).then(() => {
if (isCancelled_2) return // 检查的是 isCancelled_2
setData(...)
})
return () => {
isCancelled_2 = true // 修改的是 isCancelled_2
}
}, [month])
3.3 完整执行流程
让我们用具体的例子走一遍完整流程:
═══════════════════════════════════════════════════════════════
【步骤1:用户选择 1月】
useEffect 第1次执行:
├─ 创建 isCancelled_1 = false
├─ 发出请求1(获取1月数据)
└─ 返回清理函数 cleanup_1
内存状态:
┌─────────────────────┐
│ isCancelled_1 = false│
└─────────────────────┘
═══════════════════════════════════════════════════════════════
【步骤2:用户选择 2月】(依赖变化)
React 执行顺序:
1. 执行 cleanup_1()
└─ isCancelled_1 = true ✅ 标记请求1已过时
2. useEffect 第2次执行
├─ 创建 isCancelled_2 = false
├─ 发出请求2(获取2月数据)
└─ 返回清理函数 cleanup_2
内存状态:
┌─────────────────────┐
│ isCancelled_1 = true │ ← 已标记为取消
└─────────────────────┘
┌─────────────────────┐
│ isCancelled_2 = false│ ← 新的,有效的
└─────────────────────┘
═══════════════════════════════════════════════════════════════
【步骤3:用户选择 3月】(依赖变化)
React 执行顺序:
1. 执行 cleanup_2()
└─ isCancelled_2 = true ✅ 标记请求2已过时
2. useEffect 第3次执行
├─ 创建 isCancelled_3 = false
├─ 发出请求3(获取3月数据)
└─ 返回清理函数 cleanup_3
内存状态:
┌─────────────────────┐
│ isCancelled_1 = true │ ← 已取消
└─────────────────────┘
┌─────────────────────┐
│ isCancelled_2 = true │ ← 已取消
└─────────────────────┘
┌─────────────────────┐
│ isCancelled_3 = false│ ← 当前有效
└─────────────────────┘
═══════════════════════════════════════════════════════════════
【步骤4:请求陆续返回】
请求3 返回(最快,200ms):
├─ 回调检查 isCancelled_3
├─ isCancelled_3 === false ✅
└─ 更新数据为 3月 ✅
请求2 返回(500ms):
├─ 回调检查 isCancelled_2
├─ isCancelled_2 === true ❌
└─ 直接 return,不更新数据
请求1 返回(最慢,800ms):
├─ 回调检查 isCancelled_1
├─ isCancelled_1 === true ❌
└─ 直接 return,不更新数据
═══════════════════════════════════════════════════════════════
【最终结果】页面显示 3月数据 ✅ 正确!
四、其他解决方案
4.1 使用 AbortController
如果你的请求支持取消(如 fetch API),可以使用 AbortController 来真正取消请求:
useEffect(() => {
const controller = new AbortController()
const loadData = async () => {
try {
const response = await fetch(url, {
signal: controller.signal
})
const data = await response.json()
setCalendarData(data)
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求被取消')
return
}
console.error(error)
}
}
loadData()
return () => {
controller.abort() // 真正取消请求
}
}, [dependencies])
4.2 使用第三方库
一些流行的数据获取库已经内置了竞态条件处理:
- React Query / TanStack Query
- SWR
- RTK Query
// 使用 React Query
import { useQuery } from '@tanstack/react-query'
const { data, isLoading } = useQuery({
queryKey: ['calendar', year, month, type],
queryFn: () => fetchCalendarData(year, month, type)
})
// React Query 自动处理竞态条件
五、总结
| 概念 | 说明 |
|---|---|
| 竞态条件 | 多个异步操作竞争同一资源,结果取决于完成顺序 |
| 闭包 | 函数可以访问它被创建时所在作用域的变量 |
| 清理函数 | useEffect 返回的函数,在依赖变化或组件卸载时执行 |
| 解决思路 | 标记过时请求,忽略其响应 |
关键代码模板
useEffect(() => {
let isCancelled = false
const fetchData = async () => {
try {
const data = await api.get(...)
if (isCancelled) return // 关键检查
setState(data)
} catch (error) {
if (isCancelled) return
handleError(error)
}
}
fetchData()
return () => {
isCancelled = true // 标记取消
}
}, [dependencies])
理解这个模式后,你就能在任何需要处理异步请求竞态条件的场景中应用它。这是 React 开发中的一个重要技巧