请求 ID 跟踪模式:解决异步请求竞态条件
📋 目录
问题背景
在搜索场景中,用户快速输入关键词时会触发多个并发请求:
// 用户快速输入:a → ac → acd
// 会触发 3 个请求,但返回顺序可能不同
问题表现:
- 推荐商品列表有时会多出 5 个商品
- 显示的商品与当前关键词不匹配
- 旧请求的结果覆盖了新请求的结果
问题分析
竞态条件(Race Condition)
当多个异步请求并发执行时,由于网络延迟不同,返回顺序可能与发起顺序不一致:
时间线:
T1: 用户输入 "a" → 触发请求1
T2: 用户输入 "ac" → 触发请求2
T3: 请求2返回 → 设置 productList = ["ac相关商品"]
T4: 请求1返回 → 设置 productList = ["a相关商品"] ❌ 错误!
根本原因:
- React 的
setState是异步的 - 多个请求同时进行,无法保证哪个先返回
- 旧请求的结果可能覆盖新请求的结果
解决方案
请求 ID 跟踪机制
使用一个全局递增的请求 ID 来跟踪每个请求,确保只处理最新请求的结果。
核心思路
-
每个请求分配唯一 ID:使用
useRef保存一个递增的计数器 - 请求开始时保存 ID:在闭包中保存当前请求的 ID
- 请求返回时验证:比较保存的 ID 和最新的 ID,判断请求是否仍然有效
实现原理
1. 添加请求 ID 跟踪器
// 用于跟踪当前请求的 ID,确保只处理最新请求的结果
const requestIdRef = useRef<number>(0)
为什么使用 useRef?
-
useRef的值在组件重新渲染时保持不变 -
.current属性是可变的,可以随时更新 - 不会触发组件重新渲染
2. 请求开始时生成并保存 ID
useEffect(() => {
const fetchProductList = async () => {
// 生成新的请求 ID(先递增再取值)
const currentRequestId = ++requestIdRef.current
// 保存当前请求的关键词(双重验证)
const currentKeyword = keyWord.trim()
// ... 发起请求
}
fetchProductList()
}, [keyWord])
关键点:
-
++requestIdRef.current先递增再取值 -
currentRequestId被闭包捕获,保存请求开始时的值 -
currentKeyword也被闭包捕获,用于双重验证
3. 请求返回时验证 ID
const response = await getPublicSearchFilter(params)
// 检查是否是最新的请求
if (currentRequestId !== requestIdRef.current) {
return // 忽略旧请求的结果
}
// 双重验证:检查关键词是否仍然匹配
if (currentKeyword !== keyWord.trim()) {
return // 关键词已改变,忽略结果
}
// 只有通过所有检查才设置 state
setProductList([...proList])
完整代码示例
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 被闭包捕获
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 始终是最新的
// requestIdRef 是一个 ref 对象
const requestIdRef = useRef<number>(0)
// ref.current 是一个可变引用,每次读取都返回最新值
requestIdRef.current // 读取时总是最新值
要点:
-
requestIdRef是 React 的 ref 对象,.current是可变的 - 每次读取
requestIdRef.current都会得到当前最新值 - 不受闭包影响,因为它不是被捕获的变量,而是通过引用访问
时间线示例
// === 初始状态 ===
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
}
内存中的状态
// 内存布局示意:
// 全局 ref(所有函数共享)
requestIdRef = {
current: 3 // ← 始终是最新值,随时可读取
}
// 请求1的闭包(已废弃)
fetchProductList1 闭包环境:
currentRequestId: 1 // ← 被"冻结",不会改变
// 请求2的闭包(已废弃)
fetchProductList2 闭包环境:
currentRequestId: 2 // ← 被"冻结",不会改变
// 请求3的闭包(当前有效)
fetchProductList3 闭包环境:
currentRequestId: 3 // ← 被"冻结",不会改变
对比:闭包 vs Ref
| 特性 |
currentRequestId (闭包) |
requestIdRef.current (Ref) |
|---|---|---|
| 值的变化 | 创建时赋值后不再改变 | 每次读取都是最新值 |
| 作用域 | 函数闭包内 | 全局可访问 |
| 用途 | 保存请求开始时的 ID | 保存最新的请求 ID |
| 类比 | 拍照(定格瞬间) | 实时监控(动态更新) |
为什么这样设计有效?
// 关键代码
const currentRequestId = ++requestIdRef.current // 闭包捕获:保存"快照"
// ... 异步操作 ...
if (currentRequestId !== requestIdRef.current) { // 比较"快照"和"实时值"
return // 如果不同,说明已有新请求
}
工作原理:
-
请求开始时:
currentRequestId保存当前 ID(快照) -
请求进行中:
requestIdRef.current可能被新请求更新 -
请求返回时:比较快照和最新值
- ✅ 相同 → 仍是最新请求,处理结果
- ❌ 不同 → 已被新请求取代,忽略结果
最佳实践
1. 何时使用请求 ID 跟踪?
✅ 适用场景:
- 用户输入触发的搜索请求
- 下拉选择触发的数据加载
- 任何可能快速连续触发的异步操作
❌ 不适用场景:
- 一次性请求(如页面初始化)
- 按钮点击触发的请求(用户不会快速点击)
- 定时轮询请求(通常需要取消机制)
2. 双重验证的必要性
// 检查1:请求 ID(主要检查)
if (currentRequestId !== requestIdRef.current) {
return
}
// 检查2:关键词匹配(双重保险)
if (currentKeyword !== keyWord.trim()) {
return
}
为什么需要双重验证?
- 请求 ID 检查:防止旧请求覆盖新请求
- 关键词检查:防止边界情况(如请求 ID 相同但关键词已改变)
3. 错误处理
catch (error) {
// 检查是否是最新的请求,如果不是则忽略错误
if (currentRequestId !== requestIdRef.current) {
return
}
console.error('获取推荐商品失败:', error)
setProductList([])
}
要点:
- 错误处理也要检查请求 ID
- 避免旧请求的错误影响新请求的状态
4. Loading 状态管理
finally {
// 只有在是最新请求时才更新 loading 状态
if (currentRequestId === requestIdRef.current) {
setProductLoading(false)
}
}
要点:
- Loading 状态也要检查请求 ID
- 避免旧请求的 loading 状态影响 UI
其他解决方案对比
方案1:AbortController(推荐用于可取消的请求)
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)
const debouncedSearch = useMemo(
() => debounce((keyword: string) => {
fetchProductList(keyword)
}, 300),
[]
)
优点:
- 减少请求次数
- 简单易用
缺点:
- 延迟响应
- 用户可能等待更长时间
总结
核心要点
- 问题根源:多个异步请求并发执行,返回顺序不确定
- 解决方案:使用请求 ID 跟踪,确保只处理最新请求
- 关键机制:闭包保存"快照",Ref 提供"实时值"
- 验证策略:双重验证(请求 ID + 业务参数)
适用场景
✅ 搜索输入框的联想词/推荐商品 ✅ 下拉选择的数据加载 ✅ 快速连续触发的异步操作
关键代码模式
// 1. 创建跟踪器
const requestIdRef = useRef<number>(0)
// 2. 请求开始时保存 ID
const currentRequestId = ++requestIdRef.current
// 3. 请求返回时验证
if (currentRequestId !== requestIdRef.current) {
return // 忽略旧请求
}
记忆口诀
"闭包保存快照,Ref 提供实时值,比较两者判断有效性"
最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!