Skip to content

D13 · 竞态条件与解决方案

对应主课: L23 商品列表(竞态处理) 最后核对: 2026-04-01


1. 什么是竞态条件

当多个异步操作的完成顺序不确定时,后发先至的请求可能用旧数据覆盖新数据。


2. 解决方案 1:请求 ID

typescript
let requestId = 0

async function search(query: string) {
  const currentId = ++requestId  // 每次搜索递增

  loading.value = true
  const result = await api.search(query)

  // 只有最新的请求才更新数据
  if (currentId === requestId) {
    data.value = result    // ✅ 最新请求
    loading.value = false
  }
  // else: 旧请求,静默丢弃
}

优点: 简单直接。缺点: 旧请求仍在进行(浪费带宽)。


3. 解决方案 2:AbortController

typescript
let abortController: AbortController | null = null

async function search(query: string) {
  // 取消上一次请求
  abortController?.abort()
  abortController = new AbortController()

  try {
    const result = await fetch(`/api/search?q=${query}`, {
      signal: abortController.signal,
    })
    data.value = await result.json()
  } catch (err) {
    if (err.name === 'AbortError') {
      // 被取消的请求,忽略
      return
    }
    throw err
  }
}

优点: 真正取消了旧请求,节省了带宽和服务端资源。

Axios 版本

typescript
let cancelToken: AbortController | null = null

async function search(query: string) {
  cancelToken?.abort()
  cancelToken = new AbortController()

  const { data } = await axios.get('/api/search', {
    params: { q: query },
    signal: cancelToken.signal,
  })
  results.value = data
}

4. 解决方案 3:防抖

从源头减少请求次数,常与上述方案配合使用:

typescript
import { ref, watch } from 'vue'
import { useDebouncedRef } from '@/composables/useDebouncedRef'

const searchQuery = ref('')
const debouncedQuery = useDebouncedRef(searchQuery, 300)

// 用户输入 "手机壳" 过程:
// "手" → 等 300ms
// "手机" → 重置计时器,等 300ms
// "手机壳" → 重置计时器,等 300ms → 只发 1 次请求
watch(debouncedQuery, (q) => {
  search(q)
})

5. 解决方案 4:useRequest composable

把竞态处理封装成可复用的 composable:

typescript
function useRequest<T>(fetcher: (...args: any[]) => Promise<T>) {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)
  let currentId = 0

  async function execute(...args: any[]) {
    const id = ++currentId
    loading.value = true
    error.value = null

    try {
      const result = await fetcher(...args)
      if (id === currentId) {
        data.value = result
      }
    } catch (err) {
      if (id === currentId) {
        error.value = (err as Error).message
      }
    } finally {
      if (id === currentId) {
        loading.value = false
      }
    }
  }

  return { data, loading, error, execute }
}

6. 常见竞态场景

场景表现推荐方案
搜索自动补全旧结果覆盖新结果防抖 + AbortController
分页快速翻页显示错误页码的数据请求 ID
Tab 切换加载切换后显示上一个 Tab 的数据AbortController + onUnmounted
表单连续提交重复创建记录禁用按钮 + 请求锁

7. 组件卸载时的竞态

typescript
import { onUnmounted } from 'vue'

function useAutoCancel<T>(fetcher: () => Promise<T>) {
  const controller = new AbortController()

  // 组件卸载时自动取消
  onUnmounted(() => {
    controller.abort()
  })

  return fetcher()
}

8. 总结

  • 竞态条件在异步 UI 中很常见,必须主动处理
  • 请求 ID 是最简单的方案,适合大多数场景
  • AbortController 可以真正取消请求,节省资源
  • 防抖从源头减少请求,常与其他方案配合
  • 组件卸载时应取消未完成的请求,避免更新已销毁组件