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

事件循环与异步 — 单线程的 JavaScript 如何同时做一百件事

JavaScript 是单线程语言——同一时刻只能执行一段代码。但你每天都在用 setTimeoutPromisefetch 等异步 API,它们从不阻塞页面。这看似矛盾的背后,是浏览器精心设计的事件循环(Event Loop)机制。

📋 开篇自测:你已经知道多少?

  1. setTimeout(fn, 0) 的回调是立即执行的吗?它和 Promise.resolve().then(fn) 谁先执行?
  2. 你能说出微任务(microtask)和宏任务(macrotask)各包含哪些 API 吗?
  3. requestAnimationFrame 的回调在事件循环的哪个阶段执行?

一、为什么需要事件循环

1.1 单线程的困境

JavaScript 被设计为单线程语言,原因很简单:如果两个线程同时操作 DOM,一个要删除节点,一个要修改节点内容,浏览器该听谁的?

但单线程带来一个严重问题:如果一个操作需要等待(比如网络请求需要 2 秒),主线程就会被阻塞 2 秒,页面完全无法响应。

同步模型的问题

时间 →

[发起HTTP请求] [等待...等待...等待...2秒] [处理响应] [继续执行]
               ↑                          ↑
          页面冻结!                    终于能动了
          用户抓狂!

1.2 异步模型的解决方案

事件循环的核心思路是:把等待交给其他线程,主线程继续干活,等结果回来再处理

异步模型

时间 →

主线程:   [发起HTTP请求][继续执行其他JS][处理用户点击][...][处理HTTP响应]
                 │                                          ↑
网络线程:        └──[发送请求]──[等待服务器]──[收到响应]──→ 放入任务队列

                                                    事件循环取出并执行

二、调用栈(Call Stack)

2.1 调用栈的基本概念

调用栈是 JavaScript 引擎追踪函数调用关系的数据结构。每调用一个函数,就在栈顶添加一个栈帧(Stack Frame);函数返回时,栈帧弹出。

调用栈示例

function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function printSquare(n) {
  const result = square(n);
  console.log(result);
}
printSquare(4);

调用栈变化:

Step 1:              Step 2:              Step 3:
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│              │     │              │     │ multiply(4,4)│
│              │     │ square(4)    │     │ square(4)    │
│printSquare(4)│     │printSquare(4)│     │printSquare(4)│
└──────────────┘     └──────────────┘     └──────────────┘

Step 4:              Step 5:              Step 6:
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│              │     │ console.log  │     │              │
│ square(4)    │     │printSquare(4)│     │              │
│printSquare(4)│     └──────────────┘     │              │
└──────────────┘                          └──────────────┘
multiply返回16        console.log(16)      栈清空

2.2 栈溢出

调用栈的大小是有限的(通常几万到几十万帧)。如果函数递归调用没有终止条件,就会发生栈溢出:

function forever() {
  forever(); // 无限递归
}
forever();
// Uncaught RangeError: Maximum call stack size exceeded

三、事件循环的运行机制

3.1 完整的事件循环模型

事件循环完整模型

┌─────────────────────────────────────────────────────────────┐
│                        浏览器环境                            │
│                                                             │
│  ┌──────────────────────────────────────────────────────┐  │
│  │                     主线程                            │  │
│  │                                                      │  │
│  │  ┌────────────────┐    ┌─────────────────────────┐  │  │
│  │  │   调用栈        │    │   微任务队列              │  │  │
│  │  │  (Call Stack)   │    │  (Microtask Queue)      │  │  │
│  │  │                │    │  Promise.then            │  │  │
│  │  │  执行中的函数    │    │  MutationObserver       │  │  │
│  │  │                │    │  queueMicrotask          │  │  │
│  │  └───────┬────────┘    └──────────┬──────────────┘  │  │
│  │          │                        │                  │  │
│  │          └────────┐  ┌────────────┘                  │  │
│  │                   ▼  ▼                               │  │
│  │            ┌──────────────┐                          │  │
│  │            │  事件循环     │                          │  │
│  │            │ (Event Loop) │                          │  │
│  │            └──────┬───────┘                          │  │
│  │                   ▲                                  │  │
│  │                   │                                  │  │
│  │  ┌────────────────┴──────────────────────────────┐  │  │
│  │  │            宏任务队列                           │  │  │
│  │  │           (Macrotask Queue)                    │  │  │
│  │  │  setTimeout / setInterval                      │  │  │
│  │  │  I/O 回调 / UI 渲染事件                        │  │  │
│  │  │  MessageChannel / postMessage                  │  │  │
│  │  └───────────────────────────────────────────────┘  │  │
│  │                                                      │  │
│  │  ┌───────────────────────────────────────────────┐  │  │
│  │  │       requestAnimationFrame 回调               │  │  │
│  │  │      (既非宏任务也非微任务,在渲染前执行)        │  │  │
│  │  └───────────────────────────────────────────────┘  │  │
│  └──────────────────────────────────────────────────────┘  │
│                                                             │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐     │
│  │ 定时器线程│ │ 网络线程  │ │ DOM事件   │ │ 其他线程  │     │
│  │          │ │          │ │ 处理线程  │ │          │     │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘     │
└─────────────────────────────────────────────────────────────┘

