5月27日 23:35

如何实现 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 起完整支持。

基本用法

javascript
const 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 内部监听了这个信号,会主动断开请求。

封装超时请求

javascript
function 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 事件并在回调中执行清理:

javascript
async 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,兼容性最好,但只能"忽略结果",不能"停止执行":

javascript
function 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 模式

在多步骤任务中,需要在每个关键节点主动检查取消状态:

javascript
class 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 实现超时

javascript
function 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。

实战:搜索框防抖取消

这是最常见的业务场景——用户快速输入时,只保留最后一次请求:

javascript
function 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 返回的清理函数中取消请求:

javascript
useEffect(() => { 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 } 绑定监听器,触发后自动移除,这是最佳实践。

标签:Promise