跳到主要内容
预计阅读 43 分钟

浏览器篇 | 事件循环

前言

JavaScript 是单线程语言——任何时候只有一个任务在执行。但我们日常写的代码里到处都是”异步”:setTimeoutfetchPromiseasync/await……如果只有一个线程,这些异步操作是怎么做到”不阻塞”的?

答案就是事件循环(Event Loop)

事件循环是 JavaScript 运行时的核心调度机制。理解它,你就能准确预测代码的执行顺序,理解为什么 setTimeout(fn, 0) 不是”立即执行”,解释清楚 Promise 和 setTimeout 的执行先后。不理解它,你写的异步代码就像在碰运气——有时候对,有时候不对,出了 bug 也不知道为什么。

面试中,事件循环是几乎必考的知识点,尤其是经典的”说出下面代码的执行顺序”。本章我们就彻底把它讲透。


诊断自测

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

console.log('1');

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

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

console.log('4');
点击查看答案

输出顺序:1 → 4 → 3 → 2

同步代码先执行,所以先输出 14。然后微任务(Promise.then)优先于宏任务(setTimeout)执行,所以先输出 3,再输出 2

Q2:微任务和宏任务分别有哪些?至少各说三个。

点击查看答案

微任务(Microtask): Promise.then/catch/finally 的回调、MutationObserver 的回调、queueMicrotask() 注册的回调、async/await 中 await 之后的代码。

宏任务(Macrotask / Task): setTimeoutsetIntervalsetImmediate(Node.js)、I/O 操作、UI 渲染事件、requestAnimationFrame(有争议,有些文章归为宏任务,但它实际上在渲染之前独立执行)、MessageChannel。

Q3:为什么说 setTimeout(fn, 0) 不是”立即执行”?

点击查看答案

setTimeout(fn, 0) 只是把 fn 作为一个宏任务放入任务队列,它要等到当前同步代码执行完毕并且所有微任务都处理完之后,才会被事件循环取出执行。另外,HTML 规范规定嵌套超过 5 层的 setTimeout 最小延迟为 4ms。所以 setTimeout(fn, 0) 不是”0 延迟”,而是”尽快,但要排队”。


一、单线程模型与异步

1.1 为什么 JavaScript 是单线程的?

JavaScript 最初是为浏览器中的 DOM 操作而设计的。想象一下,如果两个线程同时操作同一个 DOM 节点——一个要删除它,另一个要修改它的内容——浏览器该听谁的?为了避免这种复杂的同步问题,JavaScript 从一开始就被设计为单线程

单线程意味着:同一时间只能做一件事。代码是一行一行顺序执行的。

但问题来了:如果一个网络请求需要 3 秒才能返回,难道页面就要卡 3 秒吗?

当然不是。这就是异步存在的意义。

1.2 异步的本质:不是多线程,而是”回头再说”

JavaScript 引擎(如 V8)本身确实是单线程的,但它运行在一个多线程的宿主环境中(浏览器或 Node.js)。当你调用 setTimeoutfetch 时:

  1. JavaScript 引擎把异步操作交给宿主环境的其他线程处理(比如定时器线程、网络线程)
  2. 引擎自己继续执行后面的同步代码
  3. 等异步操作完成后,宿主环境把回调函数放入任务队列
  4. JavaScript 引擎空闲时,从任务队列中取出回调来执行

这个”交出去 → 继续做别的 → 回头处理结果”的机制,就是事件循环


二、事件循环机制:完整流程

2.1 核心组件

事件循环涉及几个关键概念:

调用栈(Call Stack)

JavaScript 引擎执行代码的地方。每调用一个函数,就往栈里压入一个帧;函数执行完毕,帧弹出。栈空了,引擎就”闲”了。

任务队列(Task Queue / Macrotask Queue)

宏任务完成后,回调会被放在这里排队。事件循环每次从这里取一个任务来执行。

微任务队列(Microtask Queue)

微任务完成后,回调放在这里。优先级高于任务队列——每个宏任务执行完后,引擎会清空整个微任务队列,然后才会取下一个宏任务。

Web API / 宿主环境

定时器、DOM 事件、网络请求等由宿主环境(浏览器或 Node.js)管理的异步操作。

2.2 事件循环的执行顺序

用文字描述完整的事件循环流程:

1. 执行全局同步代码(这本身就是第一个宏任务)
2. 同步代码执行完毕后,清空微任务队列
   - 依次执行所有微任务
   - 如果微任务中又产生了新的微任务,继续执行,直到队列清空