3.2 事件循环的执行步骤

每一轮事件循环按以下顺序执行:

一轮事件循环的步骤

1. 从宏任务队列中取出一个任务执行


2. 执行完毕后,检查微任务队列


3. 清空微任务队列(执行所有微任务,包括执行过程中新产生的微任务)


4. 判断是否需要渲染更新
   │  (浏览器通常以 60fps 目标,约每 16.67ms 渲染一次)

   ├── 需要渲染:
   │   ├── 执行 requestAnimationFrame 回调
   │   ├── 执行渲染流水线 (样式计算→布局→绘制→合成)
   │   └── 执行 requestIdleCallback (如果有空闲时间)

   └── 不需要渲染: 跳过


5. 回到第1步,取出下一个宏任务

3.3 微任务 vs 宏任务

类别API 示例执行时机
微任务Promise.then/catch/finally当前宏任务执行完毕后
MutationObserver立即清空所有微任务
queueMicrotask()在下一个宏任务之前
process.nextTick() (Node.js)
宏任务setTimeout / setInterval每轮事件循环取一个执行
setImmediate() (Node.js)
I/O 回调
UI 事件 (click, scroll等)
MessageChannel
postMessage

核心区别:微任务在当前宏任务结束后、下一个宏任务开始前,全部清空执行

🤔 想一想 如果在一个微任务的回调中又创建了新的微任务,会发生什么?这会不会导致无限循环?


四、经典面试题深度解析

4.1 题目一:基础执行顺序

console.log('1');

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

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

console.log('4');
执行过程分析

Step 1: 同步代码 (当前宏任务)
  执行 console.log('1') → 输出 1
  执行 setTimeout → 回调放入宏任务队列
  执行 Promise.resolve().then → 回调放入微任务队列
  执行 console.log('4') → 输出 4

Step 2: 清空微任务队列
  执行 Promise回调 → 输出 3

Step 3: 取出下一个宏任务
  执行 setTimeout回调 → 输出 2

最终输出: 1, 4, 3, 2

4.2 题目二:嵌套微任务

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

Promise.resolve()
  .then(() => {
    console.log('promise 1');
    return Promise.resolve('promise 2');
  })
  .then(val => {
    console.log(val);
  });

queueMicrotask(() => console.log('microtask'));

console.log('sync');
执行过程分析

同步阶段:
  setTimeout回调 → 宏任务队列
  Promise.then回调 → 微任务队列
  queueMicrotask回调 → 微任务队列
  输出: "sync"

微任务阶段 (全部清空):
  执行Promise回调1 → 输出: "promise 1"
    return Promise.resolve() 是一个 thenable,
    规范要求额外创建一个微任务来处理 resolve,
    因此 "promise 2" 不会紧接着执行
    (注: 这是 ECMAScript 规范要求的行为;Chrome 73 之前的 V8 实现与规范不一致,
     Chrome 73+ 已修正并与规范保持一致)
  执行queueMicrotask回调 → 输出: "microtask"
  执行Promise回调2 (延迟产生的) → 输出: "promise 2"

宏任务阶段:
  执行setTimeout回调 → 输出: "timeout"

