在前端实现一个可中断的异步 Promise,并能在事件点击中终止它,是一个常见的需求,尤其是在处理用户交互频繁或网络请求可能耗时较长的场景。
原生 JavaScript 的 Promise 是不可中断的。一旦一个 Promise 被创建并开始执行,它就无法在外部被“取消”或“停止”。它只会走向 fulfilled(成功)或 rejected(失败)状态。
然而,我们可以通过一些模式来模拟“中断”或“取消”的行为。其核心思想是:
- 信号机制:提供一个机制(例如一个布尔标志或
AbortController)来通知正在执行的异步操作,它应该停止其工作并拒绝。 - 内部检查:异步操作的内部逻辑需要定期检查这个信号,并在收到中断信号时,提前终止并抛出一个特定的错误(通常是
AbortError)。 - 外部控制:提供一个方法(例如
cancel())来触发这个中断信号。
下面我将介绍两种主要的实现方式:
- 使用
AbortController(推荐用于fetchAPI) :这是 Web 标准,专门用于中断 DOM 请求(如fetch)。 - 使用自定义标志 (适用于通用异步任务) :对于非
fetch的异步操作(如setTimeout、计算密集型任务),我们可以使用一个自定义的布尔标志。
方法一:使用 AbortController (推荐用于 fetch API)
AbortController 是现代浏览器提供的一个 Web API,它允许你创建一个 AbortSignal 对象,然后将这个信号传递给可中断的 Web API(如 fetch、XMLHttpRequest、ReadableStream 等)。当调用 AbortController.abort() 方法时,所有监听这个信号的 API 都会被中断,并抛出一个 AbortError。
HTML 结构:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>可中断的 Promise (AbortController)</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
button { padding: 10px 15px; margin: 5px; cursor: pointer; }
#status { margin-top: 20px; padding: 10px; border: 1px solid #ccc; min-height: 50px; background-color: #f9f9f9; }
</style>
</head>
<body>
<h1>可中断的 Promise (AbortController 示例)</h1>
<p>点击“开始任务”模拟一个耗时的网络请求。在请求完成前点击“取消任务”可以中断它。</p>
<button id="startButton">开始任务 (Fetch)</button>
<button id="cancelButton" disabled>取消任务</button>
<div id="status">等待任务...</div>
<script>
let currentAbortController = null; // 用于存储当前的 AbortController 实例
const startButton = document.getElementById('startButton');
const cancelButton = document.getElementById('cancelButton');
const statusDiv = document.getElementById('status');
function updateStatus(message, color = 'black') {
statusDiv.textContent = message;
statusDiv.style.color = color;
}
async function performCancellableFetch() {
// 1. 创建 AbortController 实例
currentAbortController = new AbortController();
const signal = currentAbortController.signal;
updateStatus('任务开始:正在模拟网络请求 (5秒)...', 'blue');
startButton.disabled = true;
cancelButton.disabled = false;
try {
// 2. 将 signal 传递给 fetch 请求
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1', { signal });
// 模拟网络延迟,实际应用中不需要
await new Promise(resolve => setTimeout(resolve, 5000));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
updateStatus(`任务成功:获取到数据 - ${JSON.stringify(data.title)}`, 'green');
} catch (error) {
if (error.name === 'AbortError') {
updateStatus('任务已取消!', 'orange');
} else {
updateStatus(`任务失败:${error.message}`, 'red');
}
} finally {
// 任务结束,重置状态
startButton.disabled = false;
cancelButton.disabled = true;
currentAbortController = null;
}
}
startButton.addEventListener('click', performCancellableFetch);
cancelButton.addEventListener('click', () => {
if (currentAbortController) {
// 3. 调用 abort() 方法中断请求
currentAbortController.abort();
updateStatus('正在发送取消信号...', 'purple');
}
});
</script>
</body>
</html>
解释:
new AbortController(): 创建一个控制器实例。controller.signal: 获取与控制器关联的AbortSignal对象。这个信号可以传递给fetch或其他支持中断的 API。fetch(url, { signal }): 将signal对象作为fetch请求的选项之一传递。controller.abort(): 当你想中断请求时,调用AbortController实例的abort()方法。这会触发signal上的abort事件,导致fetchPromise 被拒绝,并抛出一个AbortError。catch (error): 在catch块中,你可以检查error.name === 'AbortError'来区分是中断导致的错误还是其他类型的网络错误。
方法二:使用自定义标志 (适用于通用异步任务)
对于不直接支持 AbortSignal 的异步操作(例如,一个复杂的计算任务,或者一个基于 setTimeout 的动画),我们可以通过传递一个自定义的“取消”函数或一个可变对象来模拟中断。
HTML 结构:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>可中断的 Promise (自定义标志)</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
button { padding: 10px 15px; margin: 5px; cursor: pointer; }
#status { margin-top: 20px; padding: 10px; border: 1px solid #ccc; min-height: 50px; background-color: #f9f9f9; }
</style>
</head>
<body>
<h1>可中断的 Promise (自定义标志示例)</h1>
<p>点击“开始任务”模拟一个耗时的计算任务。在任务完成前点击“取消任务”可以中断它。</p>
<button id="startButtonCustom">开始任务 (自定义)</button>
<button id="cancelButtonCustom" disabled>取消任务</button>
<div id="statusCustom">等待任务...</div>
<script>
// 封装一个可中断的 Promise
function makeCancellablePromise(executor) {
let hasCancelled = false;
let rejectPromise; // 用于存储 Promise 的 reject 函数
const wrappedPromise = new Promise((resolve, reject) => {
rejectPromise = reject; // 捕获 Promise 的 reject 函数
// 传递一个检查取消状态的函数给 executor
// executor 内部需要定期调用这个 checkCancellation 函数
const checkCancellation = () => {
if (hasCancelled) {
const error = new Error('任务已取消');
error.name = 'CancellationError'; // 自定义错误名称
throw error; // 抛出错误,会被外部 Promise 的 catch 捕获
}
};
// 执行原始的异步任务
executor(resolve, reject, checkCancellation);
});
return {
promise: wrappedPromise,
cancel() {
if (!hasCancelled) {
hasCancelled = true;
// 如果 Promise 还没有解决或拒绝,立即拒绝它
// 注意:这只能阻止 Promise 解决,不一定能停止内部的同步计算
if (rejectPromise) {
const error = new Error('任务已取消');
error.name = 'CancellationError';
rejectPromise(error);
}
}
}
};
}
let currentCancellableTask = null; // 用于存储当前的可中断任务对象
const startButtonCustom = document.getElementById('startButtonCustom');
const cancelButtonCustom = document.getElementById('cancelButtonCustom');
const statusDivCustom = document.getElementById('statusCustom');
function updateStatusCustom(message, color = 'black') {
statusDivCustom.textContent = message;
statusDivCustom.style.color = color;
}
startButtonCustom.addEventListener('click', () => {
updateStatusCustom('任务开始:正在模拟耗时计算 (5秒)...', 'blue');
startButtonCustom.disabled = true;
cancelButtonCustom.disabled = false;
// 创建一个可中断的异步任务
currentCancellableTask = makeCancellablePromise(async (resolve, reject, checkCancellation) => {
let sum = 0;
for (let i = 0; i < 5000000000; i++) { // 模拟一个耗时计算
sum += i;
// 每隔一段时间检查是否被取消
if (i % 100000000 === 0) { // 减少检查频率以提高性能,但仍能响应取消
try {
checkCancellation();
} catch (e) {
// 如果被取消,直接返回,不再继续计算
return reject(e); // 确保外部 Promise 拒绝
}
// 也可以加入 setTimeout/requestAnimationFrame 来让出主线程,
// 避免阻塞 UI,同时允许取消信号被处理
// await new Promise(r => setTimeout(r, 0));
}
}
resolve(`计算完成,结果:${sum}`);
});
currentCancellableTask.promise
.then(result => {
updateStatusCustom(`任务成功:${result}`, 'green');
})
.catch(error => {
if (error.name === 'CancellationError') {
updateStatusCustom('任务已取消!', 'orange');
} else {
updateStatusCustom(`任务失败:${error.message}`, 'red');
}
})
.finally(() => {
startButtonCustom.disabled = false;
cancelButtonCustom.disabled = true;
currentCancellableTask = null;
});
});
cancelButtonCustom.addEventListener('click', () => {
if (currentCancellableTask) {
currentCancellableTask.cancel();
updateStatusCustom('正在发送取消信号...', 'purple');
}
});
</script>
</body>
</html>
解释:
-
makeCancellablePromise(executor)函数:- 它返回一个包含
promise和cancel方法的对象。 hasCancelled标志:内部状态,指示任务是否已被取消。rejectPromise:存储了内部 Promise 的reject函数。这是关键,允许我们在外部调用cancel()时,直接拒绝 Promise。executor(resolve, reject, checkCancellation): 原始的异步逻辑被封装在这个executor函数中。它除了接收标准的resolve和reject,还接收一个checkCancellation函数。checkCancellation(): 这个函数是核心。它检查hasCancelled标志。如果为true,它会抛出一个带有CancellationError名称的错误。- 异步任务内部:在模拟的耗时计算中,我们每隔一段时间调用
checkCancellation()。如果捕获到CancellationError,就立即停止计算并reject外部 Promise。
- 它返回一个包含
-
cancel()方法:- 当外部调用
cancel()时,它会将hasCancelled设置为true。 - 然后,它会调用之前捕获的
rejectPromise,用一个CancellationError来拒绝 Promise。这会立即触发外部.catch()块。
- 当外部调用
重要注意事项:
- 同步任务中断的局限性:对于纯粹的同步计算密集型任务,即使你调用了
cancel(),任务本身可能不会立即停止执行,因为它不会主动让出 JavaScript 主线程。cancel()只能确保当任务完成或在下一个检查点时,它的结果不会被处理,而是被拒绝。为了真正不阻塞 UI 并允许及时响应取消,你可能需要将耗时计算拆分成小块,并使用setTimeout(..., 0)或requestAnimationFrame来在每次小块计算后让出主线程。或者考虑使用 Web Workers。 - 错误处理:始终在
.catch()块中检查error.name来区分是取消导致的错误还是实际的业务逻辑错误。 - 资源清理:在实际应用中,如果你的异步任务涉及打开文件、建立连接等,在取消时,你还需要在
catch块或finally块中进行适当的资源清理。 - 状态管理:在实际项目中,你可能需要一个更健壮的状态管理来跟踪任务的生命周期(例如:
idle,running,cancelling,cancelled,succeeded,failed)。
这两种方法都提供了在前端实现可中断异步 Promise 的有效途径,选择哪种取决于你的具体使用场景。对于网络请求,首选 AbortController;对于其他通用异步操作,自定义标志模式是灵活的选择。