3. 浏览器判断是否需要渲染
   - 如果需要渲染,执行 requestAnimationFrame 回调
   - 执行渲染(Layout → Paint → Composite)
4. 从宏任务队列中取出一个任务执行
5. 回到第 2 步,重复

用一张流程图来理解:

┌──────────────────────────────────────────┐
│              调用栈 (Call Stack)           │
│            执行当前宏任务的代码             │
└─────────────────┬────────────────────────┘
                  │ 栈空了

┌──────────────────────────────────────────┐
│           微任务队列全部清空               │
│  Promise.then / queueMicrotask / ...     │
│  (如果新产生的微任务,继续处理)            │
└─────────────────┬────────────────────────┘
                  │ 微任务队列空了

┌──────────────────────────────────────────┐
│         浏览器渲染(如果需要的话)          │
│  requestAnimationFrame → Layout → Paint  │
└─────────────────┬────────────────────────┘


┌──────────────────────────────────────────┐
│       从宏任务队列取出一个任务执行          │
│  setTimeout / setInterval / I/O / ...    │
└─────────────────┬────────────────────────┘

                  └──── 回到"清空微任务"步骤

2.3 关键规则总结

  1. 同步代码优先:所有同步代码在当前宏任务中一口气执行完
  2. 微任务先于下一个宏任务:每个宏任务结束后,先清空所有微任务
  3. 微任务中产生的微任务也会在当前轮次执行:不会被推迟到下一轮
  4. 每轮事件循环只取一个宏任务(但会清空所有微任务)

三、宏任务 vs 微任务

3.1 具体分类

宏任务(Macrotask / Task):

宏任务说明
setTimeout / setInterval定时器回调
setImmediateNode.js 特有
I/O 操作文件读写、网络请求完成后的回调
UI 渲染浏览器的 DOM 渲染
MessageChannel消息通道回调
script 整体代码初始的全局代码也是一个宏任务

微任务(Microtask):

微任务说明
Promise.then / .catch / .finallyPromise 回调
async/awaitawait 后面的代码相当于 .then
MutationObserverDOM 变更观察器的回调
queueMicrotask()手动添加微任务的 API
process.nextTickNode.js 特有(优先级比 Promise 还高)

3.2 为什么要区分宏任务和微任务?

微任务的设计目的是让某些操作能在当前宏任务结束后、下一个宏任务开始前尽快执行。这对于 Promise 等异步原语至关重要——你希望 .then 的回调能尽快得到执行,而不是排到一堆 setTimeout 后面。

举个例子感受一下区别:

setTimeout(() => console.log('timeout 1'), 0);
setTimeout(() => console.log('timeout 2'), 0);

Promise.resolve().then(() => console.log('promise 1'));
Promise.resolve().then(() => console.log('promise 2'));

// 输出:promise 1 → promise 2 → timeout 1 → timeout 2

两个 Promise 的微任务在两个 setTimeout 的宏任务之前执行,因为微任务队列会在当前宏任务(全局代码)结束后被完全清空。


四、经典面试题:执行顺序分析

这是面试中最常见的题型。我们从简单到复杂逐步分析。

4.1 基础题:setTimeout + Promise

console.log('start');

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

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

console.log('end');

分析过程:

  1. 执行同步代码:输出 start
  2. setTimeout:回调注册为宏任务,放入宏任务队列
  3. Promise.resolve().then():回调注册为微任务,放入微任务队列
  4. 执行同步代码:输出 end
  5. 当前宏任务(全局代码)执行完毕,清空微任务队列:输出 promise
  6. 取下一个宏任务执行:输出 timeout

输出: start → end → promise → timeout

4.2 进阶题:嵌套的 Promise 和 setTimeout

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => {
    console.log('5');
  }, 0);
});

console.log('6');

分析过程:

  1. 同步:输出 1
  2. setTimeout → 回调记为 T1,入宏任务队列
  3. Promise.then → 回调记为 P1,入微任务队列
  4. 同步:输出 6
  5. 清空微任务队列:执行 P1 → 输出 4,内部 setTimeout → 回调记为 T2,入宏任务队列
  6. 微任务队列空了。取下一个宏任务 T1 → 输出 2,内部 Promise.then → 回调记为 P2,入微任务队列
  7. 清空微任务队列:执行 P2 → 输出 3
  8. 取下一个宏任务 T2 → 输出 5

输出: 1 → 6 → 4 → 2 → 3 → 5

4.3 终极题:async/await + Promise + setTimeout

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

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

