手写一个可取消Promise任务流,告别resolved之后才后悔的异步操作

你有没有过这种经历:用户点了一个“加载数据”按钮,然后立刻又点了“取消”,结果数据还是加载出来了,界面突然多了一块根本不该出现的内容。又或者你发了个请求,但用户已经离开当前页面,请求回来以后还在setState,控制台报了一堆警告。以前我也无所谓,直到有一次线上出现因为异步回调整改了已销毁的DOM,导致整页白屏。

后来我决定把项目里所有耗时的异步操作全部改用“可取消”模式。最初我直接给每个请求挂一个AbortController,但发现自己只解决了fetch,还有setTimeout、事件监听、WebSocket这些五花八门的东西。后来我干脆写了一个很小的工具函数,把任意Promise变成可取消的,而且取消后还能调用我们注册的清理函数。用了一个多月,爽了很多,所以今天拿出来分享。

一、取消的本质是什么

JavaScript里的Promise一旦创建,就一定会进入resolved或rejected状态,没法被外部“暂停”或“终止”。我们所谓的“取消”其实是两层意思:

  • 告诉底层任务(比如fetch)你不需要了,让它别继续白费力气。
  • 屏蔽掉Promise回调,让.then和.catch不再执行,避免出现“后悔操作”。

AbortController就是提供了一个标准信号,底层任务可以被信号取消。但如果我们拿到的是一个普通的Promise,比如一些老旧的库或者自己写的计时器,就得自己做一个包装。

二、写一个最基础的cancelable函数

先写一个把Promise变得可取消的工具函数。它接收一个Promise和执行函数,返回一个新的Promise,这个新Promise在收到取消信号时,会拒绝并带一个AbortError,同时执行给定的清理函数。

function cancelable(promise, onCancel) {
    let isCancelled = false;

    const wrapped = new Promise((resolve, reject) => {
        promise.then(
            (value) => {
                if (isCancelled) return;
                resolve(value);
            },
            (error) => {
                if (isCancelled) return;
                reject(error);
            }
        );

        // 提供一个取消器给外部
        wrappedCancel = () => {
            if (isCancelled) return;
            isCancelled = true;
            if (typeof onCancel === 'function') {
                onCancel();
            }
            reject(new DOMException('操作已取消', 'AbortError'));
        };
    });

    let wrappedCancel;
    wrapped.cancel = () => {
        if (isCancelled) return;
        isCancelled = true;
        if (typeof onCancel === 'function') {
            try {
                onCancel();
            } catch (e) {
                // 清理函数抛错不该掩盖取消本身
                console.error(e);
            }
        }
        // 这里需要拿到reject,但上面reject是局部的,所以要把reject存下来
    };
    return wrapped;
}

糟糕,上面有点乱。我改一下,用闭包保存resolve/reject,这样外部可以真正取消。

function cancelable(promise, onCancel) {
    let cancelFn = null;
    let isCancelled = false;

    const wrapped = new Promise((resolve, reject) => {
        promise.then(
            (value) => {
                if (isCancelled) return;
                resolve(value);
            },
            (error) => {
                if (isCancelled) return;
                reject(error);
            }
        );

        cancelFn = () => {
            if (isCancelled) return;
            isCancelled = true;
            try {
                onCancel && onCancel();
            } catch (e) {
                console.error('取消清理函数报错', e);
            }
            reject(new DOMException('操作已取消', 'AbortError'));
        };
    });

    wrapped.cancel = () => cancelFn();
    return wrapped;
}

用的时候就是这样:

const task = cancelable(new Promise((resolve) => {
    const timer = setTimeout(() => resolve('数据'), 1000);
    // 必须把清理逻辑传给onCancel
    task.onCancel?  // 不对
}), () => clearTimeout(timer));

但这样写不优雅,因为onCancel要在外面写,而且task这个变量还没定义完。更常见的是直接提供一个函数,它返回Promise并且同时把清理函数传出来。我自己用的是一个对象configure。

三、更好的封装:统一使用AbortSignal

与其自己搞一套cancel函数,不如直接兼容AbortSignal,这样可以把fetch、定时器、事件监听全部纳入同一个取消机制。先设计一个函数,它接收一个“起始者”回调,回调里可以注册清理函数,返回一个Promise。

function cancellableTask(start) {
    // start 会收到一个信号对象,可以用它来监听取消
    let abortController = new AbortController();
    const signal = abortController.signal;

    return new Promise((resolve, reject) => {
        const cleanup = start(signal, { resolve, reject });

        if (cleanup && typeof cleanup === 'function') {
            signal.addEventListener('abort', () => {
                try {
                    cleanup();
                } catch (e) {
                    console.error('清理失败', e);
                }
                reject(new DOMException('任务取消', 'AbortError'));
            }, { once: true });
        }
    });
}

这个方案抽象得很好。使用者只需要在start函数里订阅signal的abort事件,或者把它传给fetch。但前面这个封装的取消触发条件是什么?必须由外部调用abortController.abort()。所以返回值里应该暴露abort方法。

