前端请求取消与调度完全指南:从 AbortController 到企业级优先级架构

你不是在写一个 cancel(),而是在设计一套异步竞态控制系统。

!在这里插入图片描述(i-blog.csdnimg.cn/direct/0a1e...

一、为什么需要请求取消?------ 资源、竞态与用户体验的三重拷问

很多开发者只看到了"取消请求能节省带宽"这一个点。实际上,请求取消背后有三个核心价值:

1. 资源节约

  • 网络层:浏览器对同一域名的并发连接数有限(HTTP/1.1 通常 6~8 个)。pending 请求占用了连接池,会阻塞后续更重要的请求。
  • 服务端 :被取消的请求,服务端可能还在计算。虽然无法终止服务端处理,但可以通过 AbortSignal 传递信号,让服务端提前中断(需后端配合)。
  • 客户端内存:未处理的 Promise 及其闭包可能导致内存无法释放。

2. 竞态条件(Race Condition)

这是最隐蔽的坑。比如:用户先搜索"a",再搜索"ab"。如果请求"a"比"ab"返回得晚,那么先显示的"ab"结果会被"a"的结果覆盖。取消旧请求是解决竞态的最直接手段

3. 用户体验

  • 组件卸载后的 setState 警告,虽然 React 18+ 不会崩溃,但依然污染控制台。
  • 用户点击"停止加载"按钮、切换路由时,应立刻停止无意义的网络传输。

所以,请求取消不是一个锦上添花的特性,而是现代前端应用的刚需。


二、基础篇:从 CancelToken 到 AbortController ------ 标准演进与避坑指南

2.1 为什么 CancelToken 被废弃?

Axios 0.22.0 起标记 CancelToken 为废弃,原因:

  • 非标准 :它是 Axios 自创的 API,而 AbortController 是 Web 标准,同时支持 fetchXMLHttpRequest
  • 内存/性能问题CancelToken 内部通过 Promise 回调传递 cancel 函数,会产生额外的闭包和对象。
  • 无法跨库共享:如果你同时使用 axios 和原生 fetch,需要两套取消机制。

2.2 AbortController 核心用法

ts 复制代码
const controller = new AbortController();
const signal = controller.signal;

// 用于 fetch
fetch(url, { signal });

// 用于 axios (v0.22.0+)
axios.get(url, { signal });

// 取消
controller.abort();

注意abort() 后,对应的 Promise 会 reject 一个 AbortError(fetch)或 CanceledError(axios)。需要捕获并区分:

ts 复制代码
try {
  await axios.get(url, { signal });
} catch (err) {
  if (axios.isCancel(err)) {
    console.log('用户主动取消');
  } else {
    // 真实错误
  }
}

2.3 常见误区

误区 正确理解
同一个 controller 可以复用给多个请求,取消时只取消最后一个 错误 :调用一次 abort() 会取消所有绑定了该 signal 的请求。如需单独控制,每个请求独立 controller。
取消后还能再使用同一个 controller 发新请求 错误abort() 后 signal 状态变为 aborted,无法恢复。必须 new AbortController()
取消一定能让 .then 不执行 错误:如果请求已经完成并进入微任务队列,取消指令无法撤回已发出的响应。必须结合业务标志位过滤。

三、进阶篇:竞态条件的本质与终极解决方案

3.1 问题复现:取消不等于忽略

ts 复制代码
let controller: AbortController | null = null;

async function fetchUser(id: string) {
  controller?.abort();
  controller = new AbortController();
  
  const res = await axios.get(`/api/user/${id}`, { signal: controller.signal });
  // 危险:如果请求已经返回但尚未进入微任务时,用户切换了组件,
  // 这里的 setState 依然会执行
  setUser(res.data);
}

时序图

css 复制代码
用户点击 A → 请求 A 发出 → 服务端返回 A → 用户点击 B(此时 A 已收到响应,但 Promise 还在队列中)
→ 请求 B 发出 → 执行 A 的 .then()(覆盖数据)→ 执行 B 的 .then()

3.2 解决方案:请求唯一 ID + 组件存活标志

方案一:时间戳校验

ts 复制代码
let lastRequestId = 0;

async function fetchData(query: string) {
  const currentId = ++lastRequestId;
  const res = await axios.get(`/api/search?q=${query}`);
  if (currentId === lastRequestId) {
    // 只有最新请求的结果才被使用
    setData(res.data);
  }
}

方案二:React Hook 综合防护

tsx 复制代码
function useCancellableRequest<T>(requestFn: (signal: AbortSignal) => Promise<T>) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const abortRef = useRef<AbortController | null>(null);
  const isMountedRef = useRef(true);

  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
      abortRef.current?.abort();
    };
  }, []);

  const execute = useCallback(async () => {
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;
    setLoading(true);
    try {
      const result = await requestFn(controller.signal);
      if (isMountedRef.current) {
        setData(result);
      }
    } catch (err) {
      if (isMountedRef.current && !axios.isCancel(err)) {
        console.error(err);
      }
    } finally {
      if (isMountedRef.current) setLoading(false);
    }
  }, [requestFn]);

  return { data, loading, execute };
}

