如何实现 Promise 的取消?
Promise 一旦创建就无法从外部中断它的执行——这是面试中频繁出现的考点,也是实际开发中经常遇到的痛点。下面直接给出答案,再逐步分析每种方案的原理和取舍。
核心答案
Promise 本身不支持取消。状态一旦从 pending 变为 fulfilled 或 rejected 就不可逆,这是规范设计决定的。但我们可以通过以下方式间接实现取消效果:
| 方案 | 原理 | 是否真正取消 | 适用场景 |
|---|---|---|---|
| AbortController | 浏览器标准 API,通过 signal 通知异步操作中止 | 是(对支持的 API) | fetch、Node.js 流操作等 |
| 包装函数 | 用标志位忽略 resolve/reject 的结果 | 否,仅忽略结果 | 简单场景、旧代码兼容 |
| CancellationToken | 手动传递令牌,在关键节点检查 | 半取消(需主动配合) | 复杂业务逻辑、多步骤任务 |
| Promise.race | 用超时 Promise 竞争 | 否,仅忽略结果 | 超时控制 |
面试追问答法:为什么说包装函数不是真正取消?——因为原始 Promise 内部的异步操作仍在执行,只是我们不再处理它的结果。真正的取消需要异步操作本身支持中止,比如 fetch 接收到 abort 信号后会终止 TCP 连接。
为什么 Promise 规范不内置取消?
ES6 Promise 遵循 Promises/A+ 规范,核心设计原则是不可变性:状态一旦确定就不再改变。这个设计换来了两个关键保证:
- 可靠性:then 注册的回调一定会在状态确定后执行,不存在"取消导致回调不执行"的歧义
- 可组合性:Promise 链可以自由组合,不必担心中间环节被意外取消
取消操作引入的副作用(资源未释放、回调丢失、竞态条件)远大于收益,所以规范层面选择了不支持。Domenic Denicola 曾在 TC39 提案中解释过这个设计决策:取消是操作的属性,不是值的属性,而 Promise 代表的是值。
AbortController:标准方案详解
AbortController 是 Web API(不是 ECMAScript 规范),但已成为事实上的取消标准。Node.js 从 v15 起完整支持。
基本用法
javascriptconst controller = new AbortController(); const signal = controller.signal; fetch('/api/data', { signal }) .then(res => res.json()) .then(data => console.log(data)) .catch(err => { if (err.name === 'AbortError') { console.log('请求已取消'); } }); // 取消 controller.abort();
调用 abort() 后,signal 上的 aborted 属性变为 true,同时触发 abort 事件。fetch 内部监听了这个信号,会主动断开请求。
封装超时请求
javascriptfunction fetchWithTimeout(url, options = {}, timeout = 5000) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }) .then(res => { clearTimeout(id); return res.json(); }) .catch(err => { clearTimeout(id); if (err.name === 'AbortError') { throw new Error(`请求超时(${timeout}ms)`); } throw err; }); }
AbortSignal.timeout()——更简洁的超时方案
现代浏览器和 Node.js 18+ 支持 AbortSignal.timeout(),无需手动管理 setTimeout:
javascript// 5 秒超时自动取消 fetch('/api/data', { signal: AbortSignal.timeout(5000) }) .then(res => res.json()) .catch(err => { if (err.name === 'AbortError') { console.log('超时或手动取消'); } });
给自定义异步函数添加取消支持
关键是监听 signal 的 abort 事件并在回调中执行清理:
javascriptasync function delay(ms, { signal } = {}) { return new Promise((resolve, reject) => { if (signal?.aborted) { return reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); } const timer = setTimeout(resolve, ms); signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); }, { once: true }); }); }
注意:必须用
{ once: true }防止重复触发,且要在 Promise resolve 后清除定时器避免资源泄漏。
包装函数方案
不依赖任何 API,兼容性最好,但只能"忽略结果",不能"停止执行":
javascriptfunction makeCancellable(promise) { let cancelled = false; const wrapped = new Promise((resolve, reject) => { promise.then( val => cancelled || resolve(val), err => cancelled || reject(err) ); }); return { promise: wrapped, cancel() { cancelled = true; } }; } // 使用 const { promise, cancel } = makeCancellable( fetch('/api/data').then(r => r.json()) ); promise.then(data => console.log(data)); cancel(); // 后续 then 不会执行,但 fetch 请求仍在进行
面试追问:这种方案有什么隐患?——即使调用了 cancel,原始请求仍在运行,如果它最终 resolve,回调虽不执行,但占用的网络和内存资源不会释放。对于大量并发请求的场景,这会造成资源浪费。
CancellationToken 模式
在多步骤任务中,需要在每个关键节点主动检查取消状态:
javascriptclass CancellationToken { #cancelled = false; #reason = null; get isCancelled() { return this.#cancelled; } get reason() { return this.#reason; } cancel(reason) { this.#cancelled = true; this.#reason = reason ?? 'Operation cancelled'; } throwIfCancelled() { if (this.#cancelled) throw new Error(this.#reason); } } // 多步骤任务中使用 async function processOrder(orderId, token) { token.throwIfCancelled(); const order = await fetchOrder(orderId); token.throwIfCancelled(); const payment = await processPayment(order); token.throwIfCancelled(); await confirmOrder(order, payment); }
这种模式需要开发者在代码中主动插入检查点,适合步骤清晰的长任务。缺点是如果某个步骤的 Promise 已提交但还没到下一个检查点,中间这段时间无法响应取消。
Promise.race 实现超时
javascriptfunction withTimeout(promise, ms) { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms) ); return Promise.race([promise, timeout]); }
这种方式简洁但有两个问题:一是超时后原始 Promise 仍在执行;二是如果原始 Promise 先 reject,超时定时器不会清理,造成轻微的内存泄漏。实际生产中优先用 AbortController。
实战:搜索框防抖取消
这是最常见的业务场景——用户快速输入时,只保留最后一次请求:
javascriptfunction createSearchService() { let controller = null; return async function search(query) { // 取消上一次请求 controller?.abort(); controller = new AbortController(); try { const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal }); return await res.json(); } catch (err) { if (err.name === 'AbortError') return null; // 被取消,静默处理 throw err; } }; }
实战:组件卸载时取消请求
以 React 为例,useEffect 返回的清理函数中取消请求:
javascriptuseEffect(() => { const controller = new AbortController(); fetch('/api/data', { signal: controller.signal }) .then(res => res.json()) .then(data => setData(data)) .catch(err => { if (err.name !== 'AbortError') console.error(err); }); return () => controller.abort(); }, []);
面试追问与边界问题
Q:Promise.all 中某一个被取消,其他的会怎样? 不会怎样。Promise.all 只关心结果,取消是通过外部机制(如 AbortController)实现的。如果想让所有请求共享同一个取消信号,传同一个 signal 即可。
Q:async/await 中如何取消? await 只是语法糖,取消方式完全一样——传 signal 给底层 API,用 try/catch 捕获 AbortError。
Q:取消后的 Promise 内存怎么回收?
取消本身不会自动回收。需要确保:清理定时器、移除事件监听、断开网络连接。AbortController 的 signal 用 { once: true } 绑定监听器,触发后自动移除,这是最佳实践。