你不是在写一个 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 标准,同时支持fetch和XMLHttpRequest。 - 内存/性能问题 :
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(),而是一套异步竞态控制系统。它需要:
- 网络层 :
AbortController(替代废弃的 CancelToken)。 - 业务层:组件存活标志或时间戳,防止幽灵更新。
- 全局层:pending Map + 拦截器,支持路由切换批量取消和重复提交控制。
- 调度层:优先级队列 + 老化机制,保证核心请求优先执行。
- 韧性层:去重、重试、降级,提升用户体验。
面试应答话术
"如果需要设计一个生产级的请求库,我会从五个维度来构建:
第一 ,取消机制必须使用标准的
AbortController,兼容fetch和axios,同时通过拦截器自动注入 signal。第二 ,在 React/Vue 组件中,我会封装一个
useCancellableRequestHook,利用isMountedRef和每次请求的唯一 ID 来解决竞态条件,防止组件卸载后更新状态。第三 ,对于全局请求管理,我会维护一个
Map,key 由 method、url、params、data 序列化生成。在请求拦截器中添加,在响应拦截器中移除。路由守卫中可以调用cancelAll()或根据白名单选择性取消。第四,优先级调度采用非抢占式队列 + 老化机制,避免低优先级饿死。如果业务需要紧急插队,可以支持抢占式(通过 abort 取消低优先级任务并放回队列)。
第五,补充去重(Promise 缓存)、自动重试(指数退避)和降级(fallback 数据),让请求库具备韧性。
这样设计的请求库,不仅能解决取消需求,还能提升整体应用的响应速度和稳定性。"