JS 异步的本质:从单线程到 thenable(一条进化链)
async/await、微任务、Promise、thenable......这些东西不是一堆孤立的概念,而是一条进化链:每一层都是为了解决上一层留下的具体问题。顺着链条看,就不乱了。
一、一切的起点:JS 是单线程,又不能"干等"
JS 只有一个执行线程。这就产生一个根本矛盾:
- 很多事要等(网络请求、定时器、文件读写);
- 但如果"等" = 卡住线程,整个页面就冻死了。
所以 JS 的规定是:同步代码一口气跑完(run-to-completion),"等"这件事,全部外包给队列。线程空下来时,引擎去队列里掏下一个活。这就是事件循环。队列分两层:
- 宏任务------代表:setTimeout、setInterval、I/O 回调、UI 事件
- 微任务------代表:Promise.then / catch / finally 的回调、queueMicrotask、await 的恢复
「宏:每轮掏一个」:一轮事件循环,最多只跑 1 个宏任务,不管宏队列里还排着多少,本轮不再拿新的,剩下的留到下一轮。
「微:同步栈一空就清空」:更本质的规则是------只要 JS 执行栈清空,引擎立刻把微任务队列全部跑完,哪怕跑微任务时又产生了新微任务,也当场跑完,直到微队列为空。宏任务跑完只是最常见的一种"栈清空"时刻(顶层脚本本身就是一个任务,它跑完也会触发清微)。
「总结」:拿 1 个宏 → 跑完它的同步代码 → 把所有微任务全部干完 → 再拿下一个宏。
注意:页面渲染不是宏任务。它是浏览器在"宏任务 + 清空微任务"之后视情况执行的独立渲染步骤,不在任务队列里。
顺便说清楚"执行栈"是什么:它不是另一个线程,而是那根主线程手上"正在干的活"的清单 。函数调用会一层层叠上去------a() 调 b(),b() 调 c(),栈就是 [a, b, c];函数 return 就撤掉一层。"栈清空" = 手头这段代码彻底跑完、线程闲下来了。只有这一刻,引擎才会去看队列。
js
setTimeout(() => {
console.log("宏A开始")
setTimeout(() => console.log("宏C,新生成的宏"), 0) // 排进宏队列尾部
Promise.resolve().then(() => console.log("微1")) // 排进微队列
console.log("宏A结束")
}, 0)
setTimeout(() => console.log("宏B"), 0)
输出:
javascript
宏A开始
宏A结束
微1
宏B
宏C,新生成的宏
走一遍:宏 A 跑完同步代码 → 清空微队列(微 1)→ 拿下一个宏(宏 B,它在宏 C 之前就排好队了)→ 清微(没有)→ 宏 C。
二、Promise 是什么:一台状态机
异步操作没法立刻给出结果,所以 JS 先发一个占位对象------Promise。它的全部内容就是一台状态机:
javascript
pending(进行中)──▶ fulfilled(成功了,带着结果)
──▶ rejected(失败了,带着原因)
规则只有一条:状态最多跳一次,跳完就定型,永远不再变。
看个例子
js
const p = new Promise(resolve => {
resolve("111") // 状态变化
resolve("222") // 状态已变化,不会再变了
})
const p1 = p.then(v => console.log(v)) //输出 111
p.then(v => console.log(v)) // 输出 111,状态固定,不会发生变化了
p1.then(v => console.log(v)) // 输出 undefined,因为 const p1 = p.then(v => console.log(v)) 并没有返回值,如果是 const p1 = p.then(v => {console.log(v);return 333}) 可以输出 333
111
111
undefined
2.1 谁来让状态跳变:resolve 和 reject
创建一个 Promise 时,要传一个函数进去(官方名字叫 executor)。这个函数会收到两个参数:resolve 和 reject。注意,这两个参数不是你自己准备的,是引擎塞给你的两个函数:
- 事情办成了 → 你调用
resolve(结果),状态从 pending 跳到 fulfilled; - 事情办砸了 → 你调用
reject(原因),状态从 pending 跳到 rejected。
js
const p = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("数据拿到了") // 1 秒后:pending → fulfilled("数据拿到了")
}, 1000)
})
读法:「我要办一件异步的事(1 秒的定时器)。事成之后,我调 resolve,把结果交出去。」
关于 executor,有三个反直觉的事实,一段代码看全:
js
const p = new Promise((resolve, reject) => {
console.log("1. executor 立刻执行") // 它是同步的,不等待任何事
resolve("第一次")
resolve("第二次") // 无效:状态只跳一次
console.log("2. resolve 之后的代码照常跑") // resolve 不是 return
})
console.log("3. 外层代码")
输出顺序是 1 → 3 → 2,说明三件事:
- executor 同步执行 (new 完立刻跑,所以
1在3前面); resolve只负责改变状态,不会结束函数 ,想停要自己写return(所以2照样打印);- 第一次
resolve之后状态已定型,之后再调直接被忽略。
三、then:拿到结果的入口
状态跳变了,外面的人怎么知道?靠 .then()。它的作用一句话:"等你好了,调用我这个函数"。
js
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve("数据拿到了"), 1000)
})
p.then(result => {
console.log("拿到结果:", result) // 1 秒后打印
})
then 其实可以收两个函数:第一个在 fulfilled 时调用,第二个在 rejected 时调用。平时写的 .catch(fn) 就是"只传第二个"的简写。
到这里,Promise 的两半就齐了:new Promise 是干活的人用的(调 resolve 交出结果),.then 是等结果的人用的(登记回调、拿走结果)。两边互不认识,只靠这台状态机传话------这就是 Promise 存在的意义。
3.1 then 的回调什么时候执行:永远是微任务
这是最容易踩坑的一条规则:then 的回调永远异步执行,绝不插队进同步代码。分两种情况看。
情况 1:先 then,后 resolve(正常顺序)。 resolve 被调用的瞬间,回调不会立刻跑,而是被排进微任务队列:
js
const p = new Promise(resolve => {
console.log("executor:准备 resolve")
resolve("好了")
console.log("executor:resolve 完了")
})
p.then(v => console.log("then 回调:", v))
输出:
javascript
executor:准备 resolve
executor:resolve 完了
then 回调: 好了
注意中间那行:resolve 已经调完了,回调却还在排队,等当前这段同步代码全部跑完才轮到它。
情况 2:Promise 早就好了,再 then(补登记)。 结果明明已经在那儿了,回调仍然不同步跑,照样排进微队列:
js
const p = Promise.resolve(42) // 出生就是 fulfilled
p.then(v => console.log("回调:", v))
console.log("同步代码")
输出是先"同步代码",后"回调: 42"。
为什么非要这样?因为这样回调的执行时机永远确定:不管 Promise 当前是什么状态,你都只需记住"回调在微任务里跑"。如果允许同步执行,同一段代码就会"有时同步、有时异步",那才是噩梦。
3.2 then 的返回值:一个新的 Promise
then 不只是登记,它每次调用都会造出一个新的 Promise 返回给你:
js
const p1 = Promise.resolve(1)
const p2 = p1.then(v => v + 1)
console.log(p1 === p2) // false ------ p2 是全新的 Promise
那 p2 什么时候好、结果是什么?由你在回调里做的事决定:
| 回调里做了什么 | p2 的结局 |
|---|---|
return 一个普通值 |
fulfilled(那个值) |
throw 抛错 |
rejected(那个错) |
return 另一个 Promise |
跟着那个 Promise 走,同生死 |
链式调用就是这么来的:每个 then 造一个新 Promise,下一个 then 登记在新 Promise 上,一环扣一环:
js
fetch("/api/user")
.then(res => res.json()) // 返回 Promise → 下一环等它好了再继续
.then(user => user.name) // 返回普通值 → 下一环直接拿到
.then(name => console.log(name))
.catch(err => console.error(err)) // 任何一环出错都会落到这里
四、async/await 的本质:Promise 的语法糖,动作是"登记 + 撒手"
遇到 await 时,JS 引擎做三件事:
- 暂停当前 async 函数:await 这一行之后的代码,暂时停止执行;
- 把"await 后面所有剩余代码"打包成一个续体 (continuation),登记到被等的东西上------等价于一个
.then()回调; - 直接退出当前 async 函数,返回一个 pending 状态的 Promise,执行权交还调用方(撒手)。
这是等价的心智模型,不是实现细节。严格说:被等的东西会先经过"Promise 同化"(见第六节),而且引擎恢复的是整个 async 函数的执行现场,不是真的把你的代码包成一个回调函数。
js
async function test() {
console.log("A")
await Promise.resolve() // 在这里撒手
console.log("C") // 这一句变成了微任务
}
test()
console.log("B") // test 已撒手,这行在微任务之前跑
输出:A → B → C。
关键点:调用 async 函数 ≠ 等它跑完 。它只是启动,同步跑到第一个 await 就撒手。想让调用方也等,调用方自己也得 await。
4.1 对照实验:await 之后的代码,就是 then 的回调
同一件事,两种写法,时序一模一样:
js
// 写法一:then ------ 回调排进微队列
const p1 = Promise.resolve(1)
p1.then(v => {
console.log("后执行")
return v + 1
})
console.log("先执行")
js
// 写法二:await ------ 后续代码同样排进微队列
// (await 只能写在 async 函数或模块顶层,这里假设在模块顶层)
const fakePromise = {
then(resolve) {
console.log("111") // 微任务:第一个进队
resolve(42)
},
}
const v = await fakePromise
console.log("222") // await 之后的代码 → 微任务:第二个进队
输出分别是 先执行 → 后执行 和 111 → 222。
这里要纠正一个常见的错误猜想:await 不是 "中断主线程、马上执行微任务"。它做的是"把后续代码挂进微队列,然后自己让开"------微任务还是要等同步栈清空才跑。所以第二段里,then 先进队(第一个),await 的续体后进队(第二个),按顺序出队,就是 111 → 222。
await默认只能在async函数内部使用。ES Module 支持顶层 await(浏览器type="module"/ Node ESM),普通全局脚本不行。TS 里顶层 await 需要module: es2022 / esnext / nodenext,CommonJS 模式下会报 TS1378。
五、thenable:为什么 await 认的是"then 方法"而不是"Promise 类"
先下一个死定义:thenable = 任何"身上有个 then 方法"的对象。就这么简单,没有别的要求。唯一的约定是:这个 then 要长得像 Promise 的 then------收两个函数当参数,事情办成了调第一个(把结果传给它),办砸了调第二个(把错误传给它)。
那 await 为什么敢认这种"野路子"对象?答案是个历史 + 工程决策。Promise 不是凭空被规范发明的:ES6 之前,社区已经有 Bluebird、Q、jQuery Deferred 等一堆实现。规范制定时面临一个选择:
- 方案 A :
await x只认"原生 Promise 的实例" → 所有第三方实现全部报废,生态割裂; - 方案 B (实际采用):
await x只问一句"你身上有没有一个叫 then 的函数? "有就当 Promise 用。这叫鸭子类型------走起来像鸭子、叫起来像鸭子,那它就是鸭子。
方案 B 让任何库的 Promise 都能和原生 await 无缝互通。代价就是有"怪现象":一个裸对象也能被 await:
js
const fakePromise = {
then(resolve) {
console.log("我的 then 被 await 调用了")
resolve(42)
},
}
console.log(fakePromise instanceof Promise) // false ------ 它不是 Promise!
const v = await fakePromise
console.log(v) // 42 ------ 但它确实能 await
这不是彩蛋,是规范的核心互操作机制。
5.1 await 是怎么"等"一个 thenable 的
await x(x 是一个 thenable)按下述步骤走,一步都不多:
- 引擎执行到
await x,检查 x:身上有then方法 → 按 thenable 处理; - 引擎自己造好两个函数 (就叫它们 resolve、reject),然后安排一个微任务,内容是"调用
x.then(resolve, reject)"; - 轮到那个微任务执行:x 的 then 被调用,收到了引擎递来的两个函数。x 是干活的人,事情办完时,它调一下
resolve(结果); - 引擎发现自己造的 resolve 被调了 → 知道"x 好了,结果是这个" → 把"恢复执行 await 后面的代码"排进微队列;
- await 恢复,拿到结果。
关键看第 2、3 步------方向是反的:不是 thenable 主动通知 await,而是 await 先把自己的"联系方式"(那两个函数)递给了 thenable。所以 thenable 的 then 收到的那两个参数,不是 thenable 自己的,是引擎造好塞进去的。
第 2 步里 x.then(resolve, reject) 这个写法,你可能觉得眼熟又别扭。其实它就是"把两个函数当参数传给 then "------和你早已熟悉的 p.then(成功回调, 失败回调) 是同一个写法,只不过这次传进去的回调,恰好是引擎造的那对函数。
题外话(看不懂可以跳过):引擎造的那对函数是哪来的?内部其实是先造了一个新 Promise,把新 Promise 的 resolve/reject 拿出来递给了 x。所以有种等价写法:
js// x 是 thenable 时,await x 的等价理解: await new Promise((resolve, reject) => { x.then(resolve, reject) })它和 2.1 是同一个套路:2.1 里是 executor 收到引擎塞进来的 resolve/reject;这次换成 thenable 的 then 收到引擎塞进来的 resolve/reject。
为什么不能反过来,让 await 去"问"thenable 好了没?因为 thenable 才是干活的人,只有它知道事情什么时候办完;而且单线程里"反复问"会一直占着线程,thenable 反而永远没机会干活。所以规则是:等的人留下联系方式,干活的人办完事打电话。
最后澄清一个名词:PromiseResolveThenableJob 不是 thenable 本身,它是规范给上面第 2 步那个微任务起的名字(直译:"处理 thenable 的任务")。后面说"await thenable 比 await 真 Promise 多跳一拍微任务",多的就是这一拍。
六、Promise.resolve:万能转换器
Promise.resolve(x) 的职责是"把任何东西统一包成 Promise":
| 传入 | 结果 |
|---|---|
| 真 Promise | 原样返回(不会包第二层) |
| thenable | 造一个壳 Promise 去吸收它 |
普通值 42 |
等价于 new Promise(r => r(42)) |
两个细节:
- 它其实就是 await 内部逻辑的手动版 ------await 任何东西之前,都先做这一步"同化",所以
await 42、await thenable、await promise走的是同一条规范路径。 - 吸收 thenable 时不会同步调它的 then:
js
const probe = { then: () => console.log("probe.then 被调") }
console.log("调用 Promise.resolve 之前")
Promise.resolve(probe)
console.log("调用 Promise.resolve 之后")
输出顺序:之前 → 之后 → probe.then 被调。then 是在微任务 里才被调用的------因为"吸收 thenable"这个活本身就是一个微任务(就是 5.1 第 2 步那个 PromiseResolveThenableJob)。这也是 await thenable 比 await 真 Promise 多跳一拍微任务的原因。
七、常见误区速查
误区 1:executor 是异步的。 不是。new Promise(fn) 里的 fn 同步执行,见 2.1。
误区 2:resolve() 之后函数就结束了。 不是。resolve 不是 return,后面的代码照常跑:
js
new Promise(resolve => {
resolve("done")
console.log("这行照样打印") // 想拦住它得自己 return
})
误区 3:await 只能接 Promise。 不是。await 任何值都行:非 thenable 的值直接当成已解决的结果。await 42 完全合法,等价于 await Promise.resolve(42)。
误区 4:给普通对象挂个 then 方法无所谓。 很危,这叫 thenable 污染:
js
const user = {
name: "张三",
then(resolve) { resolve("被劫持了") }, // 手滑起的名字,或别有用心的库
}
const result = await Promise.resolve(user)
console.log(result) // "被劫持了" ------ 对象本身没了!
console.log(result === user) // false
原因:所有 Promise 边界(await、Promise.resolve、Promise.all、async 函数的 return......)都会对路过的值问那句"你有 then 吗"。一旦有,对象就会被当场"拆开",你拿到的不再是原对象。更狠的是如果这个 then 永远不调用 resolve,程序直接卡死在那里。
教训有两条:
- 正向:别给业务对象起名叫 then 的方法;
- 反向:有些库故意 给返回值挂 then,让它"能被 await"------jquery 的
$.ajax返回值就是最著名的例子(它是个 thenable,所以可以直接 await)。读懂第五节,你就看得懂这类库在干什么。
总结:一条进化链
单线程不能干等 → 造了任务队列(微任务优先清空);
等要有凭据 → 造了 Promise(状态机 + 回调簿,按钮与耳朵分离);
写回调嵌套太丑 → 造了 async/await("登记 + 撒手"的语法糖);
要让全世界各种 Promise 实现互通 → 约定只认
then方法(鸭子类型,thenable);任何值都要能进这条流水线 →
Promise.resolve负责统一同化。
每一层都是为解决上一层的具体问题而生的,没有一步是多余的。觉得乱,往往是因为教程把这些同时端上来------其实它们是一条链,顺着捋一遍,每个概念都有它非存在不可的理由。