JavaScript 一面:事件循环、宏任务、微任务与 async/await

一面里考事件循环,常见思路是给一段混合了 setTimeout / Promise / async/await 的代码,让你写出打印顺序。这一篇试着把那套模型讲清楚。

可以先用一个够用的抽象来记忆浏览器里的事件循环:

  • 执行上下文中同步代码先跑完;
  • 异步任务的回调被分发到不同的队列里:
    • 宏任务队列(macrotask queue):setTimeoutsetIntervalsetImmediate(Node)、I/O、UI 渲染等;
    • 微任务队列(microtask queue):Promise.then/catch/finallyMutationObserver、queueMicrotask 等。

一个完整的“循环”大致是:

  1. 从宏任务队列取出一个任务,执行其中的同步代码;
  2. 执行完当前宏任务后,清空当前产生的所有微任务队列;
  3. 执行 UI 渲染(浏览器环境中);
  4. 回到第 1 步,处理下一个宏任务。

在一面里,如果能用两三句话把这几个点讲清楚,已经足够展示你对事件循环的理解。

一个经典的面试例子:

 1console.log(1);
 2
 3setTimeout(() => {
 4  console.log(2);
 5}, 0);
 6
 7Promise.resolve().then(() => {
 8  console.log(3);
 9});
10
11console.log(4);

打印顺序是:1, 4, 3, 2

分析过程可以这样讲:

  • 同步代码先执行:输出 1;注册一个宏任务(setTimeout 回调);注册一个微任务(then 回调);输出 4;
  • 当前宏任务(主脚本)结束,开始执行本轮微任务队列:输出 3;
  • 微任务清空后,事件循环进入下一轮,从宏任务队列取出 setTimeout 的回调:输出 2。

关键点:同一轮事件循环中,永远是先清空微任务,再执行下一个宏任务。

async/await 本质只是对 Promise 的语法糖。
一个典型考题:

 1async function foo() {
 2  console.log("foo start");
 3  await bar();
 4  console.log("foo end");
 5}
 6
 7async function bar() {
 8  console.log("bar");
 9}
10
11console.log("script start");
12foo();
13console.log("script end");

大多数运行环境下打印顺序是:

1script start
2foo start
3bar
4script end
5foo end

可以这样解释:

  • foo() 被调用时,先同步执行到 await bar()
    • 输出 foo start
    • 调用 bar(),输出 bar
    • await 把后面的逻辑(console.log("foo end"))包装成一个微任务;
  • 回到主脚本,输出 script end
  • 本轮宏任务结束,开始执行微任务队列:输出 foo end

在一面回答时,如果你能明确指出“await 之后的代码会被放到一个微任务里”,已经足够展示扎实理解。

面试常见进阶题,会混合几个异步源:

 1console.log("start");
 2
 3setTimeout(() => {
 4  console.log("timeout");
 5}, 0);
 6
 7Promise.resolve()
 8  .then(() => {
 9    console.log("then1");
10  })
11  .then(() => {
12    console.log("then2");
13  });
14
15(async () => {
16  console.log("async start");
17  await null;
18  console.log("async end");
19})();
20
21console.log("end");

分析顺序可以拆开讲:

  1. 同步部分:

    • 输出 start
    • 注册一个宏任务(setTimeout);
    • 注册第一个 then1 微任务;
    • 进入自执行 async 函数,输出 async startawait null 把后续 console.log("async end") 作为微任务;
    • 输出 end
  2. 微任务队列执行顺序:

    • 先执行 then1:输出 then1,并注册下一个微任务 then2
    • 然后执行 async 对应的微任务:输出 async end
    • 最后执行 then2:输出 then2
  3. 微任务清空后,下一轮取宏任务:

    • 执行 setTimeout 回调,输出 timeout

所以整体输出顺序为:

1start
2async start
3end
4then1
5async end
6then2
7timeout

在面试场景里,如果你能边写边口头说明“这个是宏任务,这个是微任务,现在清空哪个队列”,会比只给出最终顺序更加分。

可以预先准备的几点:

  • process.nextTick 和 Promise 微任务的顺序(Node 环境)

    • 如果面试主要是浏览器端开发,可以简单说“Node 的事件循环阶段更复杂,这里不展开”。
  • forEach + async 的坑

    • forEach 不会等待 async 回调完成,await 里面的逻辑只会排队进微任务;
    • 如果想控制异步顺序,应该用 for...of 循环配合 await
  • setTimeout(fn, 0) 是否真的“立即执行”?

    • 不会,它只是将回调排队到宏任务队列里,至少要等当前宏任务和所有微任务跑完。

这些问题不一定每次都被问到,但提前想清楚,面对变体题目时会从容很多。

 1console.log(1);
 2
 3setTimeout(() => {
 4  console.log(2);
 5}, 0);
 6
 7Promise.resolve().then(() => {
 8  console.log(3);
 9});
10
11console.log(4);

参考答案:

  • 输出顺序:1, 4, 3, 2
  • 分析:
    • 同步代码先执行,输出 14,注册一个宏任务(setTimeout)和一个微任务(then);
    • 当前宏任务结束后,清空微任务队列,输出 3
    • 然后进入下一轮事件循环,从宏任务队列取出 setTimeout 回调,输出 2

答题时可以边写边口头说明“先宏任务里的同步,再微任务,再下一个宏任务”。

 1console.log("start");
 2
 3setTimeout(() => {
 4  console.log("timeout");
 5}, 0);
 6
 7Promise.resolve()
 8  .then(() => {
 9    console.log("then1");
10  })
11  .then(() => {
12    console.log("then2");
13  });
14
15(async () => {
16  console.log("async start");
17  await null;
18  console.log("async end");
19})();
20
21console.log("end");

参考答案:

  • 输出顺序:
1start
2async start
3end
4then1
5async end
6then2
7timeout
  • 简要分析:
    • 同步阶段依次输出 startasync startend,并注册:
      • 一个宏任务(setTimeout);
      • then1 对应的微任务;
      • await null 对应的微任务(async end 那一行);
    • 清空微任务队列时:
      • 先执行 then1,输出 then1,并注册下一个微任务 then2
      • 再执行 async end
      • 最后执行 then2
    • 微任务清空后,取下一个宏任务执行,输出 timeout

有些一面题会让你“把一段 then 链改成 async/await”,或者问“这两种写法在事件循环里的差别”。

可以用类似下面的回答方式:

  • async/await 相比手写 then 链更易读,但底层仍然是:
    • await 前的部分在当前宏任务里同步执行;
    • await 之后的逻辑被放进一个微任务队列,在当前宏任务结束后执行;
  • 事件循环顺序不变,都是“当前宏任务 → 清空微任务 → 下一个宏任务”,只是 async/await 把这个流程写得更接近同步代码。

可以尝试用下面这样的结构回答事件循环相关问题:

  • 先用一句话说明宏任务/微任务和事件循环的基本模型;
  • 用一个 console + setTimeout + Promise 的小例子说明“为什么是先 Promise 再 Timeout”;
  • 再补充 async/await 只是 Promise 的语法糖,await 之后的代码会进微任务队列;
  • 如果还有时间,可以提一句 Node 事件循环更复杂,但面试重点多半在浏览器模型。

相比死记硬背“哪些输出顺序”,面试官更希望看到你能用自己的话把这套机制讲清楚,并在新题目里正确运用这套模型。