function cancellableTask(start) {
    const controller = new AbortController();
    const signal = controller.signal;

    const promise = new Promise((resolve, reject) => {
        const cleanup = start(signal, { resolve, reject });

        const abortHandler = () => {
            if (cleanup && typeof cleanup === 'function') {
                cleanup();
            }
            reject(new DOMException('任务已取消', 'AbortError'));
        };

        signal.addEventListener('abort', abortHandler, { once: true });
    });

    return {
        promise,
        abort: () => controller.abort(),
        signal,
    };
}

但这里有一个隐藏问题:如果start执行过程中同步抛异常,promise会正常reject,没问题。如果任务完成,abort事件依然可能触发(如果用户中途取消),一旦取消,即使底层promise已经resolve,我们的promise也不会改变状态,因为Promise状态一旦确定就不可变。但上面的abortHandler会在取消时调用reject,如果promise之前已经resolve了,reject无效。所以安全。但我们还是应该清理内存:任务完成时移除abort监听器。

改进一下,在resolve或reject时把abort监听移除,并且把清理函数引用置空防止内存泄漏。

function cancellableTask(start) {
    const controller = new AbortController();
    const signal = controller.signal;

    let cleanup = null;
    let isDone = false;

    const promise = new Promise((resolve, reject) => {
        const done = (fn) => (value) => {
            if (isDone) return;
            isDone = true;
            signal.removeEventListener('abort', onAbort);
            fn(value);
        };

        const onAbort = () => {
            if (isDone) return;
            isDone = true;
            if (cleanup) {
                try { cleanup(); } catch (e) { console.error(e); }
            }
            reject(new DOMException('操作已取消', 'AbortError'));
        };

        signal.addEventListener('abort', onAbort, { once: true });

        try {
            cleanup = start(signal, {
                resolve: done(resolve),
                reject: done(reject),
            });
        } catch (e) {
            done(reject)(e);
        }
    });

    return {
        promise,
        abort: () => controller.abort(),
        signal,
    };
}

这个版本健壮多了。你可以这样使用:

function fetchWithTimeout(url, ms) {
    const { promise, abort } = cancellableTask((signal, { resolve, reject }) => {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), ms);

        // 把外部signal和内部controller关联起来
        const onExternalAbort = () => controller.abort();
        signal.addEventListener('abort', onExternalAbort);

        fetch(url, { signal: controller.signal })
            .then(res => res.json())
            .then(resolve)
            .catch(err => {
                if (err.name !== 'AbortError') {
                    reject(err);
                }
            })
            .finally(() => {
                clearTimeout(timer);
                signal.removeEventListener('abort', onExternalAbort);
            });

        return () => controller.abort();
    });

    return { promise, abort };
}

这个函数返回一个可取消的fetch,并且自带超时。外部调用abort()就会同时取消fetch和清理定时器。

四、用单测验证一下基本行为

写个简单的测试,确认取消后promise状态是rejected,并且清理函数被执行。

const task = cancellableTask((signal, { resolve }) => {
    const timer = setTimeout(() => resolve('done'), 100);
    return () => clearTimeout(timer);
});

task.promise.catch(err => {
    console.log(err.name); // AbortError
});

task.abort();
console.log('已调用取消');

运行后,控制台会先打印“已调用取消”,然后打印AbortError。因为abort是同步的,promise的catch在微任务里执行,所以顺序没问题。如果你在abort之前就等promise resolve,它就会正常输出done,不会发生cancel。

五、看一个实际的业务场景:任务队列批量处理

有时候我们要上传很多文件,每个文件上传是一个异步任务,用户希望点“停止”就全部取消。以前用Promise.all没法中途取消,现在用我们的工具可以轻松实现。

设计一个简单的队列:

class TaskQueue {
    constructor() {
        this.tasks = [];
        this.abortController = null;
    }

    add(taskFn) {
        this.tasks.push(taskFn);
    }

    async runAll() {
        if (this.abortController) {
            this.abortController.abort(); // 重置上一次
        }
        const controller = new AbortController();
        this.abortController = controller;

        const results = [];
        for (const [index, taskFn] of this.tasks.entries()) {
            // 一旦取消,后面的任务不再执行,当前任务也被中断
            if (controller.signal.aborted) {
                throw new DOMException('队列已取消', 'AbortError');
            }

            const { promise, abort } = cancellableTask((signal, { resolve, reject }) => {
                const onAbort = () => {
                    reject(new DOMException('任务取消', 'AbortError'));
                };
                signal.addEventListener('abort', onAbort);
                // 注意:taskFn自己可能是异步的,我们不能直接拿到清理
                Promise.resolve(taskFn(index)).then(resolve).catch(reject);
                return () => {
                    signal.removeEventListener('abort', onAbort);
                };
            });

            // 把外部取消信号转发到当前任务
            const onQueueAbort = () => abort();
            controller.signal.addEventListener('abort', onQueueAbort, { once: true });

            try {
                results.push(await promise);
            } finally {
                controller.signal.removeEventListener('abort', onQueueAbort);
            }
        }

        return results;
    }

