这两年我看过市面上几乎所有 Promise 教程,但始终是一知半解。直到我搞清楚了浏览器多线程、事件循环和微任务,才迎来真正的顿悟。
前言:那些年我踩过的坑
Promise 的重要性不言而喻,可就是学不会。我看过阮一峰老师的教程,刷过各种视频,甚至能背出"链式调用、解决回调地狱"这些概念,但一碰到执行顺序的题目就懵。
最大的误解是什么?我以前一直以为"异步"就是 JS单线程同时处理多件事情,好像它能一边倒计时一边执行下面的代码。这种错误认知让我在面对 setTimeout 和 Promise 混用的代码时永远答不对。
直到我读了一篇文章 —— 《从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理》,配合阮一峰的 Promise 教程重新学习,我才真正开了窍。
下面我把这次"顿悟"的过程完整梳理出来,希望能帮助那些和我一样曾经痛苦挣扎的同学。
一、核心突破:理解"单线程异步"的真正含义
误解 vs 真相
| 我曾经的误解 | 真正的机制 |
|---|---|
| JS 自己一边倒计时一边执行后面的代码 | JS 把定时任务交给浏览器的定时器线程去处理 |
| 异步就是单线程来回切换干不同的事 | 异步是发布订阅模式:订阅事件 → 别的线程执行 → 完成后把回调放回任务队列 |
| Promise 构造函数里的代码也是异步的 | Promise 构造函数内的代码同步执行,只有 .then 回调才是微任务 |
核心记忆:JavaScript 引擎是单线程的,但浏览器是多线程的。
当 JS 遇到 setTimeout(cb, 1000) 时,它不会自己倒计时。它会说:“定时器线程,请你在 1 秒后把这个 cb 函数放回任务队列。“然后自己立刻继续执行后续同步代码。定时器线程倒计时结束后,把 cb 塞进宏任务队列,等待主线程空闲时通过事件循环(Event Loop)取走执行。
这就是你理解的 “Promise 中去使用 event loop 以外的线程,然后发布订阅模式” —— 完全正确。
二、事件循环(Event Loop)与任务队列
一旦理解了上面的外包机制,事件循环就变得非常直观。
1. 两类任务
- 宏任务(MacroTask):整体
script代码、setTimeout、setInterval、I/O、UI 渲染。 - 微任务(MicroTask):
Promise.then()、Promise.catch()、MutationObserver。
2. Event Loop 执行顺序
- 执行一个宏任务(最开始就是整个
script代码)。 - 执行过程中产生的所有微任务,放入微任务队列。
- 当前宏任务执行完毕后,立刻清空整个微任务队列。
- 进行必要的 UI 渲染。
- 从宏任务队列中取出下一个宏任务,循环往复。
关键点:微任务的优先级永远高于宏任务。先清空微任务,再取下一个宏任务。
3. 一个验证你理解的例子
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
输出顺序:1 4 3 2
执行过程:
- 输出
1(同步代码) setTimeout交给定时器线程,0ms 后将回调放入宏任务队列Promise.then的回调放入微任务队列- 输出
4(同步代码) - 当前宏任务(script)结束,清空微任务队列 → 输出
3 - 取出下一个宏任务(setTimeout 回调)→ 输出
2
如果你之前答错,现在应该能轻松答对了。
三、从 Event Loop 重新理解 Promise
1. Promise 构造函数是同步执行的
很多初学者以为 new Promise 里的代码是异步的,其实大错特错。
console.log('start');
new Promise((resolve) => {
console.log('promise executor');
resolve();
console.log('after resolve');
}).then(() => {
console.log('then callback');
});
console.log('end');
输出:
start
promise executor
after resolve
end
then callback
可以看到,resolve() 后面的 console.log('after resolve') 依然同步执行。.then 的回调才是真正的异步(微任务)。
2. 为什么 Promise 可以处理异步操作?
当我们在 Promise 里放置一个 setTimeout 或 AJAX 请求时,真正的异步操作是由浏览器其他线程完成的。Promise 只是提供了一个容器,让我们可以优雅地订阅成功/失败的回调。
new Promise((resolve) => {
setTimeout(() => {
resolve('done');
}, 1000);
}).then((value) => {
console.log(value);
});
setTimeout被交给定时器线程- JS 主线程继续往下执行
- 1 秒后定时器线程将
resolve的执行放入任务队列 - 执行
resolve,触发.then回调(微任务)
这就是你所说的 “Promise 本质是使用 event loop 以外的线程 + 发布订阅模式”。
四、Promise vs async/await:核心区别
你观察到的区别非常敏锐:
“async/await 函数体内 await 之后的函数都是异步,但 Promise 异步函数后面的函数还会同步执行。”
我们来用代码说清楚。
1. Promise 版本
function promiseDemo() {
console.log('A');
Promise.resolve().then(() => {
console.log('B');
});
console.log('C');
}
promiseDemo(); // 输出:A C B
A和C是同步输出B是.then微任务,在同步代码执行完毕后输出
2. async/await 版本
async function asyncDemo() {
console.log('A');
await Promise.resolve();
console.log('B');
console.log('C');
}
asyncDemo();
console.log('D');
输出:A D B C
执行过程:
- 调用
asyncDemo(),同步执行console.log('A') - 遇到
await,立即暂停当前 async 函数,await后面的代码被包装成微任务 - JS 引擎跳出 async 函数,执行外面的同步代码
console.log('D') - 同步代码执行完毕,执行微任务队列中的内容,恢复 async 函数执行
B C
所以你的总结完全正确:await 之后的代码全部变为异步(微任务)执行,而 Promise 构造函数内的代码(包括 resolve 之后的代码)依然是同步执行。
3. 对比表格
| 比较项 | Promise | async/await |
|---|---|---|
| 构造函数/await前的代码 | 同步执行 | 同步执行 |
| 异步操作后的代码 | .then 回调是微任务 | await 后面所有代码都是微任务 |
| 实现关系 | 基础 API | Promise 的语法糖 |
| 错误处理 | .catch() | try/catch |
| 阅读体验 | 链式调用 | 近似同步,更直观 |
五、我的学习路径总结(供参考)
如果你还在混沌中,可以按我走过的路来一遍:
先理解浏览器多线程模型 明白 JS 引擎线程、定时器线程、HTTP 线程是分开的。异步本质是"外包 + 回调队列”。推荐阅读:从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
掌握事件循环(Event Loop)和宏任务/微任务 这是分析一切异步代码的底层工具。多画流程图,手动执行几道经典题。
重读 Promise 教程(推荐阮一峰 ES6 入门) 在理解了事件循环的基础上,你会发现 Promise 的所有行为都变得合理可预测。
刻意练习 找 10 道混合
setTimeout、Promise.then、async/await的输出顺序题,手写答案并验证。这个过程能帮你彻底固化理解。
补充:两个被你验证过的重要认知
随着学习的深入,有两个结论值得单独提炼出来。它们能帮你建立更稳定的心智模型,也能在日常开发中减少不必要的纠结。
1. 默认同步原则:除非主动写异步,否则所有代码都是同步的
我们书写的绝大多数代码,都运行在 JS 主线程上,并且是同步执行的。 不会出现"JS 自动把某段代码变成异步"这种魔法。
// 下面的每一行都是同步执行,从上到下,一行一行完成
let a = 1;
let b = a + 2;
console.log(b); // 立即输出 3
function add(x, y) {
return x + y;
}
let c = add(5, 6); // 同步调用,立即得到结果
console.log(c); // 立即输出 11
只有在主动调用特定 API 时,才会产生异步行为:
- 定时器:
setTimeout、setInterval - 网络请求:
fetch、XMLHttpRequest - Promise 相关:
.then/.catch/.finally、async/await - 事件监听:
addEventListener(回调是宏任务) - Node.js 中的文件操作:
fs.readFile、fs.promises.readFile等
一句话记住:
正常写代码 = 同步;主动调异步 API = 异步。
2. 按需异步原则:分清微任务/宏任务不是必须的,只在必要时才深究
很多初学者被"微任务、宏任务、事件循环"吓到,以为写每一行代码都要先分析一下。其实完全不必。
日常业务开发中,你只需要记住:
Promise.then/catch/finally以及await后面的代码,会在当前同步代码执行完后尽快执行(比setTimeout(fn,0)快)。setTimeout、setInterval、事件回调、HTTP 回调等,会稍后执行(需要排队,等微任务清空)。
你不需要对所有 API 做精确分类,也无需时刻思考事件循环。只要代码按你预期的顺序工作,就放心写。
什么时候需要深入区分微任务/宏任务?
- 代码执行顺序不符合预期(例如
setTimeout的回调比一个 Promise.then先跑了)。 - 面试或刷题中被问到"输出顺序”。
- 你在写一个复杂的状态机或协调多个异步操作的库。
实用建议:
- 当你需要一个异步操作时,直接用
async/await(它基于 Promise,行为更直觉)。 - 如果你想知道"这段异步代码什么时候执行",就记住一条:微任务(Promise.then/await 后续)总是在当前同步代码结束后、下一个宏任务(setTimeout 等)之前执行。 这就覆盖了 95% 的场景。
写在最后
Promise 并不难,难的是我们一开始用了错误的心智模型去理解它。一旦建立起"浏览器多线程 + 事件循环 + 微任务/宏任务"的正确框架,Promise 和 async/await 就变成了自然而然的结果。
希望这篇文章能帮你少走一些弯路。如果你有疑问或不同见解,欢迎留言讨论。
“所有异步操作本质是 Promise 中去使用 event loop 以外的线程,然后发布订阅模式。” —— 这句话,是我从看了两年教程到真正理解的那一刻,脑海里蹦出的总结。也送给你。