React 中的竞态条件问题及解决方案:从一个日历组件说起

16 阅读5分钟

在 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 可以返回一个清理函数。这个清理函数会在以下时机执行:

  1. 依赖变化时:先执行上一次 effect 的清理函数,再执行新的 effect
  2. 组件卸载时:执行最后一次 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 开发中的一个重要技巧