    abort() {
        this.abortController?.abort();
    }
}

这里有点绕,但核心思想是:队列有一个总控的AbortController,当用户调用abort()时,总控信号触发,我们监听到这个信号,就去调用当前任务的abort()。这样当前正在执行的异步操作会被取消,循环也会因为signal.aborted而退出。

不过上面代码为了演示过于复杂。我更推荐更简单的模式:每个任务在开始时检查signal,并且把signal直接传给底层的fetch或定时器,这样底层已经被纳入了取消体系。

function cancellableFetchQueue(tasks, signal) {
    const results = [];
    for (const task of tasks) {
        if (signal.aborted) {
            throw new DOMException('队列取消', 'AbortError');
        }
        // 每个任务内部都使用signal
        const result = await fetch(task.url, { signal }).then(r => r.json());
        results.push(result);
    }
    return results;
}

这比上面那个更干净。因为fetch本身就支持signal,你根本不需要额外包装。只有那些不支持signal的任务(比如定时器)才需要手动转发。

六、定时器如何融入取消

我们经常需要做轮询或者延迟操作。把setTimeout包装成可取消,其实很简单:

function wait(ms, signal) {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => {
            signal?.removeEventListener('abort', onAbort);
            resolve();
        }, ms);

        const onAbort = () => {
            clearTimeout(timer);
            reject(new DOMException('等待被取消', 'AbortError'));
        };

        if (signal) {
            if (signal.aborted) {
                onAbort();
                return;
            }
            signal.addEventListener('abort', onAbort, { once: true });
        }
    });
}

然后你就可以在一个可取消的异步流程里:

async function polling(signal) {
    while (!signal.aborted) {
        await fetch('/api/status', { signal });
        await wait(1000, signal);
    }
}

当外部调用abort时,fetch和wait都会立即抛出AbortError,然后Promise会快速结束,而不会傻傻等地等完一秒钟。

七、处理finally中的常见错误

使用取消逻辑后,finally块里经常需要判断“是不是被取消的”。你不能简单地在finally里使用return,因为finally的return会覆盖之前的reject。正确做法是catch里处理AbortError,或者用标志位。

try {
    await doSomething(signal);
} catch (e) {
    if (e.name === 'AbortError') {
        console.log('已取消');
    } else {
        throw e;
    }
} finally {
    // 清理资源,比如清除loading状态
    loading.value = false;
}

或者更精细地,在finally里判断signal.aborted:

try {
    await taskPromise;
} finally {
    if (signal.aborted) {
        console.log('是取消导致的退出');
    }
}

但要注意,signal.aborted可能为true但结束不是因为取消,而是任务本身就完成了。所以最可靠还是看异常类型。

八、封装一个可取消的组件副作用

在React或Vue里,我们经常需要在组件卸载时取消异步操作。用我们写的这个工具,可以很容易地通过一个自定义Hooks来管理。

function useCancellable(asyncTask) {
    const controllerRef = useRef(null);

    useEffect(() => {
        const controller = new AbortController();
        controllerRef.current = controller;

        asyncTask(controller.signal).catch(e => {
            if (e.name !== 'AbortError') {
                console.error(e);
            }
        });

        return () => controller.abort();
    }, []);

    const cancel = () => controllerRef.current?.abort();
    return cancel;
}

这里asyncTask必须完全接受signal并处理取消,比如我们的fetchWithTimeout就可以直接传进去。如果是一个自定义的setTimeout流程,你也要在函数里监听signal。

九、值得注意的细节

  • 不要吞掉AbortError。如果你在catch里吞掉所有错误并且不重新抛出,会导致调用方不知道任务已经取消,可能继续执行后续逻辑。
  • 清理函数里不要再调用会引起异步的操作,因为取消过程本身是同步的。
  • 如果一个Promise已经resolve,之后调用abort是无效的,但为了安全,我们的实现里通过isDone避免了重复调用reject。
  • 在Node.js里,AbortController是全局可用的,不需要require。旧环境可能需要polyfill,但2025年了基本没问题。

十、总结

看了这么多,其实可取消Promise并不神奇,它只是利用了AbortSignal这个标准信号,把“取消”这个动作从源头传递下去。有了这个工具,你再也不用担心那种“用户都走了回调还在跑”的问题。

我现在每个项目里都会放一个几十行的cancelable.js,专门用来处理那些不支持signal的异步API。它虽然不能解决所有问题,但至少让我心里有底,知道取消不是靠document.hidden来判断,而是真的把底层资源释放掉。

如果你也受够了那种不可控的异步流程,不妨写上这么一个小函数,然后仔细想想你的业务里哪些操作需要被取消。你会发现,取消逻辑一旦理顺,界面响应那种“拖泥带水”的感觉会消失很多。

手写一个可取消Promise任务流,告别resolved之后才后悔的异步操作
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript 手写一个可取消Promise任务流,告别resolved之后才后悔的异步操作 https://www.taomawang.com/web/javascript/2499.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务