实现可中止API时,如何优雅管理AbortSignal事件监听器?
带中止功能的超时Promise实现优化
先看这个支持中止功能的超时Promise初始实现:
/** * * @param {number} delay * @param {AbortSignal} [abortSignal] * @returns {Promise<void>} */ export default function timeoutPromise(delay, abortSignal) { return new Promise((resolve, reject) => { if(abortSignal) { abortSignal.throwIfAborted(); } const timeout = setTimeout(() => { resolve(); }, delay); abortSignal.addEventListener("abort", () => { clearTimeout(timeout); reject(new Error("Aborted")); }); }); }
这个实现有明显问题:当超时正常完成后,绑定在abortSignal上的事件监听器不会被清除,可能导致内存泄漏。
虽然可以手动移除监听器来修复,但代码会变得冗余繁琐:
/** * * @param {number} delay * @param {AbortSignal} [abortSignal] * @returns {Promise<void>} */ export default function timeoutPromise(delay, abortSignal) { return new Promise((resolve, reject) => { // 改为reject以保证信号状态不同时行为一致 if(abortSignal && abortSignal.aborted) { reject(new Error("timeoutPromise aborted")); } let timeout = null; function abortHandler() { clearTimeout(timeout); reject(new Error("timeoutPromise aborted")) } timeout = setTimeout(() => { if(abortSignal) { abortSignal.removeEventListener("abort", abortHandler); } resolve(); }, delay); if(abortSignal) { abortSignal.addEventListener("abort", abortHandler, {once: true}); } }); }
对于这样一个简单的功能,代码量显得过于臃肿。接下来针对你的问题给出解答:
你的实现是否正确?
整体逻辑是正确的:
- 提前检查信号是否已中止,保证调用时信号已中止能立即触发reject
- 使用
{once: true}确保中止事件只触发一次,避免重复处理 - 超时完成后移除监听器,防止内存泄漏
但有个小细节可以优化:初始实现里的abortSignal.throwIfAborted()会抛出原生的DOMException(名称为AbortError),而你后来改成了自定义Error,其实保持和原生API一致的错误类型会更符合规范。
更优的实现方案
方案一:用Promise.race拆分逻辑
利用Promise.race合并超时Promise和中止信号Promise,拆分逻辑后代码更简洁,同时避免手动管理监听器:
/** * @param {number} delay * @param {AbortSignal} [abortSignal] * @returns {Promise<void>} */ export default function timeoutPromise(delay, abortSignal) { // 提前检查是否已中止,避免创建不必要的定时器 if (abortSignal?.aborted) { return Promise.reject(abortSignal.reason ?? new Error("timeoutPromise aborted")); } const timeoutPromise = new Promise(resolve => setTimeout(resolve, delay)); // 没有信号直接返回超时Promise if (!abortSignal) return timeoutPromise; // 创建中止信号的Promise,{once: true}确保监听器触发后自动移除 const abortPromise = new Promise((_, reject) => { abortSignal.addEventListener("abort", () => { reject(abortSignal.reason ?? new Error("timeoutPromise aborted")); }, { once: true }); }); // 用race返回先完成的Promise,无论哪一个完成,另一个的资源都会被自然处理 return Promise.race([timeoutPromise, abortPromise]); }
这个方案的优势:
- 代码结构清晰,拆分了超时和中止两个独立逻辑
- 无需手动移除监听器,
{once: true}会自动清理触发后的监听器;若超时先完成,未触发的监听器会随Promise垃圾回收被清理 - 使用原生
abortSignal.reason,保持和浏览器API一致的错误行为
方案二:利用AbortSignal.any合并信号(现代环境)
如果你的运行环境支持AbortSignal.any(Chrome 116+、Firefox 119+),可以用内部控制器触发超时,同时合并外部信号,实现更简洁:
/** * @param {number} delay * @param {AbortSignal} [abortSignal] * @returns {Promise<void>} */ export default function timeoutPromise(delay, abortSignal) { if (abortSignal?.aborted) { return Promise.reject(abortSignal.reason ?? new Error("timeoutPromise aborted")); } const controller = new AbortController(); // 合并内部超时信号和外部传入的中止信号 const combinedSignal = abortSignal ? AbortSignal.any([controller.signal, abortSignal]) : controller.signal; const timeout = setTimeout(() => controller.abort(), delay); return new Promise((_, reject) => { combinedSignal.addEventListener("abort", () => { clearTimeout(timeout); reject(combinedSignal.reason ?? new Error("timeoutPromise aborted")); }, { once: true }); }); }
这个版本无需手动监听外部信号,AbortSignal.any会自动处理任意信号的中止事件,代码更简洁优雅。
内容的提问来源于stack exchange,提问作者Tomáš Zato
相关产品推荐
相关产品推荐

