最近我在研究 JavaScript 的异步编程时,碰到两个挺有意思的问题。
- 第一个问题是:一个 Promise 一旦创建了,任务跑到一半,我后悔了,能不能把它取消?
- 第二个问题是:一个异步任务要执行很久,我总不能让用户盯着一个“加载中”干等吧?能不能告诉他,现在已经执行到 30%、50%、80% 了?
这两个问题看起来简单,其实正好戳中了 JavaScript 期约设计里的两个重要边界:Promise 擅长表达“最终结果”,却不负责表达“取消”和“过程”。
如果把一个异步任务比作点外卖,那么 Promise 就像外卖平台给你的订单状态通知:“已接单”“配送完成”“配送失败”。但问题来了,骑手已经出发了,你突然说:“我不要了,能不能让骑手马上回去?”或者你点了一份特别复杂的大餐,厨房要做十分钟,你希望平台告诉你:“现在做到 30% 了,再等五分钟。”
这时候,单纯的 Promise 就有点不够用了。今天我们就沿着这两个问题,把 JavaScript 的期约扩展能力彻底聊明白。
先搞清楚:Promise 到底解决了什么问题?
在 JavaScript 早期,处理异步任务并不是一件特别优雅的事情。回调套回调,代码很容易变成“回调地狱”。后来 Promise 出现了,我们可以把异步任务包装成一个 Promise:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("任务完成");
}, 3000);
});
promise.then(result => {
console.log(result);
}).catch(error => {
console.error(error);
});
它非常漂亮。
- 任务成功了,调用 resolve()。
- 任务失败了,调用 reject()。
消费者通过 then()、catch()、finally() 处理最终结果。于是问题来了:Promise 能不能取消?
答案是 Promise 本身没有提供取消机制。这句话非常重要,很多刚接触 Promise 的同学会产生一个误区:“既然 Promise 是一个异步任务,那我把 Promise 设置成 rejected,不就相当于取消了吗?”
还真不是。
比如:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
console.log("任务仍然执行了");
resolve("完成");
}, 5000);
});
promise.catch(error => {
console.log(error);
});
你无法通过 promise.reject() 这种方式把它取消,因为 Promise 的本质是一个状态容器,而不是一个“任务控制器”。
它主要描述的是:这个异步操作最终是成功、失败,还是仍然处于等待状态。而不是这个异步操作现在要不要继续执行。这两个概念一定要分开。
为什么 Promise 不能直接取消?
我们来看一个非常生活化的例子。假设我去餐厅点了一份牛肉面,服务员给了我一张取餐单,这张单子上面最终只有三种结果:
| Promise 状态 |
牛肉面订单 |
| pending |
厨房正在做 |
| fulfilled |
牛肉面做好了 |
| rejected |
厨房说做不了了 |
Promise 就像这张订单,它负责告诉我最后到底发生了什么。但是,如果我突然说“我不吃了”,这个动作实际上是在控制厨房的生产过程,订单本身并没有办法直接让厨师停下来。这就是 Promise 和取消机制之间的区别。
- Promise 是:结果通知机制。
- 取消机制是:任务控制机制。
所以,如果我们想取消异步操作,就需要额外设计一个“控制器”。
现代 JavaScript 最常见的解决方案,就是 AbortController。
AbortController:给异步任务装一个刹车
AbortController 可以理解成异步任务的“刹车”。最典型的场景就是取消网络请求,例如:
const controller = new AbortController();
fetch("/api/user", {
signal: controller.signal
})
.then(response => response.json())
.then(data => {
console.log(data);
})
.catch(error => {
console.log("请求结束:", error.name);
});
setTimeout(() => {
controller.abort();
}, 1000);
这里出现了三个角色:
AbortController
↓
signal
↓
fetch()
- AbortController 负责发出“取消”命令。
- signal 负责把这个命令传递给异步任务。
- fetch() 负责监听这个信号。
当我们调用 controller.abort(),就相当于对任务说:“别做了,赶紧停。”
如果请求支持 AbortSignal,那么它就可以响应取消,例如:
async function requestData() {
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 1000);
try {
const response = await fetch("/api/data", {
signal: controller.signal
});
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
console.log("请求被取消");
} else {
console.log("请求失败");
}
}
}
这里有一个非常值得面试时说出来的细节:AbortController 不是把 Promise 从世界上“删除”了。它真正做的是给底层异步操作发送取消信号,最终 Promise 可能以拒绝结束。
所以,取消异步操作 ≠ 取消 Promise。更准确地说,是通过取消信号,让异步操作停止,并让相关 Promise 以取消导致的异常结束。
真正重要的问题:并不是所有异步任务都能被取消
这点非常容易被忽略。假设我们自己写了一个异步函数:
function doSomething() {
return new Promise(resolve => {
setTimeout(() => {
console.log("任务执行完成");
resolve();
}, 5000);
});
}
你调用 const promise = doSomething(),这时候即使你不再使用 promise,里面的 setTimeout() 依然会继续执行。这就是一个关键概念:Promise 的消费者停止等待,不代表生产者停止工作。
比如:
const promise = doSomething();
setTimeout(() => {
// 我不关心 promise 了
}, 1000);
这里最多只能说“我不再关心这个结果”,并不能说“任务已经停止”。所以,如果我们自己设计一个可取消任务,就应该显式支持 AbortSignal。
例如:
function delay(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
resolve("完成");
}, ms);
if (signal) {
signal.addEventListener("abort", () => {
clearTimeout(timer);
reject(new Error("任务取消"));
});
}
});
}
调用:
const controller = new AbortController();
delay(5000, controller.signal)
.then(() => {
console.log("完成");
})
.catch(error => {
console.log(error.message);
});
setTimeout(() => {
controller.abort();
}, 1000);
这时候,任务真正实现了“取消”。为什么?因为我们不仅发出了取消信号,还在任务内部真正响应了这个信号。这才是完整的取消机制。
第二个问题:Promise 为什么不能通知进度?
取消解决了,接下来我们再看第二个问题。假设现在有一个文件上传任务。
用户点击上传:
- 然后页面上显示:正在上传……
- 一分钟过去了,还是:正在上传……
- 五分钟过去了,还是:正在上传……
这时候用户心里只有一个问题:到底上传到哪里了?
Promise 在这里又遇到了一个天然限制。Promise 通常只能告诉你:
pending
↓
fulfilled / rejected
它擅长表达最终结果,但是它没有一个标准的 30%、50%、80% 这样的“中间状态”。
Promise 像终点通知,进度像沿途路标
这个区别特别好理解。假设我要从北京开车去上海,Promise 就像导航最后告诉我:“到了。”或者“没到。”
但如果我要知道“现在到哪儿了?”,那就需要另一套机制。所以我们可以把异步系统拆成两个维度:
| 能力 |
解决的问题 |
| Promise |
最终结果是什么 |
| AbortSignal |
任务要不要继续 |
| Progress |
当前执行到哪里 |
| Event |
中间发生了什么 |
| Async Iterator |
持续产生数据 |
这其实就是现代异步编程非常重要的一种思想:不要让一个抽象承担所有职责。Promise 不需要强行变成万能异步对象。
最经典的方案:回调通知进度
如果我们自己实现一个任务,可以增加一个 onProgress 参数,比如:
function downloadFile(onProgress) {
let progress = 0;
return new Promise(resolve => {
const timer = setInterval(() => {
progress += 10;
if (onProgress) {
onProgress(progress);
}
if (progress >= 100) {
clearInterval(timer);
resolve("下载完成");
}
}, 500);
});
}
调用:
downloadFile(progress => {
console.log('当前进度:${progress}%');
}).then(result => {
console.log(result);
});
输出可能是:
- 当前进度:10%
- 当前进度:20%
- 当前进度:30%
- 当前进度:40% ……
- 当前进度:100%
- 下载完成
这就非常舒服了。
- Promise 负责:最后告诉我任务成功还是失败。
- onProgress 负责:不断告诉我任务现在走到哪里了。
两者各司其职。
AbortController + Progress:取消和进度一起解决
真正实用的异步任务,往往既需要取消,也需要进度。我们可以把它们组合起来。
function longTask({ signal, onProgress }) {
return new Promise((resolve, reject) => {
let progress = 0;
const timer = setInterval(() => {
progress += 10;
onProgress?.(progress);
if (progress >= 100) {
clearInterval(timer);
resolve("任务完成");
}
}, 500);
signal?.addEventListener("abort", () => {
clearInterval(timer);
reject(new Error("任务已取消"));
});
});
}
使用:
const controller = new AbortController();
longTask({
signal: controller.signal,
onProgress(progress) {
console.log(`任务进度: ${progress}%`);
}
})
.then(result => {
console.log(result);
})
.catch(error => {
console.log(error.message);
});
setTimeout(() => {
controller.abort();
}, 1800);
执行过程中可能出现:
- 任务进度:10%
- 任务进度:20%
- 任务进度:30%
- 任务已取消
这时候,一个比较完整的异步任务模型就出现了:
异步任务
├── Progress:当前进度
├── AbortSignal:是否取消
└── Promise:最终成功 / 失败
这才是比较成熟的设计思路。
再进一步:用事件实现进度通知
如果一个任务需要通知的不只是进度,而是很多种状态,那么单纯传一个 onProgress 回调可能就不够优雅。这时候可以使用事件,例如:
class Task extends EventTarget {
start() {
let progress = 0;
const timer = setInterval(() => {
progress += 20;
this.dispatchEvent(
new CustomEvent("progress", { detail: progress })
);
if (progress >= 100) {
clearInterval(timer);
this.dispatchEvent(new Event("complete"));
}
}, 500);
}
}
使用:
const task = new Task();
task.addEventListener("progress", event => {
console.log('进度: ${event.detail}%');
});
task.addEventListener("complete", () => {
console.log("任务完成");
});
task.start();
这种方式最大的好处是:生产者和消费者之间的耦合更低。任务只负责“发生了 progress 事件”,至于谁监听、谁处理,由消费者决定。
还有一个更现代的思路:Async Iterator
如果任务不是简单的“0% 到 100%”,而是会持续产生很多数据,那么 Async Iterator 也非常有价值。例如:
async function* progressTask() {
for (let i = 10; i <= 100; i += 10) {
await new Promise(resolve => setTimeout(resolve, 500));
yield i;
}
}
使用:
for await (const progress of progressTask()) {
console.log('当前进度: ${progress}%');
}
它的思维方式和 Promise 不一样。
- Promise 是:给我一个最终结果。
- Async Iterator 是:以后会不断给我结果,一个接一个。
所以,如果说 Promise 是“最终成绩单”,那么 Async Iterator 就更像“直播比分”。
期约扩展的核心,其实是三个问题
讲到这里,我们可以把今天的内容浓缩成三个问题。
第一:最终结果是什么?用 Promise。
第二:任务还要不要继续?用 AbortController / AbortSignal。
第三:任务现在进行到哪里了?用 Progress、事件或者 Async Iterator。
这三个问题不要混在一起。
| 问题 |
推荐机制 |
核心职责 |
| 最终结果 |
Promise |
成功、失败 |
| 取消任务 |
AbortController |
发出取消信号 |
| 响应取消 |
AbortSignal |
监听取消 |
| 进度通知 |
回调 / Event |
通知执行进度 |
| 持续产生数据 |
Async Iterator |
持续消费异步数据 |
如果你在面试中被问:“Promise 怎么取消?”
千万不要直接回答:“Promise 可以取消。”
更准确的回答应该是:
Promise 本身没有原生取消机制。如果底层异步操作支持取消,可以通过 AbortController 和 AbortSignal 协作实现取消。对于自定义任务,则需要在任务内部主动监听 AbortSignal,并执行真正的资源清理。
如果继续追问:“Promise 怎么通知进度?”
可以回答:
Promise 本身主要描述最终状态,不提供通用的进度通知机制。可以通过回调、事件、Async Iterator 等机制额外传递中间进度。
这两个回答,基本就把 JavaScript 期约的设计边界说清楚了。
最后再理解一次:Promise 不是万能胶
以前我刚学习 Promise 的时候,总觉得 Promise 好像什么都能干。后来才发现,Promise 更像一个非常专业的“快递单”,它特别擅长告诉你:快递最后到了没有?
但你让它负责“快递员现在在哪里”,它就不太擅长了。
你再问“快递员能不能取消配送”,这又是另外一套机制。
所以现代 JavaScript 的异步编程,并不是不断给 Promise 塞功能,而是在不同抽象之间进行组合:
- Promise 管最终结果,
- AbortSignal 管取消,
- 事件或回调管进度,
- Async Iterator 管持续的数据流。
这其实也是 JavaScript 异步设计越来越成熟的一个表现。
我们写异步代码的时候,真正应该思考的不是“Promise 还能不能继续加功能?”,而是“我的异步任务到底需要表达什么信息?”
- 是最终结果?
- 是取消?
- 是进度?
- 还是连续不断的数据?
把问题拆开,方案反而会变得简单。
好了,这一期关于 JavaScript 期约扩展就聊到这里。如果下一次面试官问你:“Promise 能取消吗?能不能报告任务进度?”
别急着背 API,先想想今天那个外卖的故事:
- Promise 是订单结果,
- AbortController 是取消按钮,
- 而 Progress 是一路上的导航路标。
三者各干各的活,整个异步系统,反而更稳、更清晰。更多 JavaScript 异步编程相关内容,也欢迎到 云栈社区 一起交流。