【每日一面】事件循环机制

【每日一面】事件循环机制

_

之前我们聊到了 async/await 的原理,它底层依赖 Generator + 自动执行器。但有一个更底层的问题没有展开——JavaScript 是单线程的,它是怎么做到"一边等接口返回、一边还能响应用户点击"的? 答案就是事件循环(Event Loop)。

基础问答

问: 说说你对 JavaScript 事件循环的理解?

答: JavaScript 是单线程语言,所有代码在唯一的调用栈中执行。事件循环是宿主环境(浏览器/Node.js)提供的一套调度机制,它协调调用栈、宏任务队列和微任务队列三者的执行顺序:同步代码在调用栈中按序执行;遇到异步操作(setTimeout、fetch、Promise.then 等)时,将其交给宿主环境处理,主线程继续往下走;异步操作完成后,回调被推入对应的任务队列。每轮事件循环的执行顺序是:先清空所有微任务,再取一个宏任务执行,如此往复。

扩展延伸

单线程的 JavaScript

JavaScript 最初设计用于浏览器中操作 DOM。如果它是多线程的,一个线程在修改 DOM、另一个线程在删除 DOM,浏览器就不知道以谁为准。为了避免这类复杂度,JavaScript 从一开始就是单线程的,即,同一时间只能做一件事。

但单线程意味着如果一个操作耗时很长(比如网络请求),后面的代码全部被阻塞。所以宿主环境提供了事件循环机制:耗时的操作交给环境去等,主线程先干别的活,等结果回来再处理回调。这就是异步非阻塞的本质。

核心概念

  • ① 调用栈(Call Stack) :执行同步代码的地方,后进先出。所有函数调用都压入栈中,执行完弹出。
  • ② 宿主环境(Web APIs) :浏览器(或 Node.js)提供的 API,如 setTimeout、XMLHttpRequest、DOM 事件。它们在独立线程中运行,完成后把回调函数推入任务队列。
  • ③ 任务队列(Task Queue) :分宏任务队列和微任务队列,存放等待执行的回调。

image-tnayzidy.png

浏览器事件循环流程:调用栈 → 宿主环境 → 微/宏任务队列 → 回调入栈

宏任务与微任务

任务队列不是一条,而是两条:宏任务(Macrotask)和微任务(Microtask),它们的区别不只是名字,执行优先级也不同。

宏任务 Macrotask微任务 Microtask
setTimeout / setIntervalPromise.then / catch / finally
I/O 操作回调queueMicrotask()
UI 事件(click、scroll)MutationObserver
requestAnimationFrameprocess.nextTick(Node.js)
postMessage

每轮事件循环中,JS 会先清空所有微任务,然后才取一个宏任务执行。 也就是说,哪怕宏任务队列里排了十个回调,只要微任务队列不为空,宏任务就得等。

执行流程

一轮完整的事件循环包含以下步骤:

  1. 执行调用栈中的同步代码,直到栈空
  2. 检查微任务队列,全部执行完毕(执行过程中新产生的微任务也会在这一轮清掉)
  3. 浏览器进行 UI 渲染(如果有必要)
  4. 从宏任务队列取一个任务,压入调用栈执行

这里要注意第 2 步,微任务是一锅端,不是取一个。这是微任务优先级高于宏任务的根本原因。

经典题

这是过往面试出现频率最高的一道题,能答对说明你理解了事件循环:

console.log('1. 同步');

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

Promise.resolve().then(() => {
  console.log('3. 微任务 Promise');
});

console.log('4. 同步');

执行过程拆解:

  • 执行同步代码 → 输出 1. 同步4. 同步
  • setTimeout 回调进入宏任务队列,Promise.then 回调进入微任务队列
  • 同步代码执行完毕,检查微任务队列 → 输出 3. 微任务 Promise
  • 微任务清空,取一个宏任务 → 输出 2. 宏任务 setTimeout

最终输出顺序:1 → 4 → 3 → 2
记住口诀:同步先走,微任务插队,宏任务最后。

async/await

async/await 本质是 Promise 的语法糖,await 之后的代码相当于 .then() 回调,属于微任务。来看一道更复杂的:

async function async1() {
  console.log('A');
  await async2();
  console.log('B');  // await 之后的代码 → 微任务
}

async function async2() {
  console.log('C');
}

console.log('D');

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

async1();

new Promise((resolve) => {
  console.log('F');
  resolve();
}).then(() => {
  console.log('G');
});

console.log('H');

逐步分析:

  • D — 同步执行
  • 调用 async1() → A 同步输出 → 调用 async2() 输出 C → 遇到 await,B 被挂起作为微任务
  • setTimeout 的 E 进入宏任务队列
  • new Promise 执行器同步执行 → F → resolve() 后 G 进入微任务队列
  • H — 同步执行
  • 同步代码结束,清空微任务 → 先 BG(微任务按入队顺序执行)
  • 取宏任务 → E