console.log('script start');

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

async1();

new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
});

console.log('script end');

分析过程(关键点:await 后面的代码等价于 .then 的回调):

  1. 同步:输出 script start
  2. setTimeout → 回调入宏任务队列
  3. 调用 async1()
    • 输出 async1 start
    • 执行 await async2():先执行 async2(),输出 async2
    • await 之后的代码(console.log('async1 end'))作为微任务入队
  4. 执行 new Promise 的构造函数(同步):输出 promise1
  5. .then 回调作为微任务入队
  6. 同步:输出 script end
  7. 清空微任务队列:
    • 输出 async1 end
    • 输出 promise2
  8. 取宏任务:输出 setTimeout

输出: script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout

这道题的核心考点:

  • new Promise(executor) 中的 executor同步执行
  • await 后面的代码相当于放在 .then() 里,是微任务
  • 微任务按入队顺序执行

五、requestAnimationFrame 在事件循环中的位置

requestAnimationFrame(简称 rAF)是一个比较特殊的存在。它既不属于宏任务,也不属于微任务,而是在事件循环中有自己独立的时机:

宏任务 → 微任务全部清空 → requestAnimationFrame → 浏览器渲染 → 下一个宏任务

也就是说,rAF 的执行时机是在微任务之后、浏览器渲染之前

setTimeout(() => console.log('timeout'), 0);

requestAnimationFrame(() => console.log('rAF'));

Promise.resolve().then(() => console.log('promise'));

// 通常输出:promise → rAF → timeout
// 但 rAF 和 timeout 的先后不是 100% 确定的,取决于浏览器是否在这轮决定渲染

为什么 rAF 很重要?

  • rAF 的回调在浏览器下一次重绘之前执行,适合做动画、DOM 修改等视觉更新
  • 如果页面不可见(比如切到了后台标签),rAF 会暂停执行,节省资源
  • rAF 的频率通常和屏幕刷新率一致(60Hz 屏幕上约 16.7ms 一次)
// 使用 rAF 做动画
function animate() {
  element.style.left = `${parseFloat(element.style.left) + 1}px`;
  requestAnimationFrame(animate); // 下一帧继续
}
requestAnimationFrame(animate);

面试追问:为什么动画应该用 rAF 而不是 setTimeout?

  1. setTimeout 的执行时机不可控,可能在两次渲染之间执行多次(浪费计算),也可能错过渲染时机(导致卡顿)
  2. rAF 和浏览器的渲染节奏同步,保证每一帧恰好执行一次,既不浪费也不遗漏
  3. 页面后台时 rAF 自动暂停,setTimeout 不会

六、Node.js 事件循环与浏览器的差异

虽然浏览器和 Node.js 都有事件循环,但它们的实现有一些重要差异。面试中偶尔会问到这个对比。

6.1 Node.js 事件循环的六个阶段

Node.js 的事件循环基于 libuv 库,分为六个阶段:

   ┌───────────────────────────┐
┌─>│        timers              │  setTimeout / setInterval
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │     pending callbacks     │  系统级别的回调
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │       idle, prepare       │  内部使用
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │          poll              │  I/O 回调
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │          check            │  setImmediate
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │     close callbacks       │  socket.on('close', ...)
│  └───────────┬───────────────┘
│              │
└──────────────┘

6.2 关键差异

1. process.nextTick vs Promise

Node.js 中有一个特殊的微任务:process.nextTick。它的优先级比 Promise 的微任务更高——在每个阶段切换之前,Node.js 会先清空 nextTick 队列,然后清空 Promise 微任务队列。

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

// Node.js 输出:nextTick → promise

2. setImmediate vs setTimeout(fn, 0)

setImmediate 是 Node.js 特有的 API,它的回调在 check 阶段执行。与 setTimeout(fn, 0) 的执行顺序在不同上下文中可能不同:

// 在主模块中,顺序不确定
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 可能是 timeout → immediate,也可能反过来

// 在 I/O 回调中,setImmediate 总是先于 setTimeout
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// 总是 immediate → timeout

3. 微任务的执行时机

在 Node.js 11+ 中,微任务的行为和浏览器基本一致——每执行完一个宏任务,就清空微任务队列。但在 Node.js 10 及更早版本中,微任务是在阶段切换时才清空的,这导致了一些和浏览器不一致的行为。现代 Node.js 已经对齐了浏览器的行为。


七、容易混淆的细节补充

7.1 Promise 构造函数是同步的

