请求 ID 跟踪模式:解决异步请求竞态条件

📋 目录


问题背景

在搜索场景中,用户快速输入关键词时会触发多个并发请求:

typescript 复制代码
// 用户快速输入:a → ac → acd
// 会触发 3 个请求,但返回顺序可能不同

问题表现:

  • 推荐商品列表有时会多出 5 个商品
  • 显示的商品与当前关键词不匹配
  • 旧请求的结果覆盖了新请求的结果

问题分析

竞态条件(Race Condition)

当多个异步请求并发执行时,由于网络延迟不同,返回顺序可能与发起顺序不一致:

vbnet 复制代码
时间线:
T1: 用户输入 "a"  → 触发请求1
T2: 用户输入 "ac" → 触发请求2
T3: 请求2返回     → 设置 productList = ["ac相关商品"]
T4: 请求1返回     → 设置 productList = ["a相关商品"] ❌ 错误!

根本原因:

  • React 的 setState 是异步的
  • 多个请求同时进行,无法保证哪个先返回
  • 旧请求的结果可能覆盖新请求的结果

解决方案

请求 ID 跟踪机制

使用一个全局递增的请求 ID 来跟踪每个请求,确保只处理最新请求的结果。

核心思路

  1. 每个请求分配唯一 ID :使用 useRef 保存一个递增的计数器
  2. 请求开始时保存 ID:在闭包中保存当前请求的 ID
  3. 请求返回时验证:比较保存的 ID 和最新的 ID,判断请求是否仍然有效

实现原理

1. 添加请求 ID 跟踪器

typescript 复制代码
// 用于跟踪当前请求的 ID,确保只处理最新请求的结果
const requestIdRef = useRef<number>(0)

为什么使用 useRef

  • useRef 的值在组件重新渲染时保持不变
  • .current 属性是可变的,可以随时更新
  • 不会触发组件重新渲染

2. 请求开始时生成并保存 ID

typescript 复制代码
useEffect(() => {
  const fetchProductList = async () => {
    // 生成新的请求 ID(先递增再取值)
    const currentRequestId = ++requestIdRef.current

    // 保存当前请求的关键词(双重验证)
    const currentKeyword = keyWord.trim()

    // ... 发起请求
  }

  fetchProductList()
}, [keyWord])

关键点:

  • ++requestIdRef.current 先递增再取值
  • currentRequestId 被闭包捕获,保存请求开始时的值
  • currentKeyword 也被闭包捕获,用于双重验证

3. 请求返回时验证 ID

typescript 复制代码
const response = await getPublicSearchFilter(params)

// 检查是否是最新的请求
if (currentRequestId !== requestIdRef.current) {
  return  // 忽略旧请求的结果
}

// 双重验证:检查关键词是否仍然匹配
if (currentKeyword !== keyWord.trim()) {
  return  // 关键词已改变,忽略结果
}

// 只有通过所有检查才设置 state
setProductList([...proList])

完整代码示例

typescript 复制代码
import { useState, useEffect, useRef } from 'react'

const SearchList = () => {
  const [productList, setProductList] = useState<any[]>([])
  const [productLoading, setProductLoading] = useState<boolean>(false)

  // 用于跟踪当前请求的 ID
  const requestIdRef = useRef<number>(0)

  // 获取推荐商品列表
  useEffect(() => {
    const fetchProductList = async () => {
      // 如果没有搜索关键字,不请求
      if (!keyWord || !keyWord.trim()) {
        setProductList([])
        return
      }

      // 生成新的请求 ID
      const currentRequestId = ++requestIdRef.current
      // 保存当前请求的关键词,用于验证结果是否仍然有效
      const currentKeyword = keyWord.trim()

      setProductLoading(true)
      try {
        const params: Search.SearchParams = {
          keyword: currentKeyword,
          size: 5,
          page: 1,
        }

 
        const response = await getPublicSearchFilter(params)

        // ✅ 检查1:是否是最新的请求
        // 如果不是则忽略结果,避免旧的请求结果覆盖新的结果
        if (currentRequestId !== requestIdRef.current) {
          return
        }

        // ✅ 检查2:关键词是否仍然匹配(双重保险)
        if (currentKeyword !== keyWord.trim()) {
          return
        }

        const items = response?.itemList?.data || []
        const proList = items.slice(0, 5)

        setProductList([...proList])
      } catch (error) {
        // 检查是否是最新的请求,如果不是则忽略错误
        if (currentRequestId !== requestIdRef.current) {
          return
        }
        console.error('获取推荐商品失败:', error)
        setProductList([])
      } finally {
        // 只有在是最新请求时才更新 loading 状态
        if (currentRequestId === requestIdRef.current) {
          setProductLoading(false)
        }
      }
    }

    fetchProductList()
  }, [keyWord])

  // ... 其他代码
}