最终输出: sync, promise 1, microtask, promise 2, timeout

4.3 题目三:async/await

async function foo() {
  console.log('foo start');
  const result = await bar();
  console.log('foo end:', result);
}

async function bar() {
  console.log('bar');
  return 'bar result';
}

console.log('before');
foo();
console.log('after');
关键理解: await 之后的代码等价于 .then() 回调 (微任务)

执行过程:
1. 输出: "before"
2. 调用foo()
   2a. 输出: "foo start"
   2b. 调用bar() → 输出: "bar"
   2c. await暂停foo, "foo end"部分进入微任务队列
3. 输出: "after"
4. 清空微任务:
   输出: "foo end: bar result"

最终输出: before, foo start, bar, after, foo end: bar result

五、requestAnimationFrame 的特殊地位

5.1 rAF 在事件循环中的位置

requestAnimationFrame(rAF)不属于宏任务也不属于微任务。它的回调在浏览器决定”这一帧需要渲染”时,在渲染流水线开始之前执行。

rAF 在事件循环中的位置

┌────────────────────────────────────────────────────────┐
│                    一轮事件循环                          │
│                                                        │
│ [宏任务] → [清空微任务] → [rAF回调] → [渲染] → [idle]  │
│                                                        │
│ 注意: rAF回调只在"需要渲染"的帧中执行                    │
│ 通常是每秒60次 (每16.67ms一次)                          │
└────────────────────────────────────────────────────────┘

5.2 rAF vs setTimeout 做动画

// 不推荐: 用setTimeout做动画
function animateWithTimeout(element) {
  let pos = 0;
  function step() {
    pos += 2;
    element.style.transform = `translateX(${pos}px)`;
    if (pos < 300) {
      setTimeout(step, 16); // 不能保证恰好在渲染前执行
    }
  }
  setTimeout(step, 16);
}

// 推荐: 用requestAnimationFrame做动画
function animateWithRAF(element) {
  let pos = 0;
  function step() {
    pos += 2;
    element.style.transform = `translateX(${pos}px)`;
    if (pos < 300) {
      requestAnimationFrame(step); // 保证在渲染前执行
    }
  }
  requestAnimationFrame(step);
}
rAF vs setTimeout 时序对比

rAF:
帧1: [宏任务] [微任务] [rAF: 更新位置] [渲染] ← 完美同步
帧2: [宏任务] [微任务] [rAF: 更新位置] [渲染] ← 完美同步

setTimeout(fn, 16):
帧1: [宏任务] [微任务]                  [渲染] ← 更新可能错过这帧
      [setTimeout: 更新位置]
帧2: [宏任务] [微任务]                  [渲染] ← 又错过了
  [setTimeout: 更新位置]                        ← 执行时机不可预测

5.3 requestIdleCallback

requestIdleCallback 的回调在浏览器空闲时执行——也就是当前帧的所有工作都完成后,如果还有时间剩余。

requestIdleCallback 的执行时机

│← ─────────── 16.67ms ─────────── →│
│                                     │
│[宏][微][rAF][渲染]     [idle回调]   │
│                        ↑            │
│                   剩余空闲时间       │
│                   执行低优先级工作   │

如果一帧内没有空闲时间,idle回调会被推迟
可以设置 timeout 参数确保最终被执行

六、setTimeout 的陷阱

6.1 最小延迟

