JS 异步的本质:从单线程到 thenable(一条进化链)

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 引擎做三件事:

  1. 暂停当前 async 函数:await 这一行之后的代码,暂时停止执行;
  2. 把"await 后面所有剩余代码"打包成一个续体 (continuation),登记到被等的东西上------等价于一个 .then() 回调;
  3. 直接退出当前 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)按下述步骤走,一步都不多:

  1. 引擎执行到 await x,检查 x:身上有 then 方法 → 按 thenable 处理;
  2. 引擎自己造好两个函数 (就叫它们 resolve、reject),然后安排一个微任务,内容是"调用 x.then(resolve, reject)";
  3. 轮到那个微任务执行:x 的 then 被调用,收到了引擎递来的两个函数。x 是干活的人,事情办完时,它调一下 resolve(结果);
  4. 引擎发现自己造的 resolve 被调了 → 知道"x 好了,结果是这个" → 把"恢复执行 await 后面的代码"排进微队列;
  5. 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))

两个细节:

  1. 它其实就是 await 内部逻辑的手动版 ------await 任何东西之前,都先做这一步"同化",所以 await 42、await thenable、await promise 走的是同一条规范路径。
  2. 吸收 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 负责统一同化。

每一层都是为解决上一层的具体问题而生的,没有一步是多余的。觉得乱,往往是因为教程把这些同时端上来------其实它们是一条链,顺着捋一遍,每个概念都有它非存在不可的理由。

相关推荐
liangshanbo12151 小时前
Webpack vs Vite 底层原理高频追问的问题
前端·webpack·node.js
可乐鸡翅yeah_1 小时前
hls.js 配置 loadStartPosition,点播起始播放位置踩坑
开发语言·javascript·网络协议·ecmascript·m3u8在线
en.en..1 小时前
Linux 进程退出exit(函数)
java·开发语言·算法
Cx330❀1 小时前
【Qt 进阶指南】详解 Qt 常用标准对话框与主窗口组件架构
开发语言·qt·ui·性能优化·架构·图形渲染
Light Gao1 小时前
Encoder 与 Decoder 详解:从 Embedding 到 Seq2Seq 的完整数据流
java·开发语言·embedding
HZZD_HZZD1 小时前
弱网下CoAP还省吗?合众致达Cat.1电表90天实测:MQTT重连流量占52%、续航差距从38%拉大到73%
开发语言·物联网·php·腾讯云
web打印社区1 小时前
Windows 网页静默打印设置步骤:客户端、防火墙与联调清单
开发语言·前端·javascript·chrome·pdf·ecmascript
恋猫de小郭1 小时前
KMP 又改了编译流程, Separate Compilation 禁止了 `commonMain` 的依赖穿透
android·前端·flutter
小师兄吃牛肉1 小时前
什么是R语言?如何快速学习R语言
开发语言·学习·r语言