彻底搞懂 JS 异步:Promise、async/await 与事件循环的底层逻辑

很多初学前端甚至有经验的开发者,在面对 JavaScript 的 asyncawaitPromise 以及宏任务/微任务时,都会感到一种深深的“绕脑”和挫败感。

首先要明确一件事:觉得它难以理解,绝对不是你智商的问题。 JavaScript 天生是“单线程”的(一次只能干一件事),为了在等待网络请求时不把整个网页卡死,它硬生生造出了一套极其反直觉的“事件循环(Event Loop)”机制。今天,我们就剥开语法糖的外衣,用大白话彻底讲透这套机制的底层逻辑。

一、Promise:一张网红餐厅的“排号小票”

在没有 Promise 之前,JS 处理异步用的是“回调函数(Callback)”,容易导致可怕的“回调地狱”。

Promise 的本质就是一个承诺(占位符)。 你可以把它想象成去网红餐厅吃饭时,前台给你的排号小票
拿到小票那一刻,你还没吃到饭,但这代表餐厅给了你一个承诺。这个小票有三种状态:

  1. Pending(进行中):你还在等位。
  2. Fulfilled(已成功):叫到你的号了,去吃!(代码触发 .then())
  3. Rejected(已失败):餐厅突然停水停电,吃不成了。(代码触发 .catch())

有了这这张小票,主线程就可以先去干别的事,等状态改变了再回来处理。

二、事件循环:主线程与两个“候车室”

JS 引擎在执行代码时,除了主线程,还有两个专门用来存放异步任务的“候车室”:

  1. 主线程执行栈:当前正在执行的同步代码。
  2. 微任务队列 (VIP 候车室)
    • 包含Promise.then/catch 的回调,以及 await 下面的代码。
    • 特权:只要主线程一空,引擎会立刻且全部清空这里的所有任务。
  3. 宏任务队列 (普通候车室)
    • 包含setTimeout、网络请求等。
    • 特权:地位较低。只有当主线程空了,且 VIP 候车室也完全空了,才会从这里挑出一个任务去执行。

核心运转规律(死记硬背): 执行同步代码 -> 清空所有微任务 -> 拿出一个宏任务执行 -> 循环。

三、揭秘 await 的“障眼法”(核心剪刀理论)

async/await 的出现,是为了让异步代码看起来像同步代码。但它最大的迷惑性在于:当代码遇到 await 时,它绝对不会把整个浏览器卡死。

你可以把 await 想象成一把剪刀。当 JS 引擎执行到 await 时,它是这样思考的:

async function async1() {
  console.log('1. 开始');
  await async2(); // ?? 剪刀在这里咔嚓剪断!
  console.log('2. 结束'); 
}

底层真实的翻译过程:

  1. 引擎把 await 右边async2() 当作普通同步代码立刻执行。
  2. 引擎用剪刀把 await 下面所有的代码(打印 2),打包塞进 async2 返回的 Promise 的 .then() 里面。
  3. 把这个 .then() 一脚踢进微任务队列(VIP 候车室)
  4. 主线程立刻跳出 async1 函数,去外面干别的事!

理解了“剪刀理论”,我们来看一道大厂经典面试真题:

console.log('1. 脚本开始');

setTimeout(() => { console.log('2. setTimeout'); }, 0);

async function async1() {
  console.log('3. async1 开始');
  await async2();
  console.log('4. async1 结束'); 
}

async function async2() { console.log('5. async2 执行中'); }

async1();

new Promise((resolve) => {
  console.log('6. Promise 内部');
  resolve();
}).then(() => { console.log('7. Promise.then'); });

console.log('8. 脚本结束');

正确打印顺序:1 -> 3 -> 5 -> 6 -> 8 -> 4 -> 7 -> 2。 (你可以试着用主线程和两个候车室的模型自己推导一遍!)

四、为什么 await 必须包裹在 async 里面?(逻辑必然性)

很多初学者会问:为什么不能在普通函数里直接写 await?这其实是单线程非阻塞模型下的唯一数学解

假设允许在普通函数里用 await 等待网络请求,那这个函数走到一半就卡住了,无法立刻给调用它的外层函数一个返回值。这会导致外层函数也被迫等待,最终导致整个浏览器卡死。

为了解决这个死结,JS 委员会制定了一份“强制契约”:
既然你内部因为 await 卡住了拿不到最终结果,那你必须立刻向外界扔出一个**“欠条(Pending 状态的 Promise)”**。而 async 关键字,就是向外界宣告“我必定返回 Promise”的法律合同。 不签合同,就不准用 await

五、错误捕获:如何拆除“失败的炸弹”

如果网络报错,Promise 就会变成 Rejected 状态,像一颗点燃的炸弹。

在现代的 async/await 语法中,我们用同步的 try...catch 来拆弹:

async function fetchAvatar() {
  try {
    let res = await fetch('/api/avatar');
    console.log('拿到数据', res); // 相当于打包进 .then()
  } catch (error) {
    console.log('报错了', error); // 相当于打包进 .catch()
  }
}

底层逻辑: JS 引擎遇到报错时,会无视 try 块里 await 下面的所有代码,直接把 catch 块里的代码打包进微任务的 .catch() 里执行。两者在底层是 100% 绝对等价的。

六、多重 await:是嵌套还是链条?

如果一个 async 函数里有两个 await,相当于被剪了两刀,切成了三段。

不要把它理解成“回调地狱”式的嵌套,在引擎底层,它会被翻译成严格串行的 Promise 链条(Promise Chain)

// 你的代码
async function getUserAndPosts() {
  const user = await fetch('/api/user'); 
  const posts = await fetch(`/api/posts/${user.id}`); 
  return posts;
}

// 引擎眼里的等价代码:一场微任务的“接力跑”
function getUserAndPosts() {
  return fetch('/api/user')
    .then((user) => {
      // 第一个成功后,发起第二个,并 return 新的 Promise
      return fetch(`/api/posts/${user.id}`); 
    })
    .then((posts) => {
      // 第二个成功后,最终返回结果
      return posts;
    });
}

每一个 await 都在等待前一个 Promise 真正跑完,这保证了代码绝对的串行执行。

💡进阶彩蛋:V8 引擎的微任务开销

async 函数内部,如果你直接 return new Promise(...),为了把内部状态同步给 async 自动生成的外层 Promise,V8 引擎会悄悄执行一次拆包(Unwrap),这会额外消耗一次微任务排队时间。

老手性能极致写法: 如果你需要手动封装底层的回调(如 setTimeout),不要在外层套 async,直接编写普通函数并 return new Promise 即可,这样能省去一次不必要的微任务开销!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