前端竞态问题

一. 什么是前端竞态问题?

在前端开发中,我们大量依赖异步操作,但异步并不等于安全,在多个异步请求同时存在时,并且共同影响同一份状态时,就很容易出现竞态问题

竞态是指:

程序的最终结果依赖于异步操作的完成顺序,而这个顺序是不可控的 ,核心特征只有一句话: "旧请求覆盖了新状态"

二. 竞态的典型场景

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. 队列, 互斥锁把并发改成串行,适合下单,支付,表单提交等必须强顺序的业务

防抖 / 节流能控制频率,但它们解决的是 "触发次数", 并不能保证"结果正确",所以不能当做静态的核心解法

相关推荐
G_GreenHand6 小时前
vue自定义日历
前端·javascript·vue.js
冴羽6 小时前
前端性能革命:200 行 JavaScript 代码实现 Streaming JSON
前端·javascript·react.js
inksci7 小时前
上传文件可以用飞帆的组件
前端·javascript
DIKKOO7 小时前
React 19 修复了一个遗留多年的类型乌龙,过程竞如此曲折
前端·react.js
程序员爱钓鱼7 小时前
Node.js 编程实战:Node.js + React Vue Angular 前后端协作实践
前端·后端·node.js
程序员爱钓鱼7 小时前
Node.js 编程实战:前后端结合的 SSR 服务端渲染
前端·后端·node.js
haokan_Jia7 小时前
Java 并发编程-ScheduledFuture
java·前端·python
bst@微胖子7 小时前
CrewAI+FastAPI实现多Agent协作项目
java·前端·fastapi
掘金酱7 小时前
TRAE 2025 年度报告分享活动|获奖名单公示🎊
前端·人工智能·后端
jqq6668 小时前
解析ElementPlus打包源码(三、打包类型)
前端·javascript·vue.js