3.3 理论纵深:为什么需要两层防护?

  • AbortController:负责网络层的真正中断。
  • 业务标志位(isMountedRef / 时间戳):负责防止"已取消但响应已到达"的残留微任务执行。
  • 二者结合:才是完备的竞态控制。

四、架构篇:全局请求管理 ------ Map、拦截器与路由策略

当应用规模变大,每个组件自己管理 controller 会变得混乱。我们需要一个全局的请求注册中心

4.1 设计目标

  • 能够取消所有 pending 请求(路由切换时)。
  • 能够按条件取消(例如只取消低优先级请求)。
  • 能够防重复提交(相同请求在 pending 期间直接复用或拒绝)。
  • 与业务代码解耦(通过拦截器自动注入)。

4.2 核心数据结构:PendingMap

ts 复制代码
type CancelFunc = () => void;
type PendingMap = Map<string, AbortController>;

class GlobalRequestManager {
  private pendingMap: PendingMap = new Map();

  // 生成唯一键:method + url + params + data 的序列化
  private generateKey(config: AxiosRequestConfig): string {
    const { method, url, params, data } = config;
    return `${method}:${url}:${JSON.stringify(params)}:${JSON.stringify(data)}`;
  }

  // 添加请求:如果已存在且策略为 cancelOld,则取消旧请求
  addRequest(config: AxiosRequestConfig, strategy: 'cancelOld' | 'rejectNew' = 'cancelOld'): AbortSignal {
    const key = this.generateKey(config);
    const existingController = this.pendingMap.get(key);
    
    if (existingController) {
      if (strategy === 'cancelOld') {
        existingController.abort();
        this.pendingMap.delete(key);
      } else {
        // 抛出特殊错误,由拦截器转换为拒绝
        throw new Error('DUPLICATE_REQUEST');
      }
    }
    
    const controller = new AbortController();
    this.pendingMap.set(key, controller);
    return controller.signal;
  }

  removeRequest(config: AxiosRequestConfig) {
    const key = this.generateKey(config);
    this.pendingMap.delete(key);
  }

  cancelAll() {
    this.pendingMap.forEach(controller => controller.abort());
    this.pendingMap.clear();
  }

  cancelByWhiteList(whiteListUrls: string[]) {
    for (const [key, controller] of this.pendingMap.entries()) {
      const shouldKeep = whiteListUrls.some(url => key.includes(url));
      if (!shouldKeep) {
        controller.abort();
        this.pendingMap.delete(key);
      }
    }
  }
}

4.3 集成 Axios 拦截器

ts 复制代码
const requestManager = new GlobalRequestManager();

axios.interceptors.request.use(config => {
  // 白名单跳过重复检查
  if (config.skipDuplicateCheck) return config;
  
  try {
    const signal = requestManager.addRequest(config, config.duplicateStrategy || 'cancelOld');
    config.signal = signal;
  } catch (e) {
    // 重复请求被拒绝,直接取消本次请求
    return Promise.reject({ code: 'ERR_DUPLICATE', message: '请求过于频繁' });
  }
  return config;
});

axios.interceptors.response.use(
  response => {
    requestManager.removeRequest(response.config);
    return response;
  },
  error => {
    if (error.config) requestManager.removeRequest(error.config);
    return Promise.reject(error);
  }
);

4.4 路由守卫批量取消

ts 复制代码
// Vue Router 示例
router.beforeEach((to, from, next) => {
  // 保留埋点、上传等白名单请求
  requestManager.cancelByWhiteList(['/api/report', '/api/upload']);
  next();
});

五、高级篇:请求优先级调度 ------ 非抢占式、抢占式与老化机制

很多大厂内部请求库都实现了优先级调度。这里给出生产级的实现思路,包含两种调度模型的对比。

5.1 优先级定义

ts 复制代码
enum Priority {
  CRITICAL = 0,   // 支付、下单
  HIGH = 1,       // 页面骨架、用户信息
  NORMAL = 2,     // 列表数据
  LOW = 3,        // 评论、推荐
  BACKGROUND = 4  // 埋点、预加载
}

5.2 非抢占式优先级调度(Non-Preemptive)

特点 :一旦请求开始执行,即使后来有更高优先级的请求,也要等当前请求完成才能切换。

适用 :请求时长较短(<500ms),不希望频繁中断的场景。

