各位编程爱好者,大家好!
今天我们将深入探讨一个在现代异步编程中至关重要的主题:如何实现一个带取消功能的延迟任务。在前端开发中,延迟执行任务随处可见,无论是简单的定时器、防抖、节流,还是复杂的动画序列和数据预加载。然而,传统的 setTimeout 配合 Promise 虽然能很好地处理延迟,却常常缺乏一个优雅、统一的取消机制。想象一下,用户在延迟任务完成前就切换了页面,或者发出了新的请求,如果旧的延迟任务不能被取消,它可能会执行不必要的操作,浪费资源,甚至导致意外的副作用。
幸运的是,现代 Web API 提供了 AbortController 和 AbortSignal,它们为异步操作的取消提供了一套标准化的、可组合的解决方案。我们将利用 AbortController 的强大能力,结合 Promise 的优雅,构建一个既灵活又健壮的延迟任务取消方案。
1. 为什么需要可取消的延迟任务?
在深入技术细节之前,我们先来明确一下需求。为什么传统的 setTimeout 结合 Promise 不足以满足我们的需求?
考虑以下场景:
- UI 动画延迟: 你有一个元素在用户点击后延迟 500ms 播放动画。但如果用户在 200ms 后再次点击,你可能希望立即取消前一个动画,并启动新的动画,而不是让两个动画叠加或冲突。
- 输入搜索防抖: 用户在搜索框中输入时,你通常会设置一个防抖,例如在用户停止输入 300ms 后才发起搜索请求。如果用户在 300ms 内又输入了字符,之前的延迟请求就应该被取消,重新计时。
- 数据预加载/懒加载: 在某些情况下,你可能需要延迟加载某些数据或组件,但如果用户行为发生变化(例如滚动到视口外),之前的加载请求可能就不再需要,应该被取消。
- 资源清理: 在 React 的
useEffect或 Vue 的onUnmounted等生命周期钩子中,如果一个组件在异步操作完成前被卸载,我们通常需要清理掉未完成的异步任务,以避免内存泄漏或错误。
传统的 setTimeout 返回一个 ID,你可以通过 clearTimeout(id) 来取消它。这对于简单的延迟任务是可行的。但是,当我们将 setTimeout 封装进 Promise 后,情况就变得复杂了:
function simpleDelay(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
// 如何取消这个 Promise 呢?
const p = simpleDelay(1000);
// p.cancel() ? 不存在这样的方法!
Promise 本身并没有内置的取消机制。一旦一个 Promise 处于 pending 状态,我们无法直接从外部控制它进入 rejected 状态(除非内部逻辑自行调用 reject)。这就是 AbortController 登场的原因。
2. AbortController 与 AbortSignal:异步取消的利器
AbortController 是一个 Web API,它提供了一种标准的方式来中止一个或多个 Web 请求或异步操作。它的核心思想是:
- 创建控制器: 你创建一个
AbortController实例。 - 获取信号: 从控制器实例中获取一个
AbortSignal对象。 - 传递信号: 将这个
AbortSignal对象传递给那些支持取消的异步操作。 - 发出中止信号: 当你想取消操作时,调用控制器实例的
abort()方法。
让我们详细看看它的组成部分。
2.1 AbortController
AbortController 是一个简单的构造函数:
const controller = new AbortController();
它有两个主要属性和方法:
signal(属性): 返回一个AbortSignal对象实例。这是我们传递给异步操作的“信号”。abort()(方法): 调用此方法将中止所有与该控制器关联的操作。当abort()被调用时,signal对象的aborted属性会变为true,并且会触发abort事件。
2.2 AbortSignal
AbortSignal 对象代表一个可以被中止的操作的信号。它有以下关键特性:
aborted(属性): 一个布尔值,表示操作是否已被中止。如果controller.abort()已被调用,则为true。onabort(事件处理属性) /addEventListener('abort', ...)(方法): 当controller.abort()被调用时,会触发一个abort事件。你可以通过onabort属性或addEventListener方法来监听这个事件。reason(属性, 较新): (Chrome 90+, Firefox 90+, Safari 15.4+) 返回一个表示中止原因的值。当调用controller.abort(reason)时,reason会被设置。这使得我们可以区分不同原因的取消。如果没有传递reason,它通常是一个DOMException类型的AbortError。
AbortError (DOMException):
这是一个非常重要的概念。当一个支持 AbortSignal 的异步操作(例如 fetch)被中止时,它通常会以一个 DOMException 类型的 AbortError 来拒绝其 Promise。这是一个标准的错误类型,用于表示操作被中止,而不是其他类型的错误。在我们的自定义可取消延迟任务中,我们也应该遵循这个约定。
// 示例:使用 AbortController 取消一个 fetch 请求
const controller = new AbortController();
const signal = controller.signal;
fetch('https://api.example.com/data', { signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('Fetch request was aborted.');
} else {
console.error('Fetch error:', error);
}
});
// 假设我们想在 100ms 后取消这个请求
setTimeout(() => {
controller.abort();
console.log('Controller aborted!');
}, 100);
3. 构建可取消的延迟任务:从基础到完善
现在,我们已经理解了 Promise 和 AbortController 的基本原理。是时候将它们结合起来,构建我们的 cancellableDelay 函数了。
我们将分步骤进行,逐步完善功能。
3.1 基础的 delay 函数(无取消功能)
首先,我们回忆一下一个最基础的延迟函数:
/**
* 创建一个延迟指定毫秒数的 Promise。
* @param {number} ms - 延迟的毫秒数。
* @returns {Promise<void>} - 在指定毫秒数后解决的 Promise。
*/
function delay(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
// 使用示例
console.log('开始延迟...');
delay(2000).then(() => {
console.log('延迟 2 秒完成。');
});
这个 delay 函数非常简单,但它无法被外部取消。
3.2 引入 AbortSignal 实现取消功能(第一版)
为了引入取消功能,我们需要在 delay 函数中接收一个 AbortSignal 对象。这个信号将告诉我们何时应该停止等待并拒绝 Promise。
核心思路是:
- 在 Promise 内部,检查
signal.aborted。如果已经中止,立即拒绝 Promise。 - 监听
signal上的abort事件。当事件触发时,清除setTimeout并拒绝 Promise。 - 无论 Promise 是解决还是拒绝,都要记得移除
abort事件监听器,防止内存泄漏。
/**
* 创建一个可取消的延迟 Promise。
* 如果提供了 AbortSignal 且信号被中止,Promise 将拒绝并抛出 AbortError。
* @param {number} ms - 延迟的毫秒数。
* @param {AbortSignal} [signal] - 可选的 AbortSignal 对象,用于取消延迟。
* @returns {Promise<void>} - 在指定毫秒数后解决,或在信号中止时拒绝。
*/
function cancellableDelay(ms, signal) {
return new Promise((resolve, reject) => {
// 1. 如果信号已中止,立即拒绝
if (signal && signal.aborted) {
return reject(new DOMException('Delay aborted', 'AbortError'));
}
let timeoutId;
// 2. 定义一个处理函数,用于在信号中止时清理
const abortHandler = () => {
clearTimeout(timeoutId); // 清除 setTimeout
// 使用标准的 AbortError 拒绝 Promise
reject(new DOMException('Delay aborted', 'AbortError'));
};
// 3. 监听 abort 事件
if (signal) {
signal.addEventListener('abort', abortHandler, { once: true });
}
// 4. 设置 setTimeout
timeoutId = setTimeout(() => {
// 5. 延迟完成后,移除 abort 监听器(如果存在)并解决 Promise
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
resolve();
}, ms);
});
}
代码解析:
if (signal && signal.aborted):这是一个重要的优化。如果在调用cancellableDelay之前signal已经被中止了,我们就不需要设置setTimeout或监听事件,可以直接拒绝 Promise。DOMException('Delay aborted', 'AbortError'):我们遵循AbortController的约定,使用AbortError来表示取消。abortHandler:这是一个内联函数,用于在abort事件触发时执行清理工作(clearTimeout)并拒绝 Promise。signal.addEventListener('abort', abortHandler, { once: true }):我们监听abort事件。{ once: true }是一个非常方便的选项,它确保事件监听器在事件触发后自动移除,省去了手动removeEventListener的麻烦。clearTimeout(timeoutId):当abort事件触发时,我们立即清除setTimeout,防止它在将来执行resolve。signal.removeEventListener('abort', abortHandler):在resolve()之前,我们手动移除了监听器。虽然once: true会自动移除,但为了代码清晰和兼容性(尤其是在没有once选项的老旧环境或某些特殊场景下),明确移除总是好的习惯。在这里,因为我们用了once: true,这行代码在正常情况下是冗余的,但它展示了“完成任务后清理监听器”的理念。实际上,当resolve或reject被调用时,Promise 就会从pending状态变为settled状态,其内部的资源(如timeoutId和事件监听器)应该被清理。
3.3 示例与测试
让我们通过一些例子来测试这个 cancellableDelay 函数。
示例 1:正常完成延迟
console.log('--- 示例 1: 正常完成 ---');
cancellableDelay(1000)
.then(() => console.log('延迟 1 秒完成。'))
.catch(err => console.error('错误:', err.name));
// 预期输出:1秒后 '延迟 1 秒完成。'
示例 2:在延迟中途取消
console.log('--- 示例 2: 中途取消 ---');
const controller1 = new AbortController();
const signal1 = controller1.signal;
cancellableDelay(2000, signal1)
.then(() => console.log('这个不应该被打印出来。'))
.catch(err => {
if (err.name === 'AbortError') {
console.log('延迟任务被成功取消!');
} else {
console.error('发生其他错误:', err);
}
});
setTimeout(() => {
controller1.abort(); // 在 500ms 后取消
console.log('在 500ms 时发出取消信号。');
}, 500);
// 预期输出:
// '在 500ms 时发出取消信号。'
// '延迟任务被成功取消!'
示例 3:在延迟开始前就取消
console.log('--- 示例 3: 提前取消 ---');
const controller2 = new AbortController();
const signal2 = controller2.signal;
controller2.abort(); // 立即取消
cancellableDelay(2000, signal2)
.then(() => console.log('这个不应该被打印出来。'))
.catch(err => {
if (err.name === 'AbortError') {
console.log('延迟任务在开始前就被取消!');
} else {
console.error('发生其他错误:', err);
}
});
// 预期输出:
// '延迟任务在开始前就被取消!'
这些示例证明了我们的 cancellableDelay 函数能够按照预期工作。
3.4 进一步完善:使用 reason 和更严格的清理
在现代 AbortController API 中,abort() 方法可以接受一个 reason 参数,这有助于我们传递更具体的取消原因。同时,虽然 once: true 简化了事件监听器的管理,但理解手动清理的逻辑仍然重要。
让我们稍微调整一下 cancellableDelay 函数,使其更健壮,并支持 reason。
/**
* 创建一个可取消的延迟 Promise。
* 如果提供了 AbortSignal 且信号被中止,Promise 将拒绝并抛出 AbortError(或自定义原因)。
* @param {number} ms - 延迟的毫秒数。
* @param {AbortSignal} [signal] - 可选的 AbortSignal 对象,用于取消延迟。
* @returns {Promise<void>} - 在指定毫秒数后解决,或在信号中止时拒绝。
*/
function cancellableDelayV2(ms, signal) {
return new Promise((resolve, reject) => {
let timeoutId;
let cleanupScheduled = false; // 标记是否已安排清理任务
const doCleanup = () => {
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
clearTimeout(timeoutId);
};
const abortHandler = () => {
// 如果已经安排了清理,则不再重复
if (cleanupScheduled) return;
cleanupScheduled = true;
doCleanup();
// 如果 signal.reason 存在,使用它作为拒绝原因,否则使用默认 AbortError
reject(signal.reason || new DOMException('Delay aborted', 'AbortError'));
};
// 1. 如果信号已中止,立即拒绝并清理
if (signal && signal.aborted) {
cleanupScheduled = true; // 立即标记为已清理
return reject(signal.reason || new DOMException('Delay aborted', 'AbortError'));
}
// 2. 监听 abort 事件
if (signal) {
signal.addEventListener('abort', abortHandler); // 不使用 {once: true},手动管理
}
// 3. 设置 setTimeout
timeoutId = setTimeout(() => {
// 4. 延迟完成后,如果还没有被取消,则清理并解决 Promise
if (!cleanupScheduled) {
cleanupScheduled = true; // 标记为已清理
doCleanup();
resolve();
}
}, ms);
});
}
cancellableDelayV2 的改进点:
- 手动管理监听器: 移除了
once: true,改为手动removeEventListener。这在某些旧环境或希望对事件生命周期有更精细控制的场景下更有用。 cleanupScheduled标志: 确保doCleanup和reject/resolve只被调用一次。因为 Promise 只能从pending变为fulfilled或rejected一次。这个标志避免了在abort和setTimeout几乎同时发生时可能出现的竞态条件。- 支持
signal.reason: 当controller.abort(reason)被调用时,signal.reason会保存这个reason。我们的函数现在会优先使用这个reason来拒绝 Promise,提供了更丰富的错误信息。
示例:使用 reason 取消
console.log('--- 示例 4: 使用 reason 取消 ---');
const controller3 = new AbortController();
const signal3 = controller3.signal;
cancellableDelayV2(3000, signal3)
.then(() => console.log('这个不应该被打印出来。'))
.catch(err => {
if (err.name === 'AbortError') {
console.log('任务因 AbortError 被取消。');
} else {
console.log('任务被自定义原因取消:', err);
}
});
setTimeout(() => {
controller3.abort('User navigated away'); // 传递自定义原因
console.log('在 1000ms 时发出带原因的取消信号。');
}, 1000);
// 预期输出:
// '在 1000ms 时发出带原因的取消信号。'
// '任务被自定义原因取消: User navigated away'
3.5 AbortSignal.timeout() – 简化超时处理(较新 API)
在 Chrome 98+ 和 Node.js 16.0+ 中,AbortSignal 引入了一个静态方法 AbortSignal.timeout(ms),它能直接创建一个在指定毫秒数后自动触发 abort 事件的 AbortSignal。这对于实现带有超时的延迟任务非常方便。
// 模拟一个需要 AbortSignal 的异步操作
function longRunningOperation(signal) {
return new Promise((resolve, reject) => {
// 检查是否已取消
if (signal.aborted) {
return reject(signal.reason || new DOMException('Operation aborted', 'AbortError'));
}
const timeout = setTimeout(() => {
// 检查是否已取消,防止在 setTimeout 触发前被取消
if (signal.aborted) {
return reject(signal.reason || new DOMException('Operation aborted', 'AbortError'));
}
resolve('操作成功完成!');
}, 2000); // 假设操作需要 2 秒
// 监听取消事件
signal.addEventListener('abort', () => {
clearTimeout(timeout);
reject(signal.reason || new DOMException('Operation aborted', 'AbortError'));
}, { once: true });
});
}
console.log('--- 示例 5: 使用 AbortSignal.timeout() ---');
// 创建一个在 1500ms 后自动取消的信号
const timeoutSignal = AbortSignal.timeout(1500);
longRunningOperation(timeoutSignal)
.then(result => console.log(result))
.catch(error => {
if (error.name === 'AbortError') {
console.log('操作因超时被取消:', error.message || error.reason);
} else {
console.error('操作失败:', error);
}
});
// 预期输出:
// '操作因超时被取消: Operation aborted' (如果未提供 reason)
// 或者 '操作因超时被取消: This operation timed out' (如果浏览器内部提供了默认 reason)
这个例子虽然不是直接实现 cancellableDelay,但它展示了 AbortSignal.timeout() 如何简化为任何支持 AbortSignal 的操作添加超时逻辑。我们的 cancellableDelay 函数本身就支持接收这样的信号。
4. 延迟任务的常见使用模式
cancellableDelay 函数本身很有用,但它真正的威力在于与其他异步操作的结合。
4.1 防抖 (Debouncing)
防抖是前端常见的性能优化手段,例如搜索框输入。当用户停止输入一段时间后才触发搜索。
function debounce(func, delayMs) {
let controller = null; // 用于管理取消的 AbortController
let timeoutPromise = Promise.resolve(); // 确保总是一个 Promise
return function(...args) {
// 如果上一个延迟任务还在进行,取消它
if (controller) {
controller.abort();
}
controller = new AbortController();
const signal = controller.signal;
// 创建新的延迟任务
timeoutPromise = cancellableDelayV2(delayMs, signal)
.then(() => func.apply(this, args)) // 延迟结束后执行实际函数
.catch(err => {
// 忽略 AbortError,因为这是预期的取消
if (err.name !== 'AbortError') {
console.error('Debounce error:', err);
throw err; // 重新抛出其他错误
}
})
.finally(() => {
// 任务完成或取消后,清理控制器
if (controller === this.controller) { // 确保清理的是当前控制器
controller = null;
}
});
return timeoutPromise; // 返回 Promise 允许链式调用
};
}
// 模拟搜索函数
function performSearch(query) {
console.log(`正在搜索: ${query}`);
return new Promise(resolve => setTimeout(() => resolve(`结果 for ${query}`), 300));
}
const debouncedSearch = debounce(performSearch, 500);
console.log('--- 示例 6: 防抖 ---');
debouncedSearch('apple');
debouncedSearch('ap');
debouncedSearch('app'); // 这个会取消前面的,重新计时
setTimeout(() => debouncedSearch('apple'), 600); // 这个会执行
setTimeout(() => debouncedSearch('banana'), 1000); // 这个会取消前面的,重新计时
setTimeout(() => debouncedSearch('grape'), 1600); // 这个会执行
// 预期输出:
// (500ms 后) '正在搜索: apple'
// (500ms 后) '正在搜索: grape'
在这个防抖实现中,每次调用 debouncedSearch 时,如果前一个延迟任务仍在进行,我们都会通过 controller.abort() 取消它,然后启动一个新的延迟任务。这确保了只有在用户停止输入一段时间后,performSearch 才会被调用。
4.2 带有超时的操作包装器
我们可以创建一个通用函数,为任何返回 Promise 的异步操作添加一个超时功能。
/**
* 为一个 Promise-based 的异步操作添加超时功能。
* @param {Promise<T>} promise - 原始的异步操作 Promise。
* @param {number} timeoutMs - 超时毫秒数。
* @param {AbortSignal} [parentSignal] - 可选的父级 AbortSignal,用于外部取消。
* @returns {Promise<T>} - 带有超时和取消功能的 Promise。
* @template T
*/
function withTimeout(promise, timeoutMs, parentSignal) {
return new Promise((resolve, reject) => {
const controller = new AbortController();
const signal = controller.signal;
// 如果有父级信号,当父级信号中止时,也中止当前操作
if (parentSignal) {
if (parentSignal.aborted) {
controller.abort(parentSignal.reason);
return reject(parentSignal.reason || new DOMException('Operation aborted by parent signal', 'AbortError'));
}
parentSignal.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true });
}
// 设置超时计时器
const timeoutId = setTimeout(() => {
controller.abort(new DOMException('Operation timed out', 'TimeoutError')); // 超时时发出中止信号
}, timeoutMs);
// 将 AbortSignal 传递给原始 Promise (如果它支持)
// 注意:这里的 'promise' 参数本身可能不接受 signal。
// 如果原始 promise 需要 signal,你需要修改 withTimeout 的签名,
// 例如:withTimeout(operationFactory: (signal: AbortSignal) => Promise<T>, ...)
// 为了简单,我们假设原始 promise 内部可能不会直接使用此 signal,
// 但我们可以用它来取消 setTimeout。
// 更常见的做法是:withTimeout((signal) => fetch(url, { signal }))
promise
.then(result => {
clearTimeout(timeoutId); // 操作完成,清除超时
if (parentSignal) parentSignal.removeEventListener('abort', () => controller.abort(parentSignal.reason));
resolve(result);
})
.catch(error => {
clearTimeout(timeoutId); // 操作失败,清除超时
if (parentSignal) parentSignal.removeEventListener('abort', () => controller.abort(parentSignal.reason));
reject(error);
});
// 监听内部信号,如果被外部 abort 或超时 abort,则 reject
signal.addEventListener('abort', () => {
if (signal.aborted) {
clearTimeout(timeoutId);
if (parentSignal) parentSignal.removeEventListener('abort', () => controller.abort(parentSignal.reason));
reject(signal.reason || new DOMException('Operation cancelled', 'AbortError'));
}
}, { once: true });
});
}
// 模拟一个需要较长时间的异步请求
function simulateApiCall(durationMs) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`API Call finished after ${durationMs}ms`);
resolve(`Data from API after ${durationMs}ms`);
}, durationMs);
});
}
console.log('--- 示例 7: 带有超时的操作 ---');
// 期望成功
withTimeout(simulateApiCall(500), 1000)
.then(res => console.log('成功:', res))
.catch(err => console.error('失败:', err.name, err.message || err.reason));
// 期望超时
withTimeout(simulateApiCall(1500), 1000)
.then(res => console.log('成功:', res))
.catch(err => console.error('失败:', err.name, err.message || err.reason));
// 期望被外部取消
const externalController = new AbortController();
setTimeout(() => externalController.abort('External cancellation reason'), 300);
withTimeout(simulateApiCall(2000), 1000, externalController.signal)
.then(res => console.log('成功:', res))
.catch(err => console.error('失败:', err.name, err.message || err.reason));
// 预期输出:
// (300ms 后) '失败: AbortError External cancellation reason'
// (500ms 后) 'API Call finished after 500ms'
// (500ms 后) '成功: Data from API after 500ms'
// (1000ms 后) '失败: TimeoutError Operation timed out'
// (1500ms 后) 'API Call finished after 1500ms' (注意:这个 Promise 已经被拒绝,但内部的 setTimeout 仍然会执行,只是结果不再被处理)
// (2000ms 后) 'API Call finished after 2000ms' (同上)
withTimeout 的注意事项:
上面 withTimeout 的实现中,promise 参数本身是一个已创建的 Promise。这意味着如果 promise 内部没有接收 AbortSignal,那么当超时或外部取消发生时,我们只能拒绝 withTimeout 返回的 Promise,而不能真正中止 simulateApiCall 内部的 setTimeout。这通常没问题,因为 simulateApiCall 完成后其结果会被忽略。
更理想的 withTimeout 模式是让它接受一个工厂函数,该工厂函数在执行时接收一个 AbortSignal,例如:
function withTimeoutFactory(operationFactory, timeoutMs, parentSignal) {
return new Promise((resolve, reject) => {
const controller = new AbortController();
const signal = controller.signal;
// 绑定父级信号
if (parentSignal) {
if (parentSignal.aborted) {
controller.abort(parentSignal.reason);
return reject(parentSignal.reason || new DOMException('Operation aborted by parent signal', 'AbortError'));
}
parentSignal.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true });
}
const timeoutId = setTimeout(() => {
controller.abort(new DOMException('Operation timed out', 'TimeoutError'));
}, timeoutMs);
// 调用工厂函数,并将 signal 传递给它
operationFactory(signal)
.then(result => {
clearTimeout(timeoutId);
// 确保移除父级信号监听器
if (parentSignal) parentSignal.removeEventListener('abort', () => controller.abort(parentSignal.reason));
resolve(result);
})
.catch(error => {
clearTimeout(timeoutId);
// 确保移除父级信号监听器
if (parentSignal) parentSignal.removeEventListener('abort', () => controller.abort(parentSignal.reason));
// 如果是 AbortError 且是内部信号触发的,则使用内部信号的 reason
if (error.name === 'AbortError' && signal.aborted) {
reject(signal.reason || error);
} else {
reject(error);
}
});
// 监听内部信号,如果被外部 abort 或超时 abort,则 reject
signal.addEventListener('abort', () => {
if (signal.aborted) {
clearTimeout(timeoutId);
// 确保移除父级信号监听器
if (parentSignal) parentSignal.removeEventListener('abort', () => controller.abort(parentSignal.reason));
reject(signal.reason || new DOMException('Operation cancelled', 'AbortError'));
}
}, { once: true });
});
}
// 模拟一个真正支持 AbortSignal 的 API 调用
function simulateCancellableApiCall(durationMs, signal) {
return new Promise((resolve, reject) => {
if (signal && signal.aborted) {
return reject(signal.reason || new DOMException('API call aborted', 'AbortError'));
}
const timeout = setTimeout(() => {
if (signal && signal.aborted) { // 再次检查以防在 setTimeout 触发前被取消
return reject(signal.reason || new DOMException('API call aborted', 'AbortError'));
}
console.log(`Cancellable API Call finished after ${durationMs}ms`);
resolve(`Data from cancellable API after ${durationMs}ms`);
}, durationMs);
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timeout);
reject(signal.reason || new DOMException('API call aborted', 'AbortError'));
}, { once: true });
}
});
}
console.log('--- 示例 8: 带有超时的可取消操作 (工厂函数模式) ---');
// 期望超时并取消内部 setTimeout
withTimeoutFactory(
(signal) => simulateCancellableApiCall(1500, signal),
1000
)
.then(res => console.log('成功:', res))
.catch(err => console.error('失败:', err.name, err.message || err.reason));
// 预期输出:
// (1000ms 后) '失败: TimeoutError Operation timed out'
// 此时 simulateCancellableApiCall 内部的 setTimeout 已经被 clearTimeout
这个 withTimeoutFactory 模式更为强大和推荐,因为它允许我们真正地“通知”被包装的异步操作进行自我取消。
5. 高级考量与最佳实践
5.1 资源清理的重要性
无论我们的 Promise 是 resolve 还是 reject,都必须确保清理 setTimeout 和 AbortSignal 上的事件监听器。
clearTimeout: 防止延迟函数在 Promise 已经解决/拒绝后执行不必要的操作。removeEventListener: 防止内存泄漏。如果一个组件在异步操作完成前被卸载,但事件监听器仍然存在,它可能会持有对组件的引用,阻止垃圾回收。
在 cancellableDelayV2 中,我们通过 doCleanup 函数和 cleanupScheduled 标志来确保清理工作只执行一次且在适当的时机。
5.2 AbortError 作为标准
始终使用 DOMException 类型的 AbortError 来表示取消操作。这符合 Web 平台的约定,使得消费者代码能够以统一的方式处理取消:
try {
await cancellableDelayV2(2000, signal);
} catch (error) {
if (error.name === 'AbortError') {
console.log('操作被取消。');
} else {
console.error('发生其他错误:', error);
}
}
如果需要传递更具体的取消信息,请使用 controller.abort(reason),这样 signal.reason 就可以被捕获。
5.3 AbortController 的可组合性
AbortController 的设计使其具有高度的可组合性。你可以将一个父级 AbortSignal 传递给多个子操作,当父级信号中止时,所有子操作都会收到通知并中止。这对于管理复杂的用户界面或数据流中的多个相关异步操作非常有用。
例如,一个页面组件可以创建一个 AbortController。当组件卸载时,调用 controller.abort(),所有在该组件生命周期内启动的、并接收了该 signal 的异步操作(包括我们的 cancellableDelay、fetch 请求、动画等)都会被自动取消。
5.4 与 UI 框架的集成
在像 React、Vue 这样的现代 UI 框架中,AbortController 模式与它们的生命周期管理机制完美契合:
-
React
useEffect: 在useEffect的清理函数中创建并调用controller.abort()。useEffect(() => { const controller = new AbortController(); const signal = controller.signal; async function fetchData() { try { await cancellableDelayV2(1000, signal); // 使用我们的延迟函数 // 或者 await fetch('/api/data', { signal }); // ... 处理数据 } catch (error) { if (error.name === 'AbortError') { console.log('组件卸载,任务取消'); } else { console.error('数据加载失败', error); } } } fetchData(); return () => { controller.abort(); // 组件卸载时取消所有相关任务 }; }, []); -
Vue
onUnmounted: 类似地,在onUnmounted钩子中执行取消操作。<script setup> import { onUnmounted } from 'vue'; const controller = new AbortController(); const signal = controller.signal; async function doSomethingDelayed() { try { await cancellableDelayV2(2000, signal); console.log('延迟任务完成'); } catch (error) { if (error.name === 'AbortError') { console.log('组件卸载,延迟任务取消'); } else { console.error('任务失败', error); } } } doSomethingDelayed(); onUnmounted(() => { controller.abort(); // 组件卸载时取消任务 }); </script>
总结与展望
通过 AbortController 和 Promise 的结合,我们成功实现了一个功能完善、逻辑严谨的可取消延迟任务。这一模式不仅解决了传统 setTimeout 在 Promise 环境下取消的难题,更提供了一套标准化的、可组合的异步操作取消机制。掌握 AbortController 的使用,对于编写健壮、响应迅速、资源友好的现代 Web 应用程序至关重要。
我们讨论了从基础的 delay 到高级 cancellableDelayV2 的演进,涵盖了 AbortController 和 AbortSignal 的核心概念、事件监听与清理、AbortError 的应用以及 signal.reason 的使用。此外,还通过防抖和带超时操作的例子,展示了 cancellableDelay 在实际应用中的价值和与 UI 框架的良好集成。希望这篇讲座能帮助大家在日常开发中更好地管理异步任务的生命周期。