切了三次筛选,表格显示上一轮数据:Vue3 请求竞态与取消

慢网络下快速切筛选,后发先至的请求覆盖了新数据。AbortController 取消旧请求,keep-alive、分页追加与幂等键三个漏点要一起补。

客服反馈说列表页「数据会跳」:选了 A 店铺,过一会儿表格里变成 B 店铺的数据;再点一次又对了。前端同事本地测不出来,因为本地接口 20ms 就返回,线上慢的时候要 800ms。

复现路径很简单:在筛选栏快速切换三次(A → B → C),最后停留在 C,但表格里显示的是 B 的数据。慢网络下三次请求的返回顺序和发出顺序不一致,先发的后到,后覆盖了先写的 state。

这就是典型的请求竞态。它不挑框架,只要「发起请求」和「写入状态」之间有异步间隙,就会出现。Vue3 里因为 ref 到处都是、watch 又容易触发重复请求,出现的频率比想象中高。

一、竞态的时间线

先把问题画清楚。假设用户依次点了 A、B、C,接口分别耗时 600ms、200ms、900ms:

时刻 用户操作 网络事件 表格显示
0ms 切到 A 发出 reqA 加载中
100ms 切到 B 发出 reqB 加载中
300ms 切到 C 发出 reqC 加载中
300ms — reqB 返回 B 的数据
700ms — reqA 返回 A 的数据(错)
1200ms — reqC 返回 C 的数据

最终停留在 C 是对的,但中间 700ms 到 1200ms 之间用户看到了 A 的数据,而且如果 C 的请求刚好失败,页面就会永久停在 A 上。客服截图里的「数据会跳」,就是这个中间态。

更糟的情况是请求带分页:第 2 页的数据覆盖了第 1 页,翻页后列表直接错乱。

二、三种「看起来能解决」的写法

我见过三种修补方式,都能缓解,但都不彻底。

第一种是加 loading 判断:请求返回时如果还在 loading 就丢弃。问题是 loading 是布尔值,无法区分是哪一次请求,A 返回时也还在 loading,照样写入。

第二种是打时间戳:每次请求记一个 reqId,返回时比大小,小于当前 reqId 就丢弃。这个思路是对的,但容易写错——reqId 存在组件实例上,如果组件被 keep-alive 缓存,切回来时旧 reqId 还在,新请求的 id 反而可能更小。

第三种是在 then 里比对查询条件:if (currentFilter !== params.filter) return。这个在单条件筛选下没问题,但筛选条件一多,比对就变成深比较,而且用户「切走再切回来」时会误判成同一请求。

// 反面示例:看着能防,其实防不住
async function load() {
  loading.value = true
  const res = await fetchList(query.value)
  if (!loading.value) return   // 只要还有一个请求在飞,这里永远是 true
  list.value = res.data
  loading.value = false
}

三、正确做法:真的把旧请求取消掉

AbortController 是浏览器原生能力,fetch 直接支持,axios 从 0.22 起也支持传 signal。核心思路是:发新请求前,先取消上一个。

// composables/useLatestRequest.js
import { ref, onScopeDispose } from 'vue'

export function useLatestRequest() {
  let controller = null

  async function run(fn) {
    // 取消上一次还没结束的请求
    if (controller) controller.abort()
    controller = new AbortController()
    const signal = controller.signal
    try {
      return await fn(signal)
    } catch (e) {
      // 主动取消不是错误,不要弹提示
      if (e.name === 'CanceledError' || e.name === 'AbortError' || signal.aborted) {
        return null
      }
      throw e
    }
  }

  // 组件卸载时顺手取消,避免内存里挂着一个永不 resolve 的 promise
  onScopeDispose(() => controller && controller.abort())

  return { run }
}

调用侧就很干净了:

import axios from 'axios'
import { ref, watch } from 'vue'
import { useLatestRequest } from '@/composables/useLatestRequest'

const list = ref([])
const loading = ref(false)
const { run } = useLatestRequest()

async function fetchList(params, signal) {
  const { data } = await axios.get('/api/orders', { params, signal })
  return data
}

