今天咱们聊一个前端最核心的机制:JavaScript 单线程却能处理异步操作的秘密------事件循环(Event Loop)。
你可能遇到过这种情况:用 setTimeout 延迟执行代码,或者用 fetch 发网络请求时,主线程并没有卡住,反而能继续执行后面的代码。明明 JS 是单线程的(同一时间只能做一件事),这是怎么做到的?
别急,我会用 "底层机制+代码演示+流程拆解" 的方式,带你彻底搞懂事件循环的运行原理。
前置知识:JS 单线程的本质
什么是单线程?
单线程指的是 JavaScript 引擎(如 V8)的主线程同一时间只能执行一个任务。这意味着:
- 所有同步代码必须按顺序执行,前一个任务没完成,后一个任务必须等待;
- 如果有一个任务耗时很长(比如死循环、大量计算),后面的任务会被"阻塞"(卡住)。
举个栗子:
js
console.log('开始');
while (true) { // 死循环(耗时任务)
// 主线程被卡死,后面的代码永远执行不到
}
console.log('结束'); // 永远不会输出
这就是单线程的"缺陷"------容易阻塞。但 JS 又必须处理异步操作(比如网络请求、文件读取),否则早就被淘汰了。
为什么 JS 必须支持异步?
因为浏览器需要处理大量"耗时但不阻塞主线程"的操作:
- 网络请求(
fetch、XMLHttpRequest); - 定时器(
setTimeout、setInterval); - 事件监听(点击、滚动);
- 文件操作(Node.js 中的
fs模块)。
如果这些操作都用同步方式执行,浏览器会直接"假死"(比如点一个按钮要等 3 秒才能响应)。所以 JS 必须设计一套机制,让这些耗时操作"在后台执行",不阻塞主线程。
事件循环的核心角色:谁在"调度"任务?
要理解事件循环,必须先明确 JS 运行时的几个核心"角色":
| 角色 | 职责 | 所属环境 |
|---|---|---|
| 调用栈(Call Stack) | 存储同步任务的执行上下文(函数调用链),按"后进先出"顺序执行 | JS 引擎(V8) |
| Web APIs | 浏览器提供的"后台线程能力"(如定时器、网络请求、DOM 事件监听) | 浏览器(非 JS 引擎) |
| 任务队列(Task Queue) | 存储异步任务完成后的"回调函数",等待事件循环调度执行 | 浏览器 |
| 事件循环(Event Loop) | 不断检查调用栈是否为空,若为空则从任务队列中取出回调函数执行 | 浏览器 |
一句话总结 :
JS 主线程(调用栈)只负责执行同步代码;遇到异步操作(如 setTimeout),就交给 Web APIs 后台处理;Web APIs 完成任务后,把回调函数"丢"到任务队列;事件循环负责"盯着"调用栈,一旦调用栈空了,就从任务队列里"捞"回调函数来执行。
事件循环的执行流程(附代码演示)
基础流程:同步任务 → 微任务 → 宏任务
事件循环的执行顺序有一个核心规则:同步任务优先,微任务(Microtask)次之,宏任务(Macrotask)最后。
关键概念区分:
- 同步任务 :直接在调用栈中执行的任务(如
console.log、普通函数调用); - 微任务 :由
Promise.then、MutationObserver等 API 产生的回调,优先级高于宏任务; - 宏任务 :由
setTimeout、setInterval、I/O、DOM 事件等 API 产生的回调,优先级低于微任务。
代码演示 1:基础执行顺序
js
console.log('1: 同步任务');
setTimeout(() => {
console.log('3: 宏任务(setTimeout)');
}, 0);
Promise.resolve()
.then(() => {
console.log('2: 微任务(Promise.then)');
})
.then(() => {
console.log('4: 微任务(另一个 Promise.then)');
});
输出顺序 :1 → 2 → 4 → 3
执行流程拆解:
-
执行同步任务 :调用栈先执行
console.log('1'),输出1; -
处理异步任务:
- 遇到
setTimeout,将其交给 Web APIs 的定时器线程处理(定时 0ms); - 遇到
Promise.resolve().then(),将其回调(第一个then)加入微任务队列;
- 遇到
-
同步任务执行完毕:调用栈清空;
-
执行微任务 :事件循环检查微任务队列,按顺序执行两个
then回调,输出2和4; -
执行宏任务 :微任务队列清空后,事件循环检查宏任务队列,执行
setTimeout的回调,输出3。
进阶流程:嵌套异步任务
实际代码中,异步任务可能嵌套(比如 setTimeout 里又有 Promise),事件循环的处理逻辑依然遵循"同步→微任务→宏任务"的顺序,但需要注意每一轮事件循环只处理一个宏任务(除非宏任务队列为空)。
代码演示 2:嵌套异步任务
js
console.log('A: 同步任务');
setTimeout(() => {
console.log('B: 宏任务(外层 setTimeout)');
// 嵌套的微任务
Promise.resolve().then(() => {
console.log('C: 微任务(外层 setTimeout 内的 then)');
});
// 嵌套的宏任务
setTimeout(() => {
console.log('D: 宏任务(内层 setTimeout)');
}, 0);
}, 0);
// 顶层的微任务
Promise.resolve().then(() => {
console.log('E: 微任务(顶层 then)');
});
输出顺序 :A → E → B → C → D
执行流程拆解:
-
执行同步任务 :输出
A; -
处理异步任务:
- 外层
setTimeout交给定时器线程(0ms); - 顶层
Promise.then回调加入微任务队列;
- 外层
-
同步任务结束:调用栈清空;
-
执行微任务 :执行顶层
then回调,输出E; -
第一轮事件循环结束,开始处理宏任务队列:
- 取出外层
setTimeout的回调执行,输出B; - 外层
setTimeout回调中遇到新的Promise.then,将其加入微任务队列; - 外层
setTimeout回调中遇到内层setTimeout,交给定时器线程(0ms);
- 取出外层
-
第一轮宏任务处理完毕,立即执行新产生的微任务:
- 执行外层
setTimeout内的then回调,输出C;
- 执行外层
-
第二轮事件循环开始,处理宏任务队列:
- 取出内层
setTimeout的回调执行,输出D。
- 取出内层
特殊场景:DOM 事件与异步
DOM 事件(如点击、滚动)的回调属于宏任务,但它们的触发时机与事件循环密切相关。
代码演示 3:DOM 事件回调
html
<button id="btn">点击我</button>
<script>
console.log('1: 同步任务');
const btn = document.getElementById('btn');
btn.addEventListener('click', () => {
console.log('3: DOM 事件回调(宏任务)');
// 事件回调中的微任务
Promise.resolve().then(() => {
console.log('4: 事件回调内的微任务');
});
});
setTimeout(() => {
console.log('2: 宏任务(setTimeout)');
}, 0);
</script>
可能的输出顺序(取决于用户何时点击按钮):
- 如果用户在
setTimeout回调执行前点击按钮:1 → 3 → 4 → 2; - 如果用户在
setTimeout回调执行后点击按钮:1 → 2 → 3 → 4。
执行流程拆解:
-
执行同步任务,输出
1; -
注册
setTimeout(宏任务)和click事件监听(宏任务); -
若用户先点击按钮:
- 点击事件触发,回调被加入宏任务队列;
setTimeout回调也被加入宏任务队列;- 事件循环先处理
click宏任务(因为先进入队列),输出3; - 执行
click回调中的微任务,输出4; - 最后处理
setTimeout宏任务,输出2;
-
若用户后点击按钮:
setTimeout回调先被处理,输出2;- 然后处理
click宏任务,输出3; - 最后执行
click回调中的微任务,输出4。
为什么 JS 单线程还能"异步"?核心答案
现在回到最初的问题:JS 单线程却能处理异步,本质原因是什么?
异步操作不由 JS 引擎主线程执行
JS 引擎(如 V8)只负责执行同步代码和调度任务。像网络请求、定时器这些耗时操作,实际是由浏览器的其他线程(如网络线程、定时器线程)完成的。JS 主线程只需要"注册"异步任务(比如告诉定时器线程"3 秒后执行这个函数"),然后继续执行后面的同步代码。
事件循环负责"调度"异步回调
当异步任务完成后(比如定时器到时间、网络请求返回数据),浏览器会将对应的回调函数放入任务队列。事件循环会不断检查调用栈是否为空:
- 如果调用栈空了,就从任务队列中取出回调函数,交给调用栈执行;
- 如果调用栈不空(还在执行同步代码),事件循环就继续等待。
微任务和宏任务的优先级设计
通过区分微任务和宏任务,JS 可以更精细地控制异步回调的执行顺序。微任务(如 Promise.then)会在当前调用栈清空后立即执行(属于"本轮事件循环"),而宏任务(如 setTimeout)会在下一轮事件循环执行。这种设计保证了异步操作的高效调度。
事件循环的"隐藏阶段":浏览器和 Node.js 的差异
你可能不知道:浏览器和 Node.js 的事件循环实现完全不同!虽然核心目标一致(调度异步任务),但具体阶段划分和执行顺序差异很大。
浏览器的事件循环:聚焦"渲染"与"用户交互"
浏览器的事件循环围绕 "页面渲染" 设计,核心目标是让用户界面流畅响应。它的执行流程可简化为以下步骤(简化版):
markdown
1. 执行调用栈中的同步任务 →
2. 执行所有微任务(Microtask Queue) →
3. 检查是否需要渲染(如距离上次渲染超过 16ms) →
a. 执行 UI 渲染(重排/重绘) →
4. 从宏任务队列(Macrotask Queue)中取出一个任务执行 →
5. 重复步骤 2~4(直到所有队列为空)
关键细节:
- 渲染时机 :浏览器默认以 60Hz 刷新率(约 16ms/帧)渲染页面。如果同步任务执行时间超过 16ms,会导致"掉帧"(页面卡顿)。因此,事件循环会在每轮宏任务后检查是否需要渲染,避免长时间阻塞渲染。
- 宏任务的"公平性":浏览器宏任务队列是"队列优先"(FIFO),但为了保证交互响应,用户触发的事件(如点击、滚动)的回调会被"插队"到队列头部,优先执行。
代码演示:渲染与事件循环的关系
js
console.log('开始');
// 同步任务:耗时 20ms(超过 16ms 渲染间隔)
const start = performance.now();
while (performance.now() - start < 20) {}
// 宏任务:setTimeout
setTimeout(() => {
console.log('setTimeout 执行');
}, 0);
// 微任务:Promise.then
Promise.resolve().then(() => {
console.log('Promise.then 执行');
});
// 输出顺序:开始 → Promise.then → setTimeout 执行
执行流程:
- 同步任务耗时 20ms,导致页面无法在第 16ms 渲染(掉帧);
- 同步任务结束后,先执行微任务(
Promise.then); - 最后执行宏任务(
setTimeout)。
Node.js 的事件循环:聚焦" I/O 与异步操作"
Node.js 的事件循环是为 服务器端高并发 I/O 设计的,核心目标是高效处理大量异步 I/O 请求(如文件读写、数据库查询)。它的阶段划分更复杂,具体分为 6 个阶段(按执行顺序):
| 阶段 | 职责 | 对应的宏任务类型 |
|---|---|---|
| timers | 执行 setTimeout、setInterval 的回调 |
定时器相关宏任务 |
| I/O callbacks | 处理上一轮未完成的 I/O 错误回调(如 TCP 连接错误) | 系统级 I/O 错误回调 |
| idle/prepare | 内部使用,仅在 Node.js 启动时执行 | 无 |
| poll | 核心阶段:等待新的 I/O 事件(如文件读取完成、网络请求响应) | 大部分 I/O 相关宏任务(除 setImmediate) |
| check | 执行 setImmediate 的回调 |
setImmediate 宏任务 |
| close callbacks | 执行关闭事件的回调(如 socket.on('close')) |
连接关闭相关回调 |
关键细节:
-
setImmediatevssetTimeout(0) :setImmediate的回调在check阶段执行(本轮事件循环的最后阶段);setTimeout(0)的回调在timers阶段执行(下一轮事件循环的开始阶段)。
因此,setImmediate永远比setTimeout(0)先执行(在 Node.js 中)。
代码演示:Node.js 事件循环阶段
js
const fs = require('fs');
// 阶段 1:timers(setTimeout)
setTimeout(() => {
console.log('1: setTimeout(timers 阶段)');
}, 0);
// 阶段 4:poll(fs.readFile 是 I/O 操作)
fs.readFile(__filename, () => {
console.log('2: fs.readFile 回调(poll 阶段)');
// 阶段 5:check(setImmediate)
setImmediate(() => {
console.log('3: setImmediate(check 阶段)');
});
// 阶段 1:timers(嵌套的 setTimeout)
setTimeout(() => {
console.log('4: 嵌套 setTimeout(timers 阶段)');
}, 0);
});
// 阶段 5:check(直接调用 setImmediate)
setImmediate(() => {
console.log('5: 顶层 setImmediate(check 阶段)');
});
输出顺序 (每次运行可能略有不同,但 setImmediate 总在 setTimeout(0) 前):
makefile
1: setTimeout(timers 阶段)
2: fs.readFile 回调(poll 阶段)
3: setImmediate(check 阶段)
5: 顶层 setImmediate(check 阶段)
4: 嵌套 setTimeout(timers 阶段)
宏任务与微任务的"细分家族":你以为的"宏任务"可能不是真宏任务
之前我们简单区分了宏任务和微任务,但实际上它们的"家族成员"各有不同。明确每个任务的来源,能帮你更精准地控制执行顺序。
宏任务(Macrotask)的常见类型
宏任务是"需要等待当前调用栈和微任务队列清空后才能执行"的任务,常见来源包括:
| 来源 | 示例 | 说明 |
|---|---|---|
| 定时器 | setTimeout、setInterval |
基于时间的延迟执行 |
| I/O 操作 | fs.readFile(Node.js)、fetch |
网络请求、文件读写等异步 I/O |
| UI 渲染(浏览器) | requestAnimationFrame(部分场景) |
浏览器渲染帧的回调 |
| 事件监听 | click、scroll 等 DOM 事件回调 |
用户交互触发的回调 |
setImmediate(Node.js) |
setImmediate(() => {}) |
Node.js 特有的"本轮事件循环结束后"执行 |
微任务(Microtask)的常见类型
微任务是"在当前调用栈清空后立即执行"的任务,优先级高于宏任务,常见来源包括:
| 来源 | 示例 | 说明 |
|---|---|---|
| Promise 回调 | Promise.then()、Promise.catch() |
Promise 状态变为 fulfilled 或 rejected 后的回调 |
| MutationObserver(浏览器) | new MutationObserver(callback) |
监听 DOM 变化的回调 |
| process.nextTick(Node.js) | process.nextTick(() => {}) |
Node.js 特有的"微任务队列优先级最高"的任务(比普通微任务更早执行) |
| async/await(本质是 Promise) | async function() { await ... } |
await 后的代码会被包装成微任务 |
关键细节:
async/await的底层是 Promise:await会暂停函数执行,将后续代码包装成微任务,等待 Promise 解决后执行。process.nextTick在 Node.js 中是"伪微任务":它的优先级比普通微任务(如Promise.then)更高,会在当前调用栈清空后立即执行(甚至在宏任务之前)。
代码演示:Node.js 中 process.nextTick 的优先级
js
console.log('开始');
setTimeout(() => {
console.log('1: setTimeout(宏任务)');
}, 0);
Promise.resolve().then(() => {
console.log('2: Promise.then(微任务)');
});
process.nextTick(() => {
console.log('3: process.nextTick(伪微任务)');
});
console.log('结束');
输出顺序:
yaml
开始 → 结束 → 3: process.nextTick → 2: Promise.then → 1: setTimeout
调试工具:用 Chrome DevTools"看见"事件循环
理论看懂了,但如何验证?推荐用 Chrome DevTools 的 Performance 面板 直接观察事件循环的执行过程。
步骤 1:打开 Performance 面板
- 按
F12打开 DevTools; - 点击
Performance标签; - 点击左上角的
录制按钮(或按Ctrl+E)开始记录。
步骤 2:执行测试代码
在控制台执行以下代码:
js
console.log('同步任务');
setTimeout(() => {
console.log('宏任务(setTimeout)');
}, 0);
Promise.resolve().then(() => {
console.log('微任务(Promise.then)');
});
步骤 3:分析记录结果
停止录制后,Performance 面板会显示时间线。重点关注 Main 线程 的调用栈和任务队列:
- 同步任务 :显示为连续的调用栈(如
console.log('同步任务')); - 微任务 :在调用栈清空后,显示为
Promise.then的执行; - 宏任务 :在微任务执行后,显示为
setTimeout的回调执行。
截图示例(文字描述):
- 时间轴 0ms:开始执行同步任务;
- 时间轴 1ms:同步任务结束,调用栈清空;
- 时间轴 1ms:执行微任务(
Promise.then); - 时间轴 2ms:微任务结束,开始执行宏任务(
setTimeout)。
实战避坑:事件循环的常见陷阱
陷阱 1:定时器"延迟不准"
你可能遇到过:setTimeout(fn, 100) 实际执行时间远大于 100ms。原因是 同步任务耗时过长,阻塞了事件循环,导致定时器回调无法按时执行。
示例:
js
setTimeout(() => {
console.log('定时器执行'); // 实际延迟可能远大于 100ms
}, 100);
// 同步任务耗时 1000ms
const start = Date.now();
while (Date.now() - start < 1000) {}
解决方案:
- 避免在主线程执行耗时操作(如大数据计算);
- 将耗时任务拆分为小任务(用
setTimeout或requestIdleCallback分段执行); - 使用 Web Workers(浏览器)或 Worker Threads(Node.js)将任务放到后台线程。
陷阱 2:微任务"堆积导致页面卡死"
微任务会在当前调用栈清空后立即执行,如果微任务队列无限增长(如递归调用 Promise.then),会导致页面永远无法渲染(因为事件循环被微任务阻塞)。
示例:
js
function recursiveMicrotask() {
Promise.resolve().then(() => {
console.log('微任务执行');
recursiveMicrotask(); // 递归添加微任务
});
}
recursiveMicrotask();
现象:
- 控制台会无限输出
微任务执行; - 页面无法响应点击、滚动等交互(因为事件循环被微任务队列占满,无法执行渲染或事件回调)。
解决方案:
- 避免在微任务中递归调用自身;
- 限制微任务的数量(如设置最大递归次数);
- 将部分任务转为宏任务(如用
setTimeout包裹)。
陷阱 3:Node.js 中 setImmediate 与 setTimeout 的"竞争"
在 Node.js 中,setImmediate 和 setTimeout(0) 的执行顺序可能不稳定(取决于事件循环的启动时间)。
示例:
js
setTimeout(() => {
console.log('setTimeout(timers 阶段)');
}, 0);
setImmediate(() => {
console.log('setImmediate(check 阶段)');
});
可能的输出:
- 第一次运行:
setTimeout先执行(如果timers阶段的延迟接近 0); - 第二次运行:
setImmediate先执行(如果timers阶段没有其他任务)。
解决方案:
- 如果需要严格的执行顺序,避免依赖
setImmediate和setTimeout(0)的顺序; - 明确使用
setImmediate表示"本轮事件循环结束后执行",setTimeout表示"延迟指定时间后执行"。
常见误区与总结
误区 1:JS 是多线程的?
错误。JS 主线程是单线程的,但浏览器环境(如 Web APIs)是多线程的。JS 通过事件循环与这些线程协作,实现了"异步"的效果。
误区 2:setTimeout(0) 会立即执行?
错误 。setTimeout(0) 表示"将回调函数放入宏任务队列,等待当前调用栈和微任务队列清空后立即执行",而不是立即执行。
误区 3:微任务比宏任务优先级高,所以会插队?
正确。微任务队列的优先级高于宏任务队列,所以每轮事件循环会先清空微任务队列,再处理宏任务队列。
事件循环的"口诀"
事件循环的核心是 "协作式调度":JS 主线程负责同步任务,异步任务交给其他线程处理,完成后通过回调函数"排队",事件循环按优先级(微任务→宏任务)调度执行。
同步任务先执行,调用栈里排好队;
遇到异步交后台,Web APIs 帮你忙;
定时网络事件等,完成回调放队列;
微任务队列优先级,当前循环接着跑;
宏任务队列下一轮,事件循环来回跑;
单线程不阻塞住,异步全靠事件环。