new Promise((resolve) => {
  console.log('同步执行');
  resolve();
}).then(() => {
  console.log('微任务');
});

console.log('后面的同步代码');

// 输出:同步执行 → 后面的同步代码 → 微任务

new Promise(executor) 中的 executor 函数是立即同步执行的,只有 .then 的回调才是微任务。这个点在面试中经常被用来出”障眼法”。

7.2 async 函数的返回值

async function foo() {
  return 42;
}

// 等价于
function foo() {
  return Promise.resolve(42);
}

async 函数的返回值会被自动包装成 Promise。如果返回的已经是一个 Promise,则不会再包一层。

7.3 微任务”塞车”会阻塞渲染

由于微任务会在当前轮次被全部清空,如果微任务产生了大量新的微任务,就会一直占用主线程,导致渲染被推迟:

// ⚠️ 危险:无限微任务,页面会卡死
function loop() {
  Promise.resolve().then(loop);
}
loop();

相比之下,无限的宏任务(如递归 setTimeout)不会卡死页面,因为每个宏任务之间浏览器有机会渲染:

// 不会卡死,每次 setTimeout 之间浏览器可以渲染
function loop() {
  setTimeout(loop, 0);
}
loop();

这也是理解微任务和宏任务差异的一个重要视角。


常见误区

误区一:“setTimeout(fn, 0) 就是立即执行”

不是。setTimeout(fn, 0) 的意思是”把 fn 放入宏任务队列,延迟 0ms”。但这个 0ms 不是真的 0ms——首先,所有微任务和可能的渲染都要先完成;其次,HTML 规范规定嵌套层级超过 5 时,最小延迟会被钳制为 4ms。更准确的理解是:setTimeout(fn, 0) 表示”在当前宏任务和所有微任务之后,尽快执行 fn”。

误区二:“async/await 让代码变成同步执行了”

不是。async/await 只是 Promise 的语法糖,让异步代码”看起来”像同步的。await 后面的代码实际上被放到了微任务队列中,和 .then() 的行为完全一致。await 会让出当前函数的执行权,等微任务轮到它时才继续。

误区三:“微任务一定比宏任务先执行”

这句话不够精确。准确说法是:在同一轮事件循环中,当前宏任务产生的微任务会在下一个宏任务之前执行。 如果一个微任务是在某个宏任务执行之后才入队的,它可能在更后面的宏任务之后才执行。执行顺序取决于入队时机和所处的事件循环轮次。

误区四:“浏览器每轮事件循环都会渲染”

不一定。浏览器会根据需要决定是否渲染。如果没有视觉变化(比如没有 DOM 修改、没有动画),浏览器可能跳过渲染步骤以节省资源。通常浏览器的目标渲染频率是 60fps(约 16.7ms 一帧),但这不意味着每 16.7ms 一定会渲染。


小结

本章我们从 JavaScript 的单线程本质出发,完整梳理了事件循环的工作机制。

核心要点

  1. JavaScript 单线程,但通过事件循环 + 宿主环境的多线程实现异步
  2. 事件循环的核心节奏:执行宏任务 → 清空所有微任务 → 可能渲染 → 下一个宏任务
  3. 微任务优先级高于宏任务:每个宏任务结束后,所有微任务会被一次性清空
  4. 常见宏任务:setTimeout、setInterval、I/O、UI 渲染
  5. 常见微任务:Promise.then、async/await、MutationObserver、queueMicrotask
  6. requestAnimationFrame 在微任务之后、渲染之前执行
  7. Node.js 事件循环有六个阶段,process.nextTick 优先级高于 Promise
  8. Promise 构造函数是同步的,只有 .then 的回调才是微任务

记忆口诀

一个宏任务 → 清空所有微任务 → 渲染 → 下一个宏任务。

微任务”插队”,宏任务”排队”。


本章思维导图

事件循环(Event Loop)
  • 为什么需要
    • JS 单线程 → 不能阻塞 → 需要异步
    • 宿主环境多线程处理异步操作
  • 核心组件
    • 调用栈(Call Stack)
    • 宏任务队列(Task Queue)
    • 微任务队列(Microtask Queue)
    • Web API / 宿主环境
  • 执行顺序
    • 宏任务 → 清空微任务 → 渲染 → 下一个宏任务
    • 微任务中产生的微任务在当前轮次执行
  • 宏任务 vs 微任务
    • 宏任务:setTimeout / setInterval / I/O / MessageChannel
    • 微任务:Promise.then / async-await / MutationObserver / queueMicrotask
  • requestAnimationFrame
    • 在微任务之后、渲染之前
    • 适合动画、和渲染节奏同步
  • Node.js 差异
    • 六个阶段(timers / poll / check / ...)
    • process.nextTick 优先于 Promise
    • setImmediate 在 check 阶段
  • 经典面试题
    • setTimeout + Promise 执行顺序
    • async/await 的拆解(await 后 = .then)
    • Promise 构造函数是同步的