watch(query, async (q) => {
  loading.value = true
  const data = await run((signal) => fetchList(q, signal))
  // run 返回 null 表示这次请求被更新的请求取代了,直接丢弃
  if (data) list.value = data.items
  loading.value = false
}, { deep: true })

这里有个细节容易漏:axios 的取消错误在 1.x 里是 CanceledError,在 fetch 里是 AbortError,两者的 name 不同。如果只判断一种,另一种会走到全局错误处理,用户会看到莫名其妙的「请求失败」弹窗。

四、取消之后还有三个漏点

第一个漏点是缓存。如果用了 keep-alive + onActivated 重新拉数据,controller 是在 setup 作用域里的,切回来时可能已经被 onScopeDispose 清掉了。onScopeDispose 在 keep-alive 下不会触发,这点是安全的,但如果你在 onDeactivated 里手动调用过 abort,就要记得重建。

第二个漏点是相同请求的并发去重。快速点两次同一个筛选条件,会发两个一模一样的请求,取消前者再发后者,等于白跑一趟。可以在 run 里加一层 in-flight 缓存:

const inflight = new Map()

function dedupe(key, fn) {
  if (inflight.has(key)) return inflight.get(key)
  const p = fn().finally(() => inflight.delete(key))
  inflight.set(key, p)
  return p
}

第三个漏点是分页与滚动加载。取消策略对「读列表」有效,但对「追加下一页」是错的——取消上一页请求会把已经拿到的数据丢掉。这类场景应该用「请求序号 + 追加去重」,而不是取消。

let seq = 0
async function appendPage(page) {
  const mySeq = ++seq
  const res = await fetchList({ page })
  if (mySeq !== seq) return          // 已被更新的请求取代
  const ids = new Set(list.value.map((x) => x.id))
  list.value.push(...res.items.filter((x) => !ids.has(x.id)))
}

五、不要只在前端兜:接口也该是幂等的

前端取消请求,只是让浏览器不再处理响应,请求本身可能已经到达服务端并产生了副作用。列表查询无所谓,但「提交订单」「发起支付」这类写接口,取消不代表没执行。

所以写接口必须配幂等键:前端在用户点击时生成一个 requestId,服务端按它去重。这样即使前端因为取消而重发,服务端也只会处理一次。

const requestId = crypto.randomUUID()
await axios.post('/api/orders', payload, {
  headers: { 'X-Request-Id': requestId },
})

服务端拿 X-Request-Id 做 5 分钟的 Redis 去重,命中直接返回首次结果。这个和前端取消是两件独立的事,缺一个都会出问题。

六、怎么验证真的修好了

本地 20ms 的接口永远测不出竞态,必须人为把网络调慢。

在 Chrome DevTools 的 Network 面板选 Slow 3G,或者用 Custom 把单个接口的延迟设成 1500ms,然后快速切换三次筛选。看两件事:Network 面板里前两个请求的状态是不是 (canceled);表格最终数据是不是最后一次选择对应的结果。

更稳的做法是写一个 E2E 用例,用 Playwright 拦截路由并给不同请求设不同延迟,断言最终渲染:

await page.route('**/api/orders*', async (route) => {
  const url = new URL(route.request().url())
  const shop = url.searchParams.get('shopId')
  const delay = { A: 900, B: 200, C: 500 }[shop] || 0
  await new Promise((r) => setTimeout(r, delay))
  await route.fulfill({ json: { items: [{ shopId: shop }] } })
})

这个用例在改造前必然失败,改造后稳定通过。把它放进 CI,以后谁再加一个没带 signal 的请求,也能在合并前发现——虽然这条断言只能覆盖到被改的那个页面,但比没有强。

小结

  • 请求竞态的本质是「后发先至」,只靠 loading 或条件比对防不住,必须真正取消旧请求。
  • AbortController 是标准解法,fetch 与 axios 都支持,取消前要判断 signal.aborted 避免误报错。
  • AbortError 与 CanceledError 名字不同,只判断一种会把取消当成失败弹窗。
  • 相同请求要去重,分页追加不能取消上一页,两类场景的策略不一样。
  • 前端取消不阻止服务端执行,写接口必须配 X-Request-Id 幂等键。
  • 验证要靠慢网络或路由拦截的 E2E 用例,本地快接口永远测不出这个问题。
查看「前端与性能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。