一. 什么是前端竞态问题?
在前端开发中,我们大量依赖异步操作,但异步并不等于安全,在多个异步请求同时存在时,并且共同影响同一份状态时,就很容易出现竞态问题
竞态是指:
程序的最终结果依赖于异步操作的完成顺序,而这个顺序是不可控的 ,核心特征只有一句话: "旧请求覆盖了新状态"
二. 竞态的典型场景
1. 搜索联想
- 用户高频输入
- 多个请求同时发出
- 返回顺序不可预测
2. 接口重复触发
- 按钮连续点击
- 页面初始化 + 手动刷新
3. Tab(选项卡) 频繁切换
- 多Tab共用一个状态
- 只关心当前激活项
- 是竞态问题的重灾区
三. 案例 (有bug的)
1. 搜索建议
javascript
// 有Bug的实现
let lastSearch = '';
async function updateSearchSuggestions(query) {
lastSearch = query;
const results = await fetch(`/api/suggest?q=${query}`);
// 竞态发生:如果用户输入很快,lastSearch可能已经改变
}
// 用户输入 "react hooks"
// 时间线:
// t0: 输入 "r" -> 请求A发送
// t1: 输入 "re" -> 请求B发送
// t2: 请求B返回(更快) -> 显示 "re" 的建议
// t3: 请求A返回(更慢) -> 检查通过,覆盖为 "r" 的建议 ❌
// 最终显示的是旧结果!
2.选项卡切换
javascript
// 有Bug的选项卡实现
function TabContainer() {
const [activeTab, setActiveTab] = useState('details');
const [content, setContent] = useState(null);
useEffect(() => {
fetchTabContent(activeTab).then(setContent);
}, [activeTab]);
return (
<div>
<button onClick={() => setActiveTab('details')}>详情</button>
<button onClick={() => setActiveTab('comments')}>评论</button>
<button onClick={() => setActiveTab('reviews')}>评价</button>
{content && <TabContent content={content} />}
</div>
);
}
// 问题:快速点击不同选项卡
// 点击顺序:详情 -> 评论 -> 评价
// 请求发送顺序:详情请求 -> 评论请求 -> 评价请求
// 但响应可能以任意顺序返回:评价 -> 详情 -> 评论
// 最终显示的是最后返回的结果,而不是当前激活的选项卡!
3. 表单自动保存(AutoSave)
javascript
// 有Bug的自动保存
let saveTimeout = null;
let pendingSave = null;
function onFormChange(data) {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
const saveId = Date.now();
pendingSave = saveId;
await saveToServer(data);
// 竞态发生:如果新的保存已经开始
if (pendingSave === saveId) {
showSaveSuccess();
}
}, 1000);
}
// 用户快速编辑:
// t0: 修改字段A -> 计划保存1
// t1: 500ms后修改字段B -> 取消保存1,计划保存2
// t2: 保存2开始执行,saveId = 200
// t3: 保存1(被取消但已开始)完成,检查失败,不显示成功
// t4: 保存2完成,显示成功 ✅
// 看起来正常?但如果保存2比保存1先完成...
// 用户快速编辑:
// t0: 修改字段A -> 计划保存1
// t1: 500ms后修改字段B -> 取消保存1,计划保存2
// t2: 保存2开始执行,saveId = 200
// t3: 保存1(被取消但已开始)完成,检查失败,不显示成功
// t4: 保存2完成,显示成功 ✅
// 看起来正常?但如果保存2比保存1先完成...
四. 竞态问题的分类
1. 读-写 竞态(最常见)
// 多个读取操作,一个写入操作
async function loadUserProfile(userId) {
// 读取用户基本信息
const basicInfo = await fetch(`/api/users/${userId}/basic`);
// 同时,另一个操作更新了用户数据
// 例如:用户自己编辑了个人资料
// 读取详细信息(可能基于过期的基础信息)
const details = await fetch(`/api/users/{userId}/details?version={basicInfo.version}`);
// 现在 basicInfo 和 details 可能不一致!
}
2. 写-写竞态
// 多个写入操作冲突
async function updateCart(itemId, quantity) {
// 读取当前购物车
const cart = await fetch('/api/cart');
// 同时,另一个设备也更新了购物车
// 基于过期的购物车状态更新
const updated = updateCartItem(cart, itemId, quantity);
await saveCart(updated); // 可能覆盖其他设备的更新!
}
五. 解决竞态的核心思想
不管用什么方案,目标只有一个 : 只让"当前有效请求"的结果生效
1 .AbortController 取消请求(现在项目首选)
需要HTTP库支持(fetch/axios支持,XMLHttpRequest需要封装)
服务器端可能已经开始处理请求
较旧的浏览器(IE)不支持
举例 1. uni.request
javascript
let debounceTimer: any = null
// 可取消的 Promise 类型
export type CancelablePromise<T> = Promise<T> & {
//取消当前请求(内部调用uni.request的ReauestTask.abort)
abort: () => void
}
export function http<T>(options: CustomRequestOptions): CancelablePromise<T> {
let requestTask: UniApp.RequestTask | null = null
// 创建可取消的 Promise 对象 挂载abort方法
const p = new Promise<T>((resolve, reject) => {
// 创建请求任务, requestTask 是 uni.request 的返回值,用于取消请求
requestTask = uni.request({
...options,
dataType: 'json',
// #ifndef MP-WEIXIN
responseType: 'json',
// #endif
// 响应成功
success: async (res) => {
const responseData = res.data as IResponse<T>
console.log('responseData', responseData)
const { errCode } = responseData
// 检查是否是401错误(包括HTTP状态码401或业务码401)
const isTokenExpired = res.statusCode === 401 || errCode === 401
if (isTokenExpired) {
if (debounceTimer) return // 1 秒内再次触发不执行
debounceTimer = setTimeout(() => {
debounceTimer = null
}, 1000)
const tokenStore = useTokenStore()
console.log('拦截器token', tokenStore.tokenInfo)
// 退出登录
await tokenStore.logout()
uni.showToast({
icon: 'none',
title: (res.data as any)?.message || responseData.message || '登录状态已过期,请重新登录',
})
uni.reLaunch({
url: LOGIN_PAGE,
})
return reject(res)
}
// 处理其他成功状态(HTTP状态码200-299)
if (res.statusCode >= 200 && res.statusCode < 300) {
// 处理业务逻辑错误
if (errCode !== ResultEnum.Success0 && errCode !== ResultEnum.Success200) {
console.log('业务逻辑错误.请求错误')
uni.showToast({
icon: 'none',
title: responseData.message || (responseData as any).errMsg || '请求错误',
})
// 这里仍然 resolve,让业务层自己判断 errCode
}
return resolve(responseData as any as T)
}
// 处理其他错误
!options.hideErrorToast &&
uni.showToast({
icon: 'none',
title: (responseData as any).message || '请求错误',
})
reject(res)
},
// 响应失败
fail(err) {
// ✅ abort 也会走 fail,这里做区分:取消不弹"网络错误"
const msg = (err as any)?.errMsg || ''
const isAbort = typeof msg === 'string' && msg.toLowerCase().includes('abort')
if (!isAbort) {
uni.showToast({
icon: 'none',
title: '网络错误,换个网络试试',
})
}
reject(err)
},
})
}) as CancelablePromise<T>
// ✅ 暴露 abort
p.abort = () => {
requestTask?.abort()
}
return p
}
项目中应用 微信小程序中 tab切换页
javascript// 保存"当前请求句柄",用于 abort let currentReq: { abort: () => void } | null = null // 请求序号,只认"最后一次请求"的结果 let latestSeq = 0 const queryList = async (page: number, size: number) => { // 每次请求都生成一个唯一的序号 const seq = ++latestSeq //每次请求+1 //本次请求发起时的tab值快照 const tabValueAtStart = activeTab.value // 取消上一次请求(内部调用requestTask.abort) currentReq?.abort() currentReq = null try { // 发起本次请求(veMoveList 必须返回带 abort 的 Promise) const req = veMoveList({ page, size, regionId: uni.getStorageSync('selectedRegionId'), result: tabValueAtStart, // keyword: searchValue.value, }) as unknown as Promise<any> & { abort: () => void } // 保存本次请求句柄,用于 abort currentReq = req // 等待本次请求完成 const res: any = await req // 如果有新的请求发起, 就忽略本次请求结果 if (seq !== latestSeq) return // tab 已切换也忽略 if (tabValueAtStart !== activeTab.value) return if (res.errCode === 200) { const list = res.data?.content || [] const total = res.data?.totalElements ?? 0 pagingRef.value.completeByTotal(list, total) } else { pagingRef.value.complete(false) } } catch (err: any) { const msg = err?.errMsg || '' const isAbort = typeof msg === 'string' && msg.toLowerCase().includes('abort') if (isAbort) return console.error('请求失败:', err) // 只在"当前请求"失败时通知 paging if (seq === latestSeq) pagingRef.value.complete(false) } finally { // 只有"当前这次请求"结束,才清理 currentReq if (seq === latestSeq) currentReq = null } }
2. axios 中使用 AbortController
javascript
// request.js
import axios from 'axios'
export function request(config) {
return axios({
...config,
signal: config.signal
})
}
2. 请求序列号(Request ID) 逻辑层防护
通过序列号管理器, 通过每个请求分配唯一ID, 只处理最新请求的结果
这不能真正取消请求,但能防止旧数据覆盖新数据
javascript
/**
* 请求序列号管理器
* 通过给每个请求分配唯一ID,只处理最新请求的结果
* 这不能真正取消请求,但能防止旧数据覆盖新数据
*/
class RequestSequencer {
constructor() {
// 记录最新请求的ID
this.lastRequestId = 0;
// 当前有效的请求ID
this.currentRequestId = 0;
}
/**
* 执行异步函数,只返回最新请求的结果
* @param {Function} asyncFn - 要执行的异步函数
* @returns {Promise<any>} 结果(如果是当前请求)
*/
async makeRequest(asyncFn) {
// 生成新的请求ID
const requestId = ++this.lastRequestId;
// 更新当前有效请求ID
this.currentRequestId = requestId;
try {
// 执行实际的异步操作
const result = await asyncFn();
// 关键检查:这个结果是否属于当前有效的请求?
// 如果不是,说明在请求期间有新的请求开始了
if (this.currentRequestId === requestId) {
return result; // 是当前请求,返回结果
}
// 否则忽略这个结果
console.log('忽略过期请求的结果');
return null;
} catch (error) {
// 错误处理也要检查是否当前请求
if (this.currentRequestId === requestId) {
throw error; // 是当前请求的错误,需要抛出
}
console.log('忽略过期请求的错误');
return null; // 不是当前请求的错误,忽略
}
}
/**
* 使所有请求失效
* 在状态重置时调用,比如切换页面、重置表单
*/
invalidate() {
this.currentRequestId = 0; // 设置为0使所有请求ID都不匹配
}
}
3. 防抖 - 频率控制
适用场景:
- 搜索框输入
- 窗口大小调整
- 滚动事件
- 任何高频触发的事件
4.节流, 流量控制
适用场景:
- 无限滚动加载
- 鼠标移动事件
- 游戏循环
- 实时数据更新
5. 队列/ 锁(表单提交) 避免重复提交
核心思路: 同一时间只允许一个异步任务执行,其余任务排队或被阻塞
通过串行化执行,彻底消除并发带来的竞态
javascript
// 8.1 任务队列(AsyncQueue)
// 用于将多个异步任务串行执行,避免并发引发竞态问题
class AsyncQueue {
constructor() {
// 存放等待执行的任务队列
// 每一项包含:task(任务函数)、resolve、reject
this.queue = []
// 当前是否有任务正在执行
// true 表示队列被"锁住",防止并发执行
this.processing = false
}
// 向队列中添加一个任务
// task 必须是一个返回 Promise 的函数
async enqueue(task) {
return new Promise((resolve, reject) => {
// 将任务及其 Promise 控制器放入队列
this.queue.push({ task, resolve, reject })
// 尝试启动队列处理
this.process()
})
}
// 队列调度核心逻辑
async process() {
// 如果当前正在执行任务,或队列为空,直接返回
// 保证同一时间只执行一个任务(互斥锁)
if (this.processing || this.queue.length === 0) {
return
}
// 标记为正在处理,锁住队列
this.processing = true
// 取出队列中的第一个任务(FIFO)
const { task, resolve, reject } = this.queue.shift()
try {
// 执行任务,并等待其完成
const result = await task()
// 任务成功,通知 enqueue 返回的 Promise
resolve(result)
} catch (error) {
// 任务失败,向外抛出错误
reject(error)
} finally {
// 当前任务执行完毕,释放锁
this.processing = false
// 递归调用,继续处理下一个任务
this.process()
}
}
// 清空等待中的任务(不影响正在执行的任务)
clear() {
this.queue = []
}
}
适用于在 表单/支付/下单, 必须严格按照顺序执行的接口
5.1 互斥锁 Mutex (队列+唤醒)
javascript
ass Mutex {
constructor() {
this.locked = false; // 当前是否上锁
this.waiting = []; // 等待队列(resolve 列表)
}
// 获取锁
async lock() {
if (!this.locked) {
// 没人占用,直接拿锁
this.locked = true;
return;
}
// 已被占用,进入等待队列
await new Promise(resolve => {
this.waiting.push(resolve);
});
}
// 释放锁
unlock() {
if (this.waiting.length > 0) {
// 唤醒队列中的下一个
const next = this.waiting.shift();
next();
} else {
// 没有人等待,彻底释放锁
this.locked = false;
}
}
// 在锁保护下运行任务(便捷方法)
async run(task) {
await this.lock();
try {
return await task();
} finally {
this.unlock();
}
}
}
使用
const mutex = new Mutex();
async function withdraw(amount) {
console.log('start', amount);
await new Promise(r => setTimeout(r, 300));
console.log('done', amount);
}
mutex.run(() => withdraw(80));
mutex.run(() => withdraw(50));
mutex.run(() => withdraw(30));
六. 总结
前端静态问题本质上是"多个异步任务竞争同一份状态",最终 UI 或数据结果取决于不可控的返回顺序,典型场景包括搜索联想,接口重复触发,Tab频繁切换,自动保存等,解决静态的核心只有一句话: 只让当前有效请求的结果生效,工程上常用手段主要有三类:
1. 取消就请求,从源头减少无效结果
2. 请求序列号/状态校验只认最后一次请求的结果,防止旧数据覆盖
3. 队列, 互斥锁把并发改成串行,适合下单,支付,表单提交等必须强顺序的业务
防抖 / 节流能控制频率,但它们解决的是 "触发次数", 并不能保证"结果正确",所以不能当做静态的核心解法