练习挑战

第一题 ⭐(基础):说出输出顺序

console.log('A');

setTimeout(() => console.log('B'), 0);

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

console.log('E');
点击查看答案

输出: A → E → C → D → B

分析:

  1. 同步:输出 A
  2. setTimeout 回调入宏任务队列
  3. Promise.then 回调入微任务队列
  4. 同步:输出 E
  5. 清空微任务:输出 C.then 链的下一个回调入微任务队列,输出 D
  6. 取宏任务:输出 B

注意 .then 的链式调用:第一个 .then 执行完后,第二个 .then 的回调才入队(因为需要前一个 Promise resolve 后才会注册下一个 .then)。

第二题 ⭐⭐(进阶):混合场景

setTimeout(() => {
  console.log('1');
  Promise.resolve().then(() => console.log('2'));
}, 0);

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

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

console.log('5');
点击查看答案

输出: 5 → 4 → 1 → 2 → 3

分析:

  1. 两个 setTimeout 的回调分别记为 T1、T2,入宏任务队列
  2. Promise.then 回调入微任务队列
  3. 同步:输出 5
  4. 清空微任务队列:输出 4
  5. 取宏任务 T1:输出 1,内部 Promise.then 回调入微任务队列
  6. 清空微任务:输出 2
  7. 取宏任务 T2:输出 3

关键点:T1 执行完后,它内部的微任务(输出 2)会在 T2 之前执行——因为每个宏任务结束后都要先清空微任务队列。

第三题 ⭐⭐⭐(综合):async/await 完整分析

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2 start');
  return new Promise((resolve) => {
    console.log('async2 promise');
    resolve();
  }).then(() => {
    console.log('async2 end');
  });
}

console.log('script start');

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

async1();

new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
}).then(() => {
  console.log('promise3');
});

console.log('script end');
点击查看答案

输出: script start → async1 start → async2 start → async2 promise → promise1 → script end → async2 end → promise2 → async1 end → promise3 → setTimeout

详细分析:

  1. 同步:输出 script start
  2. setTimeout 回调入宏任务队列
  3. 调用 async1()
    • 输出 async1 start
    • 调用 async2()
      • 输出 async2 start
      • 执行 new Promise 构造函数(同步):输出 async2 promise
      • .then(() => console.log('async2 end')) 入微任务队列(记为 M1)
      • async2 返回的是一个 Promise(即 .then 返回的新 Promise)
    • await 等待 async2() 返回的 Promise resolve,console.log('async1 end') 需要等 M1 执行后,async2 返回的 Promise 才 resolve,所以 async1 end 还不能入微任务队列
  4. 执行 new Promise 构造函数(同步):输出 promise1
  5. .then(() => console.log('promise2')) 入微任务队列(记为 M2)
  6. 同步:输出 script end
  7. 清空微任务:
    • 执行 M1:输出 async2 end,此时 async2 返回的 Promise resolve 了,async1 end 入微任务队列(记为 M3)
    • 执行 M2:输出 promise2.then(() => console.log('promise3')) 入微任务队列(记为 M4)
    • 执行 M3:输出 async1 end
    • 执行 M4:输出 promise3
  8. 取宏任务:输出 setTimeout

这道题的难点在于 async2 返回了一个 Promise 链(不是直接 resolve),导致 await 需要等待额外的微任务轮次才能继续。


自我检测

  • 能画出或描述事件循环的完整流程:宏任务 → 微任务 → 渲染 → 下一个宏任务
  • 能列举至少 3 种宏任务和 3 种微任务
  • 能准确分析 setTimeout + Promise 混合代码的执行顺序
  • 能解释 await 后面的代码为什么是微任务(等价于 .then
  • 能说清楚 new Promise(executor) 中 executor 是同步执行的
  • 能解释 requestAnimationFrame 在事件循环中的位置及其适用场景
  • 能说出 Node.js 事件循环与浏览器的至少两个关键差异
  • 能解释为什么无限微任务会卡死页面,而无限宏任务不会
  • 能在面试中完整、有条理地分析一道 async/await + Promise + setTimeout 的执行顺序题

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90