摘要
本文彻底拆解 JavaScript 单线程异步的核心机制 ------Event Loop(事件循环),详细讲解宏任务、微任务的定义、分类、执行顺序,结合大量可运行代码示例、执行流程图解,拆解面试高频代码输出题,帮你彻底搞懂 "JS 为什么能同时处理多个异步操作",轻松应对前端最难的 Event Loop 面试题,摆脱 "代码执行顺序混乱" 的困扰。
一、前言:为什么需要 Event Loop?
JavaScript 是单线程语言------ 同一时间只能执行一段代码,无法同时执行多个任务。但实际开发中,我们经常会遇到 "异步操作"(如定时器、接口请求、DOM 事件),如果单线程一直等待异步操作完成,会导致页面卡顿、无响应。
Event Loop(事件循环)就是 JavaScript 单线程处理异步操作的核心机制 ,它的作用是:协调同步代码、宏任务、微任务的执行顺序,让单线程也能高效处理多个异步操作,避免线程阻塞。
核心痛点示例(先看一道面试题)
javascript
运行
javascript
console.log("同步1");
setTimeout(() => {
console.log("定时器1");
}, 0);
Promise.resolve().then(() => {
console.log("微任务1");
});
console.log("同步2");
请问:输出顺序是什么?(答案在后面,先思考)
很多开发者会误以为 "定时器延迟 0ms,会先执行",但实际输出顺序是:同步 1 → 同步 2 → 微任务 1 → 定时器 1。
这背后的核心逻辑,就是 Event Loop、宏任务与微任务的执行规则 ------ 这也是前端面试中,Event Loop 最常考的形式。
二、核心概念:同步代码、宏任务、微任务
要搞懂 Event Loop,必须先分清三个核心概念:同步代码、宏任务(Macro Task)、微任务(Micro Task),它们的执行优先级不同,决定了代码的执行顺序。
1. 同步代码
- 定义:直接在主线程中执行,立即执行、无延迟,执行完一段再执行下一段,会阻塞主线程。
- 示例:console.log、变量声明、函数调用(非异步)、条件判断、循环等。
javascript
运行
javascript
// 所有同步代码,按顺序执行
console.log("同步1"); // 立即执行
let a = 10; // 立即执行
function fn() { console.log("同步函数"); }
fn(); // 立即执行
console.log("同步2"); // 立即执行
2. 宏任务(Macro Task)
- 定义:异步操作,执行优先级低,会被放入 "宏任务队列",等待同步代码和微任务执行完毕后,再执行。
- 核心特点:每次 Event Loop 循环,只执行一个宏任务(执行完一个,再检查微任务队列)。
- 常见宏任务(必记,面试高频):
- setTimeout、setInterval(定时器)
- setImmediate(Node.js 特有,浏览器不支持)
- I/O 操作(如接口请求、文件读取)
- DOM 事件(如 click、scroll,本质是宏任务)
- script 标签(整个脚本的执行,属于宏任务)
3. 微任务(Micro Task)
- 定义:异步操作,执行优先级高,会被放入 "微任务队列",同步代码执行完毕后,立即执行所有微任务,再执行宏任务。
- 核心特点:同步代码执行完后,会清空整个微任务队列(所有微任务一次性执行完)。
- 常见微任务(必记,面试高频):
- Promise.then、Promise.catch、Promise.finally(Promise 的回调)
- process.nextTick(Node.js 特有,优先级最高的微任务)
- MutationObserver(浏览器特有,监听 DOM 变化的回调)
- queueMicrotask(新增 API,手动添加微任务)
关键优先级排序(面试必背)
同步代码 > 微任务 > 宏任务
一句话记忆:先执行所有同步代码,再执行所有微任务,最后执行宏任务;每次执行完一个宏任务,都要先检查并清空微任务队列。
三、Event Loop 执行流程(图解 + 步骤,必懂)
Event Loop 的核心是 "循环执行",每次循环称为一个 "tick",每个 tick 的执行流程固定,分为 4 步(结合图解理解,面试可口述):
1. 执行流程图解(文字版)
plaintext
开始 → 执行同步代码 → 清空微任务队列 → 执行一个宏任务 → 清空微任务队列 → 重复循环...
2. 详细执行步骤(必记,面试必考)
- 执行当前 "宏任务" 中的所有同步代码(如果是第一次执行,当前宏任务就是整个 script 脚本)。
- 同步代码执行完毕后,清空微任务队列(按顺序执行所有微任务,直到微任务队列为空)。
- 微任务队列清空后,从宏任务队列中取出第一个宏任务,执行其内部的同步代码。
- 该宏任务的同步代码执行完毕后,再次清空微任务队列。
- 重复步骤 3-4,直到宏任务队列和微任务队列都为空,Event Loop 结束。
3. 结合前面的面试题,拆解执行流程
javascript
运行
javascript
console.log("同步1"); // 同步代码,立即执行
setTimeout(() => {
console.log("定时器1"); // 宏任务,放入宏任务队列
}, 0);
Promise.resolve().then(() => {
console.log("微任务1"); // 微任务,放入微任务队列
});
console.log("同步2"); // 同步代码,立即执行
执行步骤拆解:
- 执行当前宏任务(script 脚本)中的同步代码:
- 执行
console.log("同步1")→ 输出:同步 1 - 遇到 setTimeout,将其回调放入宏任务队列(宏任务队列:[定时器 1])
- 遇到 Promise.then,将其回调放入微任务队列(微任务队列:[微任务 1])
- 执行
console.log("同步2")→ 输出:同步 2
- 执行
- 同步代码执行完毕,清空微任务队列 :
- 执行微任务队列中的 "微任务 1" → 输出:微任务 1
- 微任务队列为空
- 微任务队列清空后,取出宏任务队列中的第一个宏任务(定时器 1),执行其内部同步代码:
- 执行
console.log("定时器1")→ 输出:定时器 1
- 执行
- 该宏任务执行完毕,再次检查微任务队列(为空),继续循环(宏任务队列已空,循环结束)。
最终输出顺序:
同步 1 → 同步 2 → 微任务 1 → 定时器 1(和前面的答案一致)
4. 补充:多个宏任务、微任务的执行顺序
javascript
运行
javascript
// 复杂示例,巩固执行流程
console.log("同步1");
// 宏任务1
setTimeout(() => {
console.log("宏任务1-1");
Promise.resolve().then(() => {
console.log("宏任务1的微任务");
});
}, 0);
// 微任务1
Promise.resolve().then(() => {
console.log("微任务1");
// 微任务中新增微任务
Promise.resolve().then(() => {
console.log("微任务1-1");
});
// 微任务中新增宏任务
setTimeout(() => {
console.log("微任务中的宏任务");
}, 0);
});
// 宏任务2
setTimeout(() => {
console.log("宏任务2");
}, 0);
console.log("同步2");
执行步骤拆解:
- 执行 script 同步代码:输出 同步 1、同步 2;宏任务队列:[宏任务 1, 宏任务 2];微任务队列:[微任务 1]
- 清空微任务队列:
- 执行微任务 1 → 输出 微任务 1
- 微任务 1 中新增微任务 1-1,放入微任务队列
- 微任务 1 中新增宏任务,放入宏任务队列(宏任务队列:[宏任务 1, 宏任务 2, 微任务中的宏任务])
- 继续执行微任务 1-1 → 输出 微任务 1-1;微任务队列为空
- 取出第一个宏任务(宏任务 1),执行其同步代码:输出 宏任务 1-1
- 清空微任务队列(宏任务 1 中新增的微任务):输出 宏任务 1 的微任务;微任务队列为空
- 取出第二个宏任务(宏任务 2),执行其同步代码:输出 宏任务 2
- 清空微任务队列(为空)
- 取出第三个宏任务(微任务中的宏任务),执行其同步代码:输出 微任务中的宏任务
- 循环结束
最终输出顺序:
同步 1 → 同步 2 → 微任务 1 → 微任务 1-1 → 宏任务 1-1 → 宏任务 1 的微任务 → 宏任务 2 → 微任务中的宏任务
四、浏览器 vs Node.js 的 Event Loop 区别(面试加分)
很多面试会问:"浏览器和 Node.js 的 Event Loop 有区别吗?"------ 答案是:有,核心区别在宏任务的执行顺序和微任务的优先级。
1. 浏览器的 Event Loop(重点,前端面试主要考这个)
- 宏任务队列:只有一个,按顺序执行(每次执行一个宏任务)。
- 微任务队列:只有一个,同步代码执行完后,清空所有微任务。
- 微任务优先级:Promise.then > MutationObserver。
2. Node.js 的 Event Loop(了解即可,面试提及加分)
- 宏任务队列:有 6 个,按优先级顺序执行(不同类型的宏任务放入不同队列)。
- 微任务队列:有 2 个,分为 "微任务队列(microtasks)" 和 "nextTick 队列"。
- 微任务优先级:process.nextTick(最高)> Promise.then。
- 执行流程:执行同步代码 → 清空 nextTick 队列 → 清空微任务队列 → 执行一个宏任务队列 → 重复。
3. 核心区别总结(面试必答)
- 宏任务队列:浏览器只有 1 个,Node.js 有 6 个,执行顺序不同。
- 微任务队列:浏览器只有 1 个,Node.js 有 2 个,process.nextTick 优先级最高。
- 定时器精度:Node.js 中 setTimeout 的延迟时间精度更高,浏览器中受 Event Loop 影响,精度较低。
五、高频面试题(代码输出题,必练)
Event Loop 面试最常考 "代码输出顺序",以下 3 道题覆盖所有高频场景,练会就能应对 90% 的面试题。
面试题 1:基础版(同步 + 微任务 + 宏任务)
javascript
运行
javascript
console.log("1");
setTimeout(() => {
console.log("2");
Promise.resolve().then(() => {
console.log("3");
});
}, 0);
Promise.resolve().then(() => {
console.log("4");
setTimeout(() => {
console.log("5");
}, 0);
});
console.log("6");
输出顺序:1 → 6 → 4 → 2 → 3 → 5
解析:
- 同步代码:1、6 → 微任务队列:[4];宏任务队列:[2]
- 清空微任务:4 → 新增宏任务 5 → 宏任务队列:[2,5]
- 执行宏任务 2 → 同步代码 2 → 新增微任务 3 → 清空微任务 3
- 执行宏任务 5 → 5
面试题 2:进阶版(多个微任务 + 宏任务嵌套)
javascript
运行
javascript
async function fn() {
console.log("1");
await Promise.resolve();
console.log("2");
}
console.log("3");
setTimeout(() => {
console.log("4");
fn();
}, 0);
Promise.resolve().then(() => {
console.log("5");
});
fn();
console.log("6");
输出顺序:3 → 1 → 6 → 5 → 2 → 4 → 1 → 2
解析:
- 同步代码:3 → 执行 fn (),输出 1 → await 后面的 Promise 是微任务,放入微任务队列 → 同步代码 6 → 微任务队列:[5, 2];宏任务队列:[4]
- 清空微任务:5 → 2
- 执行宏任务 4 → 输出 4 → 执行 fn (),输出 1 → await 微任务放入队列 → 清空微任务 2
- 循环结束
面试题 3:易错版(DOM 事件 + 微任务 + 宏任务)
javascript
运行
javascript
document.addEventListener("click", () => {
console.log("click1");
Promise.resolve().then(() => {
console.log("click1微任务");
});
});
document.addEventListener("click", () => {
console.log("click2");
Promise.resolve().then(() => {
console.log("click2微任务");
});
});
// 手动触发点击事件
document.click();
输出顺序:click1 → click1 微任务 → click2 → click2 微任务
解析:
- DOM 事件属于宏任务,手动触发 click 后,两个 click 事件会依次放入宏任务队列。
- 执行第一个 click 宏任务:输出 click1 → 新增微任务,清空微任务(click1 微任务)。
- 执行第二个 click 宏任务:输出 click2 → 新增微任务,清空微任务(click2 微任务)。
六、常见误区(面试避坑)
- 误区 1:setTimeout (fn, 0) 会立即执行 ------ 错!setTimeout 是宏任务,即使延迟 0ms,也会等到同步代码和微任务执行完毕后,才会执行。
- 误区 2:微任务和宏任务是同时执行的 ------ 错!优先级:同步 > 微任务 > 宏任务,微任务全部执行完,才会执行宏任务。
- 误区 3:多个宏任务会一次性执行完 ------ 错!每次 Event Loop 只执行一个宏任务,执行完后,必须先清空微任务队列,再执行下一个宏任务。
- 误区 4:async/await 中的 await 后面的代码是同步的 ------ 错!await 后面的代码属于 "微任务",会在 await 等待的 Promise 完成后,放入微任务队列。
七、总结(核心要点,面试速记)
- JS 是单线程,Event Loop 是单线程处理异步的核心机制。
- 三大执行优先级:同步代码 > 微任务 > 宏任务。
- 每个 Event Loop 循环:执行 1 个宏任务 → 清空所有微任务 → 重复。
- 微任务:Promise.then、queueMicrotask、MutationObserver(浏览器)、process.nextTick(Node.js)。
- 宏任务:setTimeout、setInterval、I/O、DOM 事件、script 脚本。
- 面试重点:代码输出顺序(掌握执行流程,就能轻松破解)。