前端实现一个可中断的异步 Promise

217 阅读4分钟

在前端实现一个可中断的异步 Promise,并能在事件点击中终止它,是一个常见的需求,尤其是在处理用户交互频繁或网络请求可能耗时较长的场景。

原生 JavaScript 的 Promise 是不可中断的。一旦一个 Promise 被创建并开始执行,它就无法在外部被“取消”或“停止”。它只会走向 fulfilled(成功)或 rejected(失败)状态。

然而,我们可以通过一些模式来模拟“中断”或“取消”的行为。其核心思想是:

  1. 信号机制:提供一个机制(例如一个布尔标志或 AbortController)来通知正在执行的异步操作,它应该停止其工作并拒绝。
  2. 内部检查:异步操作的内部逻辑需要定期检查这个信号,并在收到中断信号时,提前终止并抛出一个特定的错误(通常是 AbortError)。
  3. 外部控制:提供一个方法(例如 cancel())来触发这个中断信号。

下面我将介绍两种主要的实现方式:

  1. 使用 AbortController (推荐用于 fetch API) :这是 Web 标准,专门用于中断 DOM 请求(如 fetch)。
  2. 使用自定义标志 (适用于通用异步任务) :对于非 fetch 的异步操作(如 setTimeout、计算密集型任务),我们可以使用一个自定义的布尔标志。

方法一:使用 AbortController (推荐用于 fetch API)

AbortController 是现代浏览器提供的一个 Web API,它允许你创建一个 AbortSignal 对象,然后将这个信号传递给可中断的 Web API(如 fetchXMLHttpRequestReadableStream 等)。当调用 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>

解释:

  1. new AbortController() : 创建一个控制器实例。
  2. controller.signal: 获取与控制器关联的 AbortSignal 对象。这个信号可以传递给 fetch 或其他支持中断的 API。
  3. fetch(url, { signal }) : 将 signal 对象作为 fetch 请求的选项之一传递。
  4. controller.abort() : 当你想中断请求时,调用 AbortController 实例的 abort() 方法。这会触发 signal 上的 abort 事件,导致 fetch Promise 被拒绝,并抛出一个 AbortError
  5. 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>

解释:

  1. makeCancellablePromise(executor) 函数

    • 它返回一个包含 promisecancel 方法的对象。
    • hasCancelled 标志:内部状态,指示任务是否已被取消。
    • rejectPromise:存储了内部 Promise 的 reject 函数。这是关键,允许我们在外部调用 cancel() 时,直接拒绝 Promise。
    • executor(resolve, reject, checkCancellation) : 原始的异步逻辑被封装在这个 executor 函数中。它除了接收标准的 resolvereject,还接收一个 checkCancellation 函数。
    • checkCancellation() : 这个函数是核心。它检查 hasCancelled 标志。如果为 true,它会抛出一个带有 CancellationError 名称的错误。
    • 异步任务内部:在模拟的耗时计算中,我们每隔一段时间调用 checkCancellation()。如果捕获到 CancellationError,就立即停止计算并 reject 外部 Promise。
  2. 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;对于其他通用异步操作,自定义标志模式是灵活的选择。