输出顺序:D → A → C → F → H → B → G → E

这里有个关键细节:await async2() 会先同步执行 async2 函数体(输出 C),然后才将 await 之后的代码(B)放入微任务队列。await 右边的表达式是同步执行的,await 之后的部分才是微任务。

Node.js 事件循环的差异

Node.js 的事件循环由 libuv 实现,和浏览器的模型有差异。浏览器只有宏任务 + 微任务两层,Node.js 的宏任务被进一步拆分为六个阶段:

  1. timers — 执行 setTimeout / setInterval 到期的回调
  2. pending callbacks — 执行系统级回调(如 TCP 错误)
  3. idle / prepare — 内部使用
  4. poll — 获取新的 I/O 事件,执行 I/O 回调
  5. check — 执行 setImmediate 回调
  6. close callbacks — 执行 close 事件回调

此外,Node.js 有一个特殊的微任务 process.nextTick,它的优先级比 Promise 还高——在每个阶段切换之间,会先清空所有 nextTick 队列,再清空 Promise 微任务队列。

对比维度浏览器Node.js
宏任务分层不分层,统一队列分 6 个阶段
微任务执行时机每个宏任务之后每个阶段切换之间
process.nextTick不支持支持,优先级最高
setImmediate不支持check 阶段执行

一个经典对比题——在 Node.js 中,setTimeout(fn, 0)setImmediate(fn) 谁先执行?

答案是不确定。取决于进入事件循环时,1ms 的定时器是否已到期。但如果把它们放在 fs.readFile 的回调中执行,setImmediate 一定先于 setTimeout——因为 I/O 回调在 poll 阶段,下一个阶段就是 check,而 timers 在更后面。这是面试常考的不确定和确定的对比。

面试追问

宏任务和微任务的本质区别是什么?为什么微任务优先级更高?

本质区别是队列清空策略不同:微任务是全部清空,宏任务是只取一个

微任务优先级更高的原因是微任务代表的是当前同步代码的逻辑延续(如 Promise 链),应该在进入下一个宏任务之前处理完,保证逻辑的完整性和一致性。如果微任务被延后到下一个宏任务才执行,当前轮次的逻辑状态可能已经被打断。

requestAnimationFrame 算宏任务还是微任务?

都不算。requestAnimationFrame 的回调在浏览器重绘之前执行,时机位于微任务清空之后、宏任务执行之前(或着可以视为一种独立的渲染阶段)。它既不是宏任务也不是微任务,是浏览器渲染流水线的一部分。这也是为什么 rAF 的回调比 setTimeout(fn, 0) 更适合做动画的原因,即,它和屏幕刷新率是同步的。

下面代码的输出顺序是什么?

Promise.resolve().then(() => {
  console.log('A');
  Promise.resolve().then(() => {
    console.log('B');
  });
});

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

Promise.resolve().then(() => {
  console.log('D');
});

输出:A → D → B → C。同步代码执行完后,微任务队列有 [A的then, D的then]。执行 A 的回调时输出 A,同时往微任务队列追加 B 的 then。此时微任务队列变成 [D的then, B的then]。继续清空 → 输出 D、输出 B。微任务全部清空后取宏任务 → 输出 C。需要注意的是,微任务执行过程中新产生的微任务,在当前轮次就会被清掉,不会留到下一轮。

为什么不直接把所有异步都做成微任务,还要分宏微?

因为两类异步任务的语义不同。微任务是当前执行上下文的逻辑延续,必须尽快处理,否则 Promise 链会断裂;

宏任务是独立的、外部的异步事件(定时器到期、用户点击、网络返回),它们的执行时机本就不需要和当前逻辑强绑定。如果全是微任务,一个 setTimeout 的回调就可以无限插队,导致 UI 渲染和 I/O 回调永远得不到执行。分层的本质是按紧迫程度排序,保证页面响应不被饿死。

实际开发中,事件循环的知识有什么用?

有两个典型的应用场景:

  • ① 批量任务分片:处理大量数据时,如果全部同步执行会阻塞 UI。可以用 setTimeout(宏任务)或 queueMicrotask 分片执行,让浏览器有机会渲染。选宏任务还是微任务取决于你希望分片之间是否让浏览器重绘。
  • ② 避免微任务爆炸:递归创建大量微任务(如 Promise 链无限 then)会导致微任务队列永远清不完,宏任务被饿死,页面卡死。这种问题在 Node.js 中更危险,会直接导致服务无响应。了解事件循环能让你提前预判这类问题。
SSG 预渲染工具站 SEO 优化实践:从零收录到全面修复的 7 个问题 2026-09-02
【每日一面】浏览器渲染机制:重绘与回流 2026-09-04

评论区