Skip to content

请求竞态处理,一个容易被忽略的前端问题

提示本文发布于 4 个月前,其中信息可能已经时过境迁...

"先发的请求后返回,后发的请求先返回"——这个场景在实际开发中非常常见,尤其是在搜索框自动补全、Tab 页快速切换这些场景里。

比如用户在搜索框里快速输入"vue"→"vue3"→"vue3 教程",如果每次输入都发请求,第一个请求可能因为某些原因比第三个还慢,结果用户明明在等"vue3 教程"的结果,界面却被"vue"的结果覆盖了。

这就是请求竞态。

解决方案

我的做法是封装一个竞态处理函数:

typescript
function createCancelTask<T extends any[], R>(
  asyncTask: (...args: T) => Promise<R>
): (...args: T) => Promise<R> {
  let cancel: () => void = () => {};
  return (...args: T): Promise<R> => {
    return new Promise<R>((resolve, reject) => {
      // 取消上一个未完成的请求
      cancel();
      // 设置新的取消函数
      cancel = () => {
        resolve = reject = () => {};
      };
      // 执行新请求
      asyncTask(...args).then(
        (res: R) => resolve(res),
        (err: any) => reject(err)
      );
    });
  };
}

核心思路很简单:每次发起新请求时,先"取消"上一个还没完成的请求。这里的取消不是真的中断网络请求,而是忽略它的回调——把 resolve 和 reject 变成空函数,这样旧请求回来时就不会更新 UI 了。

使用场景

搜索框

typescript
const searchAPI = (keyword: string) => fetch(`/api/search?q=${keyword}`)
const cancelableSearch = createCancelTask(searchAPI)

const handleSearch = () => {
  cancelableSearch(input.value)
    .then(data => results.value = data)
}

Tab 切换

typescript
const loadTabData = (tabId: string) => fetch(`/api/tabs/${tabId}/data`)
const cancelableLoadTab = createCancelTask(loadTabData)

const switchTab = (tabId: string) => {
  cancelableLoadTab(tabId)
    .then(data => tabData.value = data)
}

注意事项

这个方案只是忽略了旧请求的回调,实际网络请求还是会完成。所以它不适合需要节省带宽的场景,但解决 UI 状态不一致的问题已经足够了。

对于真正需要取消网络请求的场景,可以用 AbortController。不过那又是另一套方案了。