实现:维护多个 FIFO 队列,调度器每次从最高非空队列取任务。

ts 复制代码
class NonPreemptiveScheduler {
  private queues: Map<Priority, Array<RequestTask>> = new Map();
  private running = 0;
  private maxConcurrent = 4;

  enqueue(task: RequestTask) {
    if (!this.queues.has(task.priority)) {
      this.queues.set(task.priority, []);
    }
    this.queues.get(task.priority)!.push(task);
    this.run();
  }

  private run() {
    if (this.running >= this.maxConcurrent) return;
    
    // 从高到低寻找第一个非空队列
    for (let p = Priority.CRITICAL; p <= Priority.BACKGROUND; p++) {
      const queue = this.queues.get(p);
      if (queue && queue.length > 0) {
        const task = queue.shift()!;
        this.running++;
        task.execute().finally(() => {
          this.running--;
          this.run();
        });
        break;
      }
    }
  }
}

5.3 抢占式优先级调度(Preemptive)

特点 :当更高优先级的请求到来时,如果当前正在执行一个较低优先级的请求,则主动取消 后者,让更高优先级立即执行。被取消的请求可以自动重试。

适用 :紧急操作(如支付)需要立刻获得网络资源。

实现 :利用 AbortController 主动中断。

ts 复制代码
class PreemptiveScheduler {
  private currentTask: RequestTask | null = null;
  private highPriorityQueue: RequestTask[] = [];
  private normalQueue: RequestTask[] = [];
  private lowQueue: RequestTask[] = [];
  private maxConcurrent = 1; // 抢占式通常单路执行,也可并发但逻辑更复杂
  
  enqueue(task: RequestTask) {
    // 放入对应队列
    if (task.priority === Priority.CRITICAL || task.priority === Priority.HIGH) {
      this.highPriorityQueue.push(task);
    } else if (task.priority === Priority.NORMAL) {
      this.normalQueue.push(task);
    } else {
      this.lowQueue.push(task);
    }
    
    this.schedule();
  }
  
  private schedule() {
    // 如果有更高优先级的任务且当前正在运行低优先级任务 -> 抢占
    if (this.currentTask && this.hasHigherPriorityWaiting(this.currentTask.priority)) {
      this.currentTask.controller.abort(); // 取消当前任务
      // 将被抢占的任务重新放回相应队列头部(稍后重试)
      this.requeue(this.currentTask);
      this.currentTask = null;
    }
    
    if (!this.currentTask) {
      const nextTask = this.getNextTask();
      if (nextTask) {
        this.currentTask = nextTask;
        this.currentTask.execute().finally(() => {
          this.currentTask = null;
          this.schedule();
        });
      }
    }
  }
  
  private hasHigherPriorityWaiting(currentPriority: Priority): boolean {
    if (currentPriority <= Priority.HIGH) return false;
    return (this.highPriorityQueue.length > 0);
  }
}

5.4 老化机制(Aging):防止低优先级饿死

如果系统持续有高优先级任务,低优先级可能永远无法执行。解决方案:记录任务入队时间,超时后自动提升优先级

ts 复制代码
class RequestTask {
  public enqueueTime = Date.now();
  
  public getEffectivePriority(): Priority {
    if (this.priority === Priority.BACKGROUND && Date.now() - this.enqueueTime > 5000) {
      return Priority.NORMAL; // 5秒后升级
    }
    if (this.priority === Priority.LOW && Date.now() - this.enqueueTime > 3000) {
      return Priority.NORMAL;
    }
    return this.priority;
  }
}

在调度器的 getNextTask 中,根据 getEffectivePriority() 动态排序。


六、终极篇:去重、重试与降级 ------ 生产级请求库的完整拼图

6.1 请求去重(Deduplication)

场景 :多个组件同时请求同一个用户信息接口。

实现:Promise 缓存。

ts 复制代码
const pendingPromises = new Map<string, Promise<any>>();

function dedupe<T>(key: string, requestFn: () => Promise<T>): Promise<T> {
  if (pendingPromises.has(key)) {
    return pendingPromises.get(key)!;
  }
  const promise = requestFn().finally(() => {
    pendingPromises.delete(key);
  });
  pendingPromises.set(key, promise);
  return promise;
}

6.2 自动重试(带退避策略)

ts 复制代码
async function retryOnCancel<T>(
  requestFn: (signal: AbortSignal) => Promise<T>,
  maxRetries = 3,
  baseDelay = 1000
): Promise<T> {
  let lastError: any;
  for (let i = 0; i < maxRetries; i++) {
    const controller = new AbortController();
    try {
      return await requestFn(controller.signal);
    } catch (err) {
      lastError = err;
      if (!axios.isCancel(err)) throw err; // 非取消错误不重试
      if (i === maxRetries - 1) throw err;
      await new Promise(r => setTimeout(r, baseDelay * Math.pow(2, i))); // 指数退避
    }
  }
  throw lastError;
}