闭包与 Ref 深入理解

关键概念

1. currentRequestId 被闭包捕获

typescript 复制代码
const fetchProductList = async () => {
  // 这一行执行时,currentRequestId 被"冻结"在闭包中
  const currentRequestId = ++requestIdRef.current  // 假设此时 = 1

  // ... 发起异步请求 ...
  await getPublicSearchFilter(params)  // 这里等待,可能需要几秒钟

  // 当请求返回时,currentRequestId 仍然是 1(闭包保存的值)
  // 但 requestIdRef.current 可能已经是 2、3、4...(最新值)
  if (currentRequestId !== requestIdRef.current) {
    return
  }
}

要点:

  • currentRequestId 是局部常量,在函数执行时被赋值
  • 异步函数返回时,它仍然保持请求开始时的值
  • 这就是闭包:函数"记住"了创建时的变量值

2. requestIdRef.current 始终是最新的

typescript 复制代码
// requestIdRef 是一个 ref 对象
const requestIdRef = useRef<number>(0)

// ref.current 是一个可变引用,每次读取都返回最新值
requestIdRef.current  // 读取时总是最新值

要点:

  • requestIdRef 是 React 的 ref 对象,.current 是可变的
  • 每次读取 requestIdRef.current 都会得到当前最新值
  • 不受闭包影响,因为它不是被捕获的变量,而是通过引用访问

时间线示例

typescript 复制代码
// === 初始状态 ===
requestIdRef.current = 0

// === T1: 用户输入 "a",触发请求1 ===
const fetchProductList1 = async () => {
  const currentRequestId = ++requestIdRef.current
  // 执行后:currentRequestId = 1, requestIdRef.current = 1

  // 闭包捕获:currentRequestId = 1(被"冻结")
  // ref 引用:requestIdRef.current(随时可读取最新值)

  await getPublicSearchFilter(...)  // 等待响应...
}

// === T2: 用户输入 "ac",触发请求2(请求1还在等待中)===
const fetchProductList2 = async () => {
  const currentRequestId = ++requestIdRef.current
  // 执行后:currentRequestId = 2, requestIdRef.current = 2

  await getPublicSearchFilter(...)  // 等待响应...
}

// === T3: 请求1返回(此时 requestIdRef.current 已经是 2)===
// 在 fetchProductList1 的闭包中:
if (currentRequestId !== requestIdRef.current) {
  // currentRequestId = 1(闭包保存的旧值)
  // requestIdRef.current = 2(读取的最新值)
  // 1 !== 2 ✅ 返回,忽略结果
  return
}

// === T4: 请求2返回 ===
// 在 fetchProductList2 的闭包中:
if (currentRequestId !== requestIdRef.current) {
  // currentRequestId = 2(闭包保存的值)
  // requestIdRef.current = 2(如果此时没有新请求)
  // 2 === 2 ✅ 通过检查,设置 state
}

内存中的状态

typescript 复制代码
// 内存布局示意:

// 全局 ref(所有函数共享)
requestIdRef = {
  current: 3  // ← 始终是最新值,随时可读取
}

// 请求1的闭包(已废弃)
fetchProductList1 闭包环境:
  currentRequestId: 1  // ← 被"冻结",不会改变

// 请求2的闭包(已废弃)
fetchProductList2 闭包环境:
  currentRequestId: 2  // ← 被"冻结",不会改变

// 请求3的闭包(当前有效)
fetchProductList3 闭包环境:
  currentRequestId: 3  // ← 被"冻结",不会改变

对比:闭包 vs Ref

特性 currentRequestId (闭包) requestIdRef.current (Ref)
值的变化 创建时赋值后不再改变 每次读取都是最新值
作用域 函数闭包内 全局可访问
用途 保存请求开始时的 ID 保存最新的请求 ID
类比 拍照(定格瞬间) 实时监控(动态更新)

为什么这样设计有效?

typescript 复制代码
// 关键代码
const currentRequestId = ++requestIdRef.current  // 闭包捕获:保存"快照"
// ... 异步操作 ...
if (currentRequestId !== requestIdRef.current) {  // 比较"快照"和"实时值"
  return  // 如果不同,说明已有新请求
}

工作原理:

  1. 请求开始时currentRequestId 保存当前 ID(快照)
  2. 请求进行中requestIdRef.current 可能被新请求更新
  3. 请求返回时 :比较快照和最新值
    • 相同 → 仍是最新请求,处理结果
    • 不同 → 已被新请求取代,忽略结果

最佳实践