即使你写 setTimeout(fn, 0),实际的最小延迟并不是 0ms:

  • 嵌套层级 ≤ 5 时:最小延迟约 1ms
  • 嵌套层级 > 5 时:最小延迟被强制提升为 4ms(即从第 6 层起生效,参见 HTML 规范
  • 非活跃标签页(后台标签):最小延迟被提升为 1000ms
// 嵌套setTimeout的延迟累积
setTimeout(() => {                    // ~1ms (第1层)
  setTimeout(() => {                  // ~1ms (第2层)
    setTimeout(() => {                // ~1ms (第3层)
      setTimeout(() => {              // ~1ms (第4层)
        setTimeout(() => {            // ~1ms (第5层)
          setTimeout(() => {          // ~4ms ← 第6层开始强制4ms
            console.log('finally');
          }, 0);
        }, 0);
      }, 0);
    }, 0);
  }, 0);
}, 0);

6.2 setInterval 的累积问题

setInterval 不会等待回调执行完毕就会安排下一次调用。如果回调执行时间超过间隔时间,回调会出现延迟——现代浏览器会丢弃中间被延迟的间隔以避免无限堆积,但这会导致执行间隔不均匀。

setInterval 累积问题

setInterval(callback, 100)

理想情况 (callback执行50ms):
│callback│  空闲  │callback│  空闲  │
0       50       100      150      200

实际情况 (callback偶尔执行150ms):
│    callback (150ms)     │callback│callback│  ← 堆积!
0                        150      200      250

解决方案: 用递归 setTimeout 替代
function repeat() {
  doWork();
  setTimeout(repeat, 100);  // 保证间隔
}

七、MutationObserver:DOM 变化的微任务监听

7.1 为什么 MutationObserver 使用微任务

MutationObserver 用于监听 DOM 变化。它的回调被设计为微任务,这意味着:

  1. DOM 修改完成后(当前宏任务结束),微任务阶段会立即处理变化通知
  2. 多次 DOM 修改会被合并为一次通知(批量处理)
  3. 在渲染发生之前就能收到通知
const observer = new MutationObserver((mutations) => {
  console.log('DOM changed:', mutations.length, 'mutations');
});

observer.observe(document.body, { childList: true, subtree: true });

// 即使连续添加3个元素,回调只触发一次
document.body.appendChild(document.createElement('div'));
document.body.appendChild(document.createElement('span'));
document.body.appendChild(document.createElement('p'));

// 微任务阶段: 回调执行一次,mutations包含3个记录
MutationObserver 的执行时序

[JS: 修改DOM] [JS: 修改DOM] [JS: 修改DOM] → [微任务: MutationObserver回调]
    ↓              ↓              ↓                        ↓
  记录变化1      记录变化2      记录变化3          一次性处理3个变化

八、Node.js 事件循环的差异

虽然本课程聚焦浏览器,但了解 Node.js 事件循环的差异有助于加深理解:

浏览器 vs Node.js 事件循环

浏览器:
[宏任务] → [所有微任务] → [rAF] → [渲染] → [下一个宏任务]

Node.js (libuv):
┌───────────────────────────┐
│   timers (setTimeout等)    │
├───────────────────────────┤
│   pending callbacks       │ ← 每个阶段之间都会
├───────────────────────────┤   清空微任务队列
│   idle, prepare           │
├───────────────────────────┤
│   poll (I/O)              │
├───────────────────────────┤
│   check (setImmediate)    │
├───────────────────────────┤
│   close callbacks         │
└───────────────────────────┘

关键差异:
- Node.js 有 setImmediate (浏览器没有)
- Node.js 有 process.nextTick (优先级高于Promise)
- Node.js 没有 rAF 和渲染阶段

九、实战:长任务分割

9.1 问题:长任务阻塞渲染

// 假设需要处理10万条数据
function processAll(data) {
  for (let i = 0; i < data.length; i++) {
    processItem(data[i]); // 假设每条耗时0.1ms
  }
  // 总耗时: 100000 * 0.1ms = 10000ms = 10秒
  // 这10秒内页面完全卡死!
}

9.2 方案一:用 setTimeout 分割

function processInChunks(data, chunkSize = 1000) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, data.length);
    for (; index < end; index++) {
      processItem(data[index]);
    }
    if (index < data.length) {
      setTimeout(processChunk, 0); // 让出主线程给渲染和用户交互
    }
  }

  processChunk();
}

9.3 方案二:用 requestIdleCallback 利用空闲时间

function processWhenIdle(data) {
  let index = 0;

  function processChunk(deadline) {
    while (index < data.length && deadline.timeRemaining() > 1) {
      processItem(data[index]);
      index++;
    }
    if (index < data.length) {
      requestIdleCallback(processChunk);
    }
  }

  requestIdleCallback(processChunk);
}
长任务分割效果对比

未分割:
[──────────────── 10秒的JS执行 ────────────────] [渲染]
 页面10秒无响应!

分割后 (setTimeout):
[chunk1][渲染][chunk2][渲染][chunk3][渲染]...
 每个chunk约1ms, 每帧都能渲染, 页面保持响应