6.3 降级策略(Fallback)

当请求被取消时,返回缓存或默认数据,而不是报错。

ts 复制代码
async function withFallback<T>(
  requestFn: () => Promise<T>,
  fallbackData: T
): Promise<T> {
  try {
    return await requestFn();
  } catch (err) {
    if (axios.isCancel(err)) {
      return fallbackData;
    }
    throw err;
  }
}

七、实战:基于 TypeScript 的企业级请求调度器完整实现

由于篇幅,这里给出核心类的骨架和集成方式。完整代码可参考 GitHub Gist 链接(略)。

ts 复制代码
// types.ts
export enum Priority {
  CRITICAL = 0,
  HIGH = 1,
  NORMAL = 2,
  LOW = 3,
  BACKGROUND = 4,
}

export interface RequestConfig extends AxiosRequestConfig {
  priority?: Priority;
  duplicateStrategy?: 'cancelOld' | 'rejectNew';
  skipDuplicateCheck?: boolean;
  enableRetry?: boolean;
  maxRetries?: number;
  fallbackData?: any;
}

// scheduler.ts
export class RequestScheduler {
  private queues: Map<Priority, RequestTask[]> = new Map();
  private runningCount = 0;
  private maxConcurrent = 6;
  private agingEnabled = true;
  
  // ... 实现非抢占式调度 + 老化
}

// axios instance
const scheduler = new RequestScheduler();

const axiosInstance = axios.create({
  timeout: 10000,
  adapter: (config: AxiosRequestConfig & RequestConfig) => {
    return new Promise((resolve, reject) => {
      const task = new RequestTask(config, resolve, reject, scheduler);
      scheduler.enqueue(task);
    });
  }
});

// 使用示例
axiosInstance.get('/api/order', { priority: Priority.CRITICAL, enableRetry: true });
axiosInstance.post('/api/log', data, { priority: Priority.BACKGROUND, fallbackData: { success: true } });

八、总结与面试应答策略

核心观点

请求取消不仅仅是调用 abort(),而是一套异步竞态控制系统。它需要:

  1. 网络层AbortController(替代废弃的 CancelToken)。
  2. 业务层:组件存活标志或时间戳,防止幽灵更新。
  3. 全局层:pending Map + 拦截器,支持路由切换批量取消和重复提交控制。
  4. 调度层:优先级队列 + 老化机制,保证核心请求优先执行。
  5. 韧性层:去重、重试、降级,提升用户体验。

面试应答话术

"如果需要设计一个生产级的请求库,我会从五个维度来构建:

第一 ,取消机制必须使用标准的 AbortController,兼容 fetchaxios,同时通过拦截器自动注入 signal。

第二 ,在 React/Vue 组件中,我会封装一个 useCancellableRequest Hook,利用 isMountedRef 和每次请求的唯一 ID 来解决竞态条件,防止组件卸载后更新状态。

第三 ,对于全局请求管理,我会维护一个 Map,key 由 method、url、params、data 序列化生成。在请求拦截器中添加,在响应拦截器中移除。路由守卫中可以调用 cancelAll() 或根据白名单选择性取消。

第四,优先级调度采用非抢占式队列 + 老化机制,避免低优先级饿死。如果业务需要紧急插队,可以支持抢占式(通过 abort 取消低优先级任务并放回队列)。

第五,补充去重(Promise 缓存)、自动重试(指数退避)和降级(fallback 数据),让请求库具备韧性。

这样设计的请求库,不仅能解决取消需求,还能提升整体应用的响应速度和稳定性。"


相关推荐
颂love1 小时前
Vue的两大生态以及组件通信
前端·javascript·vue.js·typescript
甜汤圆1 小时前
Python 里**自定义数据单元**
前端
小bo波1 小时前
用匿名内部类优雅地计算方法执行时间
java·设计模式·性能测试·模板方法模式·lambda·代码优化·匿名内部类
cidy_981 小时前
将 Figma 接入 Codex MCP:从 `/plugins` 到本地插件配置的完整教程
前端
vivo互联网技术1 小时前
动效开发不踩坑:几种动效实现方案对比与实战选型
前端·性能优化·动效
Csvn1 小时前
【Vue3】Composition API vs Options API —— 什么场景该选哪个
前端
Csvn1 小时前
Vue3 迁移血泪史:v-model 的 .sync 陷阱,90% 升级项目都会踩
前端·vue.js
光影少年1 小时前
js单线程,为什在node环境下的js可以处理高并发请求?
前端·javascript·掘金·金石计划
vim怎么退出2 小时前
Dive into React——事件系统
前端·react.js·源码阅读