一、为什么需要事件循环?
JavaScript 是一门单线程语言,同一时间只能执行一个任务。但在实际开发中,我们会遇到大量异步操作(比如网络请求、定时器、DOM 事件),如果这些操作都阻塞主线程,页面就会卡死。
事件循环的核心作用就是:协调主线程、调用栈、任务队列,让单线程的 JS 能够处理异步任务,实现非阻塞执行。
二、核心概念铺垫
在讲事件循环前,先明确几个关键名词:
- 调用栈(Call Stack):也叫执行栈,是 JS 执行同步代码的地方,遵循“先进后出”原则。每执行一个函数,就把它压入栈;函数执行完,就弹出栈。
- 任务队列(Task Queue):存放异步任务的回调函数,分为两类:
- 宏任务(Macrotask):粒度较大的异步任务,常见的有:
script整体代码、setTimeout/setInterval、DOM 事件、AJAX 请求、setImmediate(Node 环境)、I/O 操作。 - 微任务(Microtask):粒度较小的异步任务,优先级高于宏任务,常见的有:
Promise.then/catch/finally、async/await(本质是 Promise 语法糖)、queueMicrotask()、MutationObserver(浏览器环境)、process.nextTick(Node 环境,优先级最高)。
- 宏任务(Macrotask):粒度较大的异步任务,常见的有:
- 主线程:执行调用栈中同步代码的线程,也是事件循环的核心执行线程。
三、事件循环的运行流程(浏览器环境)
事件循环的核心规则可以总结为“先同步、再微任务、最后宏任务”,具体步骤如下:
1. 基础运行流程

2. 分步拆解
-
第一步:执行同步代码
JS 引擎首先执行script标签里的同步代码,每遇到一个函数就压入调用栈,执行完弹出。 示例:javascriptconsole.log('同步代码1'); // 同步,直接执行 setTimeout(() => { // 宏任务,回调放入宏任务队列 console.log('setTimeout 宏任务'); }, 0); Promise.resolve().then(() => { // 微任务,回调放入微任务队列 console.log('Promise 微任务'); }); console.log('同步代码2'); // 同步,直接执行这一步执行完,控制台先输出:
同步代码1、同步代码2。 -
第二步:清空微任务队列
调用栈为空后,立即执行所有微任务,直到微任务队列为空。 上例中,此时执行Promise.then回调,控制台输出:Promise 微任务。 -
第三步:渲染页面(浏览器专属)
微任务执行完后,浏览器会进行一次页面渲染(比如更新 DOM、样式)。 -
第四步:执行一个宏任务
从宏任务队列中取出第一个任务,放入调用栈执行。 上例中,执行setTimeout回调,控制台输出:setTimeout 宏任务。 -
循环往复
执行完这个宏任务后,再次检查微任务队列(如果宏任务执行中产生了新的微任务),清空后再渲染、再取下一个宏任务,以此循环。
四、进阶:微任务的嵌套执行
如果微任务/宏任务执行中又产生了新的微任务,会被加入当前微任务队列的末尾,继续执行(直到队列清空)。 示例:
console.log('同步代码');
Promise.resolve()
.then(() => {
console.log('微任务1');
// 新的微任务,加入当前队列
Promise.resolve().then(() => {
console.log('嵌套微任务');
});
})
.then(() => {
console.log('微任务2');
});
// 输出顺序:同步代码 → 微任务1 → 嵌套微任务 → 微任务2
五、浏览器 vs Node 环境的差异
Node 环境的事件循环也遵循“微任务优先于宏任务”,但有自己的阶段划分(比如 timer、poll、check 阶段),且微任务优先级:process.nextTick > Promise 微任务。
核心差异:
| 特性 | 浏览器环境 | Node 环境 |
|---|---|---|
| 微任务优先级 | Promise.then 等优先级一致 | process.nextTick > Promise |
| 渲染阶段 | 有(微任务后执行) | 无(无 DOM 渲染) |
| 宏任务执行顺序 | 按加入队列顺序 | 按阶段(timer → poll → check) |
六、经典示例:验证事件循环
console.log('1'); // 同步
setTimeout(() => { // 宏任务1
console.log('2');
Promise.resolve().then(() => { // 微任务(宏任务1执行时产生)
console.log('3');
});
}, 0);
new Promise((resolve) => {
console.log('4'); // Promise 构造函数是同步的
resolve();
}).then(() => { // 微任务1
console.log('5');
setTimeout(() => { // 宏任务2
console.log('6');
}, 0);
});
console.log('7'); // 同步
输出顺序:1 → 4 → 7 → 5 → 2 → 3 → 6
解析:
- 同步代码:1、4、7 先执行;
- 清空微任务队列:执行微任务1(输出5),同时产生宏任务2;
- 执行第一个宏任务(宏任务1,输出2),执行中产生新微任务(输出3);
- 清空宏任务1产生的微任务(输出3);
- 执行下一个宏任务(宏任务2,输出6)。
七、网络请求/定时器与宏任务的执行关系
网络请求(fetch/XMLHttpRequest)属于宏任务,其执行分为两个独立阶段,不会阻塞后续宏任务:
- 发起请求阶段:JS 主线程仅向浏览器网络线程下达请求指令,该操作同步且瞬时完成,执行后立即退出调用栈。
- 回调执行阶段:网络线程在后台等待响应,拿到结果后才将回调函数放入宏任务队列,等待事件循环调度。
代码示例验证
console.log('开始发起请求');
// 网络请求(宏任务)
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(res => res.json())
.then(data => {
console.log('网络请求回调执行:', data.title);
});
// 1秒定时器(宏任务)
setTimeout(() => {
console.log('1秒定时器执行');
}, 1000);
// 2秒定时器(宏任务)
setTimeout(() => {
console.log('2秒定时器执行');
}, 2000);
console.log('同步代码执行完毕');
输出顺序:开始发起请求 → 同步代码执行完毕 → 1秒定时器执行 → 2秒定时器执行 → 网络请求回调执行
核心原理
浏览器是多线程环境,JS 主线程、网络线程、定时器线程相互独立:
- 主线程:处理同步代码、事件循环、任务回调执行。
- 网络线程:专门处理网络请求,后台等待响应不占用主线程。
- 定时器线程:专门管理定时器计时,时间到后将回调放入宏任务队列。
是否需要我帮你整理事件循环高频面试题及解析,强化对这些知识点的应用?
总结
- JS 单线程是事件循环的前提,事件循环的核心是协调同步/异步任务的执行顺序;
- 事件循环的核心规则:先执行同步代码 → 清空所有微任务 → 渲染页面(浏览器)→ 执行一个宏任务 → 重复上述步骤;
- 微任务优先级高于宏任务,且微任务队列会被“一次性清空”(包括执行中产生的新微任务)。(也就是说新产生的微任务都属于当前事件轮次)
- 新产生的宏任务,会被加入宏任务队列的末尾,只能等待 “当前轮次的宏任务 + 微任务” 全部处理完,才会在后续轮次被执行。
- 网络请求、定时器等宏任务的“等待时间”是相对于后台线程的,主线程在此期间会正常处理其他任务。