深入理解 Event Loop(事件循环)
从浏览器架构到微任务调度,彻底搞懂 JavaScript 异步执行机制
什么是事件循环?
定义:事件循环(Event Loop)是 JavaScript 运行时的核心调度机制,它负责协调调用栈(Call Stack)、任务队列(Task Queue)和微任务队列(Microtask Queue)之间的工作,使单线程的 JS 能够非阻塞地处理异步操作。
涉及场景:
- 定时器调度:
setTimeout、setInterval、requestAnimationFrame的回调何时执行 - 网络请求回调:
fetch、XMLHttpRequest完成后的处理 - DOM 事件:用户点击、滚动、输入等事件的响应
- Promise 微任务:
.then()、async/await、MutationObserver的执行时机 - Node.js I/O:文件读写、数据库查询等异步操作的调度
作用:
- 非阻塞执行:让耗时操作(网络请求、文件I/O)不会冻结页面
- 确定执行顺序:定义了同步代码、微任务、宏任务的优先级规则
- 渲染协调:保证每一帧中 JS 执行和浏览器渲染的有序配合
- 面试核心考点:几乎所有前端面试都会考察 Event Loop 执行顺序题
为什么需要事件循环?
JavaScript 是单线程语言——同一时刻只能执行一段代码。这个设计源于它最初的用途:操作DOM。如果多个线程同时修改同一个DOM节点,浏览器将无法判断以谁为准。
但单线程带来一个严重问题:如果一个操作很耗时(如网络请求),整个页面都会卡死。
事件循环就是解决这个问题的机制——它让 JavaScript 能够在单线程中非阻塞地处理异步操作。
浏览器的多进程架构
在理解事件循环之前,需要先了解浏览器的架构:
┌─────────────────────────────────────────────────────┐
│ 浏览器主进程 │
│ (Browser Process - UI、地址栏、书签、前进后退) │
├─────────────────────────────────────────────────────┤
│ 渲染进程 (Renderer Process) │
│ 每个Tab一个(站点隔离后,每个站点一个) │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ JS引擎线程 │ │ 渲染线程 │ │ 合成线程 │ │
│ │ (V8) │ │ (Layout, │ │ │ │
│ │ ⚠️ 与渲染线 │ │ Paint) │ │ │ │
│ │ 程互斥! │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └───────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ 事件触发线程 │ │ 定时器线程 │ │ 网络线程 │ │
│ │ (Event Loop │ │ (setTimeout │ │ (HTTP请求) │ │
│ │ 管理者) │ │ 计时) │ │ │ │
│ └──────────────┘ └──────────────┘ └───────────┘ │
├─────────────────────────────────────────────────────┤
│ GPU进程 / 网络进程 / 插件进程 │
└─────────────────────────────────────────────────────┘关键点:
- JS引擎线程与渲染线程互斥 —— JS执行时页面不会重绘,反之亦然
- 定时器线程 负责
setTimeout/setInterval的计时(不在JS线程中计时) - 事件触发线程 管理事件循环,将回调放入任务队列
事件循环的完整流程
┌───────────────────────┐
│ 调用栈 (Call Stack)│
│ ┌─────────────────┐ │
│ │ 当前执行的函数 │ │
│ ├─────────────────┤ │
│ │ 上一个函数 │ │
│ ├─────────────────┤ │
│ │ 全局执行上下文 │ │
│ └─────────────────┘ │
└───────────┬───────────┘
│
┌───────────▼───────────┐
│ 调用栈是否为空? │
└───────────┬───────────┘
是 │
┌───────────▼───────────┐
│ 微任务队列是否为空? │──否──→ 取出所有微任务执行
└───────────┬───────────┘ │
是 │ ←──────────┘
┌───────────▼───────────┐
│ 是否需要渲染? │
│ (通常16.6ms一次) │──是──→ 执行渲染
└───────────┬───────────┘ │
否 │ ←──────────┘
┌───────────▼───────────┐
│ 宏任务队列是否为空? │──否──→ 取出一个宏任务执行
└───────────┬───────────┘ │
是 │ ←──────────┘
(等待新任务...)每一轮事件循环的精确步骤
- 执行一个宏任务(如果宏任务队列不为空)
- 检查微任务队列,执行所有微任务直到队列清空
- 微任务执行过程中产生的新微任务也会在本轮执行
- 判断是否需要渲染更新(浏览器决定,通常60fps = 16.6ms一次)
- 如果需要渲染:
- 触发
requestAnimationFrame回调 - 执行布局(Layout)
- 执行绘制(Paint)
- 触发
- 如果需要渲染:
- 判断是否有空闲时间
- 如果有:触发
requestIdleCallback回调
- 如果有:触发
- 回到第1步
宏任务 vs 微任务
宏任务(Macro Task / Task)
| 宏任务来源 | 说明 |
|---|---|
<script> 整体代码 | 第一个宏任务 |
setTimeout / setInterval | 定时器线程计时完毕后入队 |
setImmediate | Node.js 独有 |
requestAnimationFrame | 严格来说是渲染步骤的一部分,但行为类似宏任务 |
| I/O操作 | 文件读写、网络请求完成 |
| UI渲染/交互事件 | click、scroll、resize 等 |
MessageChannel | 创建独立的消息通道 |
postMessage | 跨窗口/iframe通信 |
微任务(Micro Task)
| 微任务来源 | 说明 |
|---|---|
Promise.then/catch/finally | Promise 状态变化时 |
async/await | await 之后的代码相当于 .then() |
queueMicrotask() | 手动添加微任务 |
MutationObserver | DOM变化观察 |
process.nextTick | Node.js 独有,优先级高于其他微任务 |
关键区别
// 微任务在当前宏任务结束后、下一个宏任务开始前、渲染前执行
// 宏任务之间可能穿插渲染
console.log('1. 同步代码(宏任务的一部分)');
setTimeout(() => {
console.log('5. setTimeout(新的宏任务)');
}, 0);
Promise.resolve().then(() => {
console.log('3. Promise.then(微任务)');
}).then(() => {
console.log('4. Promise.then链(微任务中产生的微任务,同一轮执行)');
});
queueMicrotask(() => {
console.log('3.5 queueMicrotask(微任务,按入队顺序执行)');
});
console.log('2. 同步代码(宏任务的一部分)');
// 输出顺序:1 → 2 → 3 → 3.5 → 4 → 5经典面试题逐行分析
题目1:基础综合
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve()
.then(function () {
console.log('promise1');
})
.then(function () {
console.log('promise2');
});
console.log('script end');分析过程:
第一轮事件循环:
├── 宏任务:script整体代码
│ ├── console.log('script start') → 输出 "script start"
│ ├── setTimeout callback → 放入宏任务队列
│ ├── Promise.resolve().then(cb1) → cb1放入微任务队列
│ └── console.log('script end') → 输出 "script end"
│
├── 微任务队列清空:
│ ├── 执行cb1: console.log('promise1') → 输出 "promise1"
│ │ └── .then(cb2) → cb2放入微任务队列
│ └── 执行cb2: console.log('promise2') → 输出 "promise2"
│
└── (可能渲染)
第二轮事件循环:
├── 宏任务:setTimeout callback
│ └── console.log('setTimeout') → 输出 "setTimeout"
└── 微任务队列:空最终输出: script start → script end → promise1 → promise2 → setTimeout
题目2:async/await
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
async1();
new Promise(function (resolve) {
console.log('promise1');
resolve();
}).then(function () {
console.log('promise2');
});
console.log('script end');关键知识点: await 可以理解为:
// await async2() 等价于:
async2().then(() => {
console.log('async1 end');
});逐步分析:
执行栈:
1. console.log('script start') → 输出 "script start"
2. setTimeout callback → 放入宏任务队列
3. 调用 async1()
3.1 console.log('async1 start') → 输出 "async1 start"
3.2 调用 async2()
console.log('async2') → 输出 "async2"
3.3 await 后面的代码 → 放入微任务队列(相当于.then)
4. new Promise(executor)
4.1 console.log('promise1') → 输出 "promise1"
4.2 resolve() → .then(cb) 放入微任务队列
5. console.log('script end') → 输出 "script end"
微任务队列清空:
6. async1 await后面的代码
console.log('async1 end') → 输出 "async1 end"
7. Promise.then callback
console.log('promise2') → 输出 "promise2"
下一轮宏任务:
8. setTimeout callback
console.log('setTimeout') → 输出 "setTimeout"最终输出: script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout
题目3:复杂嵌套
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => {
console.log('promise1');
});
}, 0);
setTimeout(() => {
console.log('timeout2');
Promise.resolve().then(() => {
console.log('promise2');
});
}, 0);
Promise.resolve().then(() => {
console.log('promise3');
setTimeout(() => {
console.log('timeout3');
}, 0);
});
console.log('start');分析:
第一轮(script整体):
├── setTimeout1 callback → 宏任务队列
├── setTimeout2 callback → 宏任务队列
├── Promise.then(cb3) → 微任务队列
└── console.log('start') → 输出 "start"
微任务:
├── cb3: console.log('promise3') → 输出 "promise3"
│ └── setTimeout3 callback → 宏任务队列
第二轮(宏任务:timeout1):
├── console.log('timeout1') → 输出 "timeout1"
└── Promise.then(cb1) → 微任务队列
微任务:
└── cb1: console.log('promise1') → 输出 "promise1"
第三轮(宏任务:timeout2):
├── console.log('timeout2') → 输出 "timeout2"
└── Promise.then(cb2) → 微任务队列
微任务:
└── cb2: console.log('promise2') → 输出 "promise2"
第四轮(宏任务:timeout3):
└── console.log('timeout3') → 输出 "timeout3"最终输出: start → promise3 → timeout1 → promise1 → timeout2 → promise2 → timeout3
题目4:微任务无限循环
// ⚠️ 危险代码 - 微任务会阻塞渲染!
function infiniteMicrotask() {
Promise.resolve().then(() => {
console.log('microtask');
infiniteMicrotask(); // 每次微任务中又产生新微任务
});
}
infiniteMicrotask();
// 微任务队列永远不会清空
// → 永远不会执行下一个宏任务
// → 永远不会渲染
// → 页面卡死!
// 对比:setTimeout 不会阻塞
function infiniteTimeout() {
setTimeout(() => {
console.log('timeout');
infiniteTimeout();
}, 0);
}
infiniteTimeout();
// 每次都是新的宏任务,宏任务之间可以渲染
// → 页面不会卡死requestAnimationFrame 的执行时机
requestAnimationFrame(rAF)在事件循环中的位置比较特殊:
宏任务 → 微任务全部清空 → [如果需要渲染] → rAF → 布局 → 绘制 → 宏任务...console.log('1');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
requestAnimationFrame(() => console.log('rAF'));
console.log('2');
// 确定的顺序:1 → 2 → promise → ...
// rAF 和 timeout 的顺序不确定!
// rAF 在渲染前执行,可能在 timeout 之前或之后
// 取决于浏览器是否决定在这一帧渲染// rAF 的典型用途:读写分离优化
// 错误做法:在循环中反复读写DOM,触发强制同步布局
elements.forEach(el => {
const width = el.offsetWidth; // 读 → 触发Layout
el.style.width = width + 10 + 'px'; // 写 → 使Layout失效
// 下一次循环读取时又触发Layout = 布局抖动!
});
// 正确做法:批量读取,然后在 rAF 中批量写入
const widths = elements.map(el => el.offsetWidth); // 批量读
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px'; // 批量写
});
});requestIdleCallback 的执行时机
一帧的时间线(约16.6ms):
┌────────┬──────────┬──────────┬──────────┬──────────┐
│ 宏任务 │ 微任务 │ rAF │ 布局+绘制 │ 空闲时间 │
│ │ │ │ │ rIC在此 │
└────────┴──────────┴──────────┴──────────┴──────────┘// requestIdleCallback - 利用空闲时间执行低优先级任务
requestIdleCallback((deadline) => {
// deadline.timeRemaining() 返回当前帧剩余的毫秒数
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
processTask(tasks.shift());
}
// 如果还有任务没处理完,下一帧继续
if (tasks.length > 0) {
requestIdleCallback(processRemainingTasks);
}
}, { timeout: 2000 }); // 最多等2秒,即使没有空闲也会执行Node.js 的事件循环
Node.js 的事件循环与浏览器有显著差异,基于 libuv 库实现:
┌───────────────────────────┐
┌─>│ timers │ setTimeout / setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ pending callbacks │ I/O回调(上一轮延迟的)
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ idle, prepare │ 仅内部使用
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ poll │ 检索新的I/O事件
│ │ (执行I/O相关回调) │ 计算应该阻塞多久
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ check │ setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ close callbacks │ socket.on('close', ...)
│ └─────────────┬─────────────┘
└─────────────────┘
⚠️ 每个阶段之间都会执行所有微任务
(process.nextTick 优先于 Promise.then)Node.js 关键差异
// 1. process.nextTick vs Promise.then
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// Node.js 中:nextTick → promise
// nextTick 优先级高于所有其他微任务
// 2. setTimeout vs setImmediate(不确定顺序)
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 顺序不确定!取决于进程性能、系统时钟精度
// 3. 在 I/O 回调中,setImmediate 一定先于 setTimeout
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// 确定顺序:immediate → timeout
// 因为 I/O 回调在 poll 阶段,下一个阶段是 check(setImmediate)
// 4. Node.js 11+ 行为与浏览器对齐
// 每执行一个宏任务后,立即清空微任务队列
// (之前的版本是每个阶段结束后才清空微任务)完整对比
// Node.js vs 浏览器的事件循环差异
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => console.log('promise1'));
}, 0);
setTimeout(() => {
console.log('timeout2');
Promise.resolve().then(() => console.log('promise2'));
}, 0);
// 浏览器 & Node.js 11+:
// timeout1 → promise1 → timeout2 → promise2
// (每个宏任务后立即执行微任务)
// Node.js 10 及更早:
// timeout1 → timeout2 → promise1 → promise2
// (同一阶段的所有宏任务执行完后,再清空微任务)queueMicrotask API
queueMicrotask() 是专门用于入队微任务的标准API:
// 使用 queueMicrotask(推荐)
queueMicrotask(() => {
console.log('microtask');
});
// 等价于(但语义更清晰):
Promise.resolve().then(() => {
console.log('microtask via promise');
});
// 实际用例:批量DOM更新
let pending = false;
const updates = [];
function scheduleUpdate(update) {
updates.push(update);
if (!pending) {
pending = true;
queueMicrotask(() => {
const batch = [...updates];
updates.length = 0;
pending = false;
// 批量执行所有更新
batch.forEach(fn => fn());
});
}
}
// 同步代码中多次调用,只会执行一次批量更新
scheduleUpdate(() => el1.style.color = 'red');
scheduleUpdate(() => el2.style.color = 'blue');
scheduleUpdate(() => el3.style.color = 'green');
// → 三个更新合并为一次微任务执行终极面试题
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
await async3();
console.log('async1 final');
}
async function async2() {
console.log('async2');
return new Promise((resolve) => {
console.log('async2 promise');
resolve();
}).then(() => {
console.log('async2 then');
});
}
async function async3() {
console.log('async3');
}
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');详细分析:
同步代码执行:
1. "script start"
2. 调用 async1()
→ "async1 start"
→ 调用 async2()
→ "async2"
→ "async2 promise"(Promise executor 是同步的)
→ .then(cb: "async2 then") → 微任务队列 [async2Then]
→ await 等待 async2() 返回的 Promise 完成
3. "promise1"(Promise executor 同步执行)
→ .then(cb: "promise2") → 微任务队列 [async2Then, promise2Cb]
4. "script end"
第一轮微任务:
5. 执行 async2Then → "async2 then"
→ async2 的 Promise 完成 → await 后面的代码入队
→ 微任务队列 [promise2Cb, async1EndCb]
6. 执行 promise2Cb → "promise2"
→ .then(cb: "promise3") → 微任务队列 [async1EndCb, promise3Cb]
7. 执行 async1EndCb → "async1 end"
→ 调用 async3() → "async3"
→ await → 微任务队列 [promise3Cb, async1FinalCb]
8. 执行 promise3Cb → "promise3"
9. 执行 async1FinalCb → "async1 final"
第二轮宏任务:
10. "setTimeout"最终输出:
script start
async1 start
async2
async2 promise
promise1
script end
async2 then
promise2
async1 end
async3
promise3
async1 final
setTimeout常见误区
1. setTimeout(fn, 0) 不是真正的 0ms
// 最小延迟约 4ms(嵌套超过5层时)
// HTML规范规定:嵌套层级 > 5 时,最小延迟 4ms
function nestedTimeout(depth = 0) {
const start = performance.now();
setTimeout(() => {
console.log(`depth ${depth}: ${(performance.now() - start).toFixed(2)}ms`);
if (depth < 10) nestedTimeout(depth + 1);
}, 0);
}
nestedTimeout();
// depth 0: 0.xx ms
// depth 1: 0.xx ms
// ...
// depth 5+: 4.xx ms(最小延迟生效)2. 微任务不是"下一轮"事件循环
// 微任务在当前宏任务的末尾执行,不是新的事件循环
// 它是当前轮次的"收尾工作"
console.log('start');
queueMicrotask(() => console.log('microtask'));
console.log('end');
// start → end → microtask(微任务在同步代码之后、渲染之前)3. async/await 不会创建新的微任务(只是转换)
// await 后面的代码就是 .then() 的语法糖
async function foo() {
console.log(1);
const result = await bar();
console.log(result); // 这行代码相当于 .then(result => ...)
}总结
┌──────────────────────────────────────────────────────────┐
│ 事件循环核心规则 │
├──────────────────────────────────────────────────────────┤
│ 1. 执行一个宏任务(最初是 <script> 整体代码) │
│ 2. 执行所有微任务(包括执行过程中产生的新微任务) │
│ 3. 如果需要渲染 → rAF → Layout → Paint │
│ 4. 如果有空闲时间 → requestIdleCallback │
│ 5. 回到第1步 │
├──────────────────────────────────────────────────────────┤
│ 优先级:同步代码 > 微任务 > rAF > 宏任务 > rIC │
│ Node.js: process.nextTick > Promise.then > 其他微任务 │
├──────────────────────────────────────────────────────────┤
│ 面试解题步骤: │
│ ① 找出所有同步代码,确定初始输出 │
│ ② 将异步回调分类(宏/微),放入对应队列 │
│ ③ 同步代码执行完后,先清空微任务队列 │
│ ④ 取出一个宏任务执行,重复②③ │
└──────────────────────────────────────────────────────────┘