事件循环与异步 — 单线程的 JavaScript 如何同时做一百件事
JavaScript 是单线程语言——同一时刻只能执行一段代码。但你每天都在用
setTimeout、Promise、fetch等异步 API,它们从不阻塞页面。这看似矛盾的背后,是浏览器精心设计的事件循环(Event Loop)机制。
📋 开篇自测:你已经知道多少?
setTimeout(fn, 0)的回调是立即执行的吗?它和Promise.resolve().then(fn)谁先执行?- 你能说出微任务(microtask)和宏任务(macrotask)各包含哪些 API 吗?
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 变化。它的回调被设计为微任务,这意味着:
- DOM 修改完成后(当前宏任务结束),微任务阶段会立即处理变化通知
- 多次 DOM 修改会被合并为一次通知(批量处理)
- 在渲染发生之前就能收到通知
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 面板观察事件循环
- 打开 Chrome DevTools 的 Performance 面板
- 录制页面交互过程
- 在 Main 轨道中观察 Task 的分布
- 查看哪些任务超过了 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阶段)
📝 结尾自测:检验你的学习成果
- 事件循环的一轮包含哪些步骤?微任务和宏任务的执行时机有什么区别?
- 以下代码的输出顺序是什么?请说明原因。
setTimeout(() => console.log('A'), 0); Promise.resolve().then(() => console.log('B')); requestAnimationFrame(() => console.log('C')); console.log('D');requestAnimationFrame相比setTimeout做动画有什么优势?- 为什么
setTimeout(fn, 0)的实际延迟不是 0?嵌套调用时延迟如何变化?- 如何将一个耗时 5 秒的同步任务分割成不阻塞页面的异步任务?给出两种方案。
下一章预告:JavaScript 代码执行过程中会不断创建对象、数组、闭包等数据。这些数据存在哪里?不再使用时如何被回收?下一章我们将深入 V8 的内存管理机制,了解堆结构、新生代/老生代的划分,以及 Scavenge、Mark-Sweep、Mark-Compact 三大垃圾回收算法。
购买课程解锁全部内容
前端进阶第一课:11 章掌握浏览器核心
¥29.90