1. 何时使用请求 ID 跟踪?

适用场景:

  • 用户输入触发的搜索请求
  • 下拉选择触发的数据加载
  • 任何可能快速连续触发的异步操作

不适用场景:

  • 一次性请求(如页面初始化)
  • 按钮点击触发的请求(用户不会快速点击)
  • 定时轮询请求(通常需要取消机制)

2. 双重验证的必要性

typescript 复制代码
// 检查1:请求 ID(主要检查)
if (currentRequestId !== requestIdRef.current) {
  return
}

// 检查2:关键词匹配(双重保险)
if (currentKeyword !== keyWord.trim()) {
  return
}

为什么需要双重验证?

  • 请求 ID 检查:防止旧请求覆盖新请求
  • 关键词检查:防止边界情况(如请求 ID 相同但关键词已改变)

3. 错误处理

typescript 复制代码
catch (error) {
  // 检查是否是最新的请求,如果不是则忽略错误
  if (currentRequestId !== requestIdRef.current) {
    return
  }
  console.error('获取推荐商品失败:', error)
  setProductList([])
}

要点:

  • 错误处理也要检查请求 ID
  • 避免旧请求的错误影响新请求的状态

4. Loading 状态管理

typescript 复制代码
finally {
  // 只有在是最新请求时才更新 loading 状态
  if (currentRequestId === requestIdRef.current) {
    setProductLoading(false)
  }
}

要点:

  • Loading 状态也要检查请求 ID
  • 避免旧请求的 loading 状态影响 UI

其他解决方案对比

方案1:AbortController(推荐用于可取消的请求)

typescript 复制代码
const abortControllerRef = useRef<AbortController | null>(null)

useEffect(() => {
  // 取消之前的请求
  if (abortControllerRef.current) {
    abortControllerRef.current.abort()
  }

  const abortController = new AbortController()
  abortControllerRef.current = abortController

  fetch(url, { signal: abortController.signal })
    .then(response => {
      if (abortController.signal.aborted) return
      // 处理响应
    })
}, [deps])

优点:

  • 可以真正取消网络请求
  • 节省带宽和服务器资源

缺点:

  • 需要 API 支持 AbortController
  • 某些旧的 API 可能不支持

方案2:请求 ID 跟踪(本文方案)

优点:

  • 适用于任何异步操作
  • 不依赖 API 支持
  • 实现简单

缺点:

  • 不能真正取消网络请求
  • 请求仍会占用带宽

方案3:防抖(Debounce)

typescript 复制代码
const debouncedSearch = useMemo(
  () => debounce((keyword: string) => {
    fetchProductList(keyword)
  }, 300),
  []
)

优点:

  • 减少请求次数
  • 简单易用

缺点:

  • 延迟响应
  • 用户可能等待更长时间

总结

核心要点

  1. 问题根源:多个异步请求并发执行,返回顺序不确定
  2. 解决方案:使用请求 ID 跟踪,确保只处理最新请求
  3. 关键机制:闭包保存"快照",Ref 提供"实时值"
  4. 验证策略:双重验证(请求 ID + 业务参数)

适用场景

✅ 搜索输入框的联想词/推荐商品 ✅ 下拉选择的数据加载 ✅ 快速连续触发的异步操作

关键代码模式

typescript 复制代码
// 1. 创建跟踪器
const requestIdRef = useRef<number>(0)

// 2. 请求开始时保存 ID
const currentRequestId = ++requestIdRef.current

// 3. 请求返回时验证
if (currentRequestId !== requestIdRef.current) {
  return  // 忽略旧请求
}

记忆口诀

"闭包保存快照,Ref 提供实时值,比较两者判断有效性"
最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

相关推荐
开心_开心急了2 小时前
AI+PySide6实现自定义窗口标题栏目(titleBar)
前端
开心_开心急了2 小时前
Ai加Flutter实现自定义标题栏(appBar)
前端·flutter
布列瑟农的星空2 小时前
SSE与流式传输(Streamable HTTP)
前端·后端
GISer_Jing2 小时前
跨境营销前端AI应用业务领域
前端·人工智能·aigc
oak隔壁找我2 小时前
Node.js的package.json
前端·javascript
talenteddriver2 小时前
web: http请求(自用总结)
前端·网络协议·http
全栈派森2 小时前
Flutter 实战:基于 GetX + Obx 的企业级架构设计指南
前端·flutter
Awu12272 小时前
Vue3自定义渲染器:原理剖析与实践指南
前端·vue.js·three.js
支撑前端荣耀3 小时前
从零实现前端监控告警系统:SMTP + Node.js + 个人邮箱 完整免费方案
前端·javascript·面试