你有没有过这种经历:用户点了一个“加载数据”按钮,然后立刻又点了“取消”,结果数据还是加载出来了,界面突然多了一块根本不该出现的内容。又或者你发了个请求,但用户已经离开当前页面,请求回来以后还在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来判断,而是真的把底层资源释放掉。
如果你也受够了那种不可控的异步流程,不妨写上这么一个小函数,然后仔细想想你的业务里哪些操作需要被取消。你会发现,取消逻辑一旦理顺,界面响应那种“拖泥带水”的感觉会消失很多。