分割后 (requestIdleCallback):
[渲染] [空闲:处理数据] [渲染] [空闲:处理数据] ...
 优先保证渲染,利用空闲时间处理数据

🤔 想一想 React 的 Fiber 架构本质上也是在做”长任务分割”。它使用了什么机制来实现可中断的渲染?为什么不直接用 requestIdleCallback?


十、动手实验

10.1 实验一:可视化事件循环

// 在浏览器控制台执行,观察输出顺序
console.log('=== 开始 ===');

setTimeout(() => console.log('宏任务1: setTimeout'), 0);

Promise.resolve().then(() => {
  console.log('微任务1: Promise');
  queueMicrotask(() => console.log('微任务2: 嵌套queueMicrotask'));
});

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

queueMicrotask(() => console.log('微任务3: queueMicrotask'));

console.log('=== 结束 ===');

10.2 实验二:观察 setTimeout 最小延迟

let count = 0;
const start = performance.now();

function nested() {
  count++;
  if (count <= 10) {
    const elapsed = (performance.now() - start).toFixed(2);
    console.log(`第${count}层: ${elapsed}ms`);
    setTimeout(nested, 0);
  }
}
setTimeout(nested, 0);
// 观察第5层以后延迟是否明显增加

10.3 实验三:Performance 面板观察事件循环

  1. 打开 Chrome DevTools 的 Performance 面板
  2. 录制页面交互过程
  3. 在 Main 轨道中观察 Task 的分布
  4. 查看哪些任务超过了 50ms(长任务,会显示红色三角标记)

十一、本章知识脉络总结

事件循环知识地图

事件循环
├── 核心概念
│   ├── 调用栈: 追踪函数调用,LIFO结构
│   ├── 宏任务队列: setTimeout/setInterval/I/O/UI事件
│   ├── 微任务队列: Promise.then/MutationObserver/queueMicrotask
│   └── 事件循环: 协调调用栈与任务队列

├── 执行顺序
│   ├── 1. 执行一个宏任务
│   ├── 2. 清空所有微任务 (包括新产生的)
│   ├── 3. 判断是否需要渲染
│   ├── 4. 执行rAF回调
│   ├── 5. 渲染
│   └── 6. 执行requestIdleCallback (如有空闲)

├── requestAnimationFrame
│   ├── 在渲染前执行,保证动画与显示同步
│   ├── 帧率与显示器刷新率一致
│   └── 替代setTimeout做动画

├── setTimeout 陷阱
│   ├── 最小延迟: 嵌套>5层时为4ms
│   ├── 后台标签: 延迟提升到1000ms
│   └── setInterval累积问题

├── 实战模式
│   ├── 长任务分割: setTimeout/rIC
│   ├── 批量DOM操作: 微任务合并
│   └── MutationObserver: DOM变化监听

└── Node.js 差异
    ├── 多阶段事件循环 (libuv)
    ├── process.nextTick (优先于Promise)
    └── setImmediate (check阶段)

📝 结尾自测:检验你的学习成果

  1. 事件循环的一轮包含哪些步骤?微任务和宏任务的执行时机有什么区别?
  2. 以下代码的输出顺序是什么?请说明原因。
    setTimeout(() => console.log('A'), 0);
    Promise.resolve().then(() => console.log('B'));
    requestAnimationFrame(() => console.log('C'));
    console.log('D');
  3. requestAnimationFrame 相比 setTimeout 做动画有什么优势?
  4. 为什么 setTimeout(fn, 0) 的实际延迟不是 0?嵌套调用时延迟如何变化?
  5. 如何将一个耗时 5 秒的同步任务分割成不阻塞页面的异步任务?给出两种方案。

下一章预告:JavaScript 代码执行过程中会不断创建对象、数组、闭包等数据。这些数据存在哪里?不再使用时如何被回收?下一章我们将深入 V8 的内存管理机制,了解堆结构、新生代/老生代的划分,以及 Scavenge、Mark-Sweep、Mark-Compact 三大垃圾回收算法。

购买课程解锁全部内容

前端进阶第一课:11 章掌握浏览器核心

¥29.90