前端竞态问题

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

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

竞态是指:

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

二. 竞态的典型场景

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

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

相关推荐
恋猫de小郭1 天前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅1 天前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60611 天前
完成前端时间处理的另一块版图
前端·github·web components
掘了1 天前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅1 天前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅1 天前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 天前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment1 天前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅1 天前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊1 天前
jwt介绍
前端