JS 事件循环(Event Loop)

一、开篇直击:为什么事件循环是 JS 的 "异步引擎"?

先看一段让 90% 开发者困惑的代码:

复制代码

console.log("同步1");

setTimeout(() => {

console.log("定时器1");

}, 0);

Promise.resolve().then(() => {

console.log("微任务1");

}).then(() => {

console.log("微任务2");

});

console.log("同步2");

你觉得输出顺序是什么? 答案是:同步1 → 同步2 → 微任务1 → 微任务2 → 定时器1。

为什么setTimeout延迟 0ms 却比 Promise.then 晚执行?为什么微任务会连续执行完再执行宏任务?这些问题的答案,都指向 JS 的核心异步机制 ------事件循环(Event Loop)

JS 是单线程语言,却能处理异步操作(如网络请求、定时器、DOM 事件),本质就是事件循环在 "调度" 任务执行顺序。掌握事件循环,不仅能搞定面试题,更能避免实际开发中的 "异步顺序 bug"(如接口数据依赖、DOM 操作时机),看懂 Vue.nextTick、React.setState 等框架异步 API 的底层逻辑。

二、事件循环的本质:单线程下的 "任务调度机制"

1. 核心前提:JS 单线程模型
  • JS 只有一个 "主线程",同一时间只能执行一个任务;
  • 为了避免 "长时间任务阻塞线程"(如网络请求、定时器等待),JS 将任务分为两类:同步任务异步任务
  • 异步任务执行完毕后,不会立即执行,而是进入 "任务队列",等待主线程空闲后由事件循环调度执行。
2. 事件循环的核心组件(可视化)
复制代码

[调用栈(Call Stack)] → 执行同步任务,执行完则清空

[微任务队列(Microtasks Queue)] → 优先级高,清空后才执行宏任务

[宏任务队列(Macrotasks Queue)] → 优先级低,一次执行一个

[UI渲染(浏览器环境)/ 其他回调(Node.js)]

(重复上述流程 → 事件循环)

组件职责解析

  • 「调用栈」:存放正在执行的任务,同步任务直接入栈执行,执行完出栈;
  • 「微任务队列」:存放微任务(如 Promise.then/catch/finally、process.nextTick(Node.js)、queueMicrotask),优先级最高;
  • 「宏任务队列」:存放宏任务(如 setTimeout、setInterval、DOM 事件、fetch 网络请求、setImmediate(Node.js)),优先级低于微任务;
  • 「UI 渲染」:浏览器环境特有,微任务执行完后可能触发 UI 渲染(并非每次循环都渲染)。
3. 事件循环的执行流程(必记!)
  1. 执行所有同步任务,直到调用栈清空;
  1. 执行微任务队列中所有任务(注意:是 "所有",不是一个),执行过程中新增的微任务会追加到队列末尾,一并执行;
  1. 微任务队列清空后,浏览器环境会触发UI 渲染(Node.js 无此步骤);
  1. 宏任务队列 中取出第一个任务,放入调用栈执行;
  1. 重复步骤 1-4,形成 "事件循环"。
4. 开篇代码的执行流程拆解(对照流程理解)
复制代码

// 步骤1:执行同步任务,调用栈处理

console.log("同步1"); // 同步任务 → 入栈执行 → 输出"同步1" → 出栈

setTimeout(...) // 异步宏任务 → 交给浏览器定时器线程 → 执行完后入宏任务队列

Promise.resolve().then(...) // 异步微任务 → 交给Promise线程 → 执行完后入微任务队列

console.log("同步2"); // 同步任务 → 入栈执行 → 输出"同步2" → 出栈

// 此时调用栈清空

// 步骤2:执行所有微任务

微任务队列:[微任务1, 微任务2]

执行微任务1 → 输出"微任务1" → 出队

执行微任务2 → 输出"微任务2" → 出队

// 微任务队列清空

// 步骤3:浏览器UI渲染(此处无DOM操作,跳过)

// 步骤4:执行宏任务队列第一个任务

宏任务队列:[定时器1]

执行定时器1回调 → 输出"定时器1" → 出队

// 循环结束,最终输出:同步1 → 同步2 → 微任务1 → 微任务2 → 定时器1

三、关键区分:宏任务与微任务的分类(实战必背)

很多开发者混淆宏微任务,这里整理权威分类表(覆盖浏览器 + Node.js):

|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|-----|-------------------|
| 任务类型 | 包含的 API / 操作 | 优先级 | 执行时机 |
| 微任务(Microtasks) | 1. Promise.then/catch/finally2. queueMicrotask()3. process.nextTick(Node.js 特有,优先级最高)>4. MutationObserver(浏览器特有,监听 DOM 变化) | 高 | 调用栈清空后,立即执行所有微任务 |
| 宏任务(Macrotasks) | 1. setTimeout/setInterval2. setImmediate(Node.js 特有)3. DOM 事件(如 click、resize) fetch/XMLHttpRequest(网络请求). I/O 操作(Node.js 特有,如文件读写) script 标签(整个脚本执行,视为一个宏任务) | 低 | 所有微任务执行完后,执行一个宏任务 |

核心记忆点

  • 微任务:"快",跟随当前同步任务之后立即执行;
  • 宏任务:"慢",需等待下一轮事件循环执行;
  • Node.js 中process.nextTick是 "微任务中的 VIP",优先级高于其他微任务(如 Promise.then)。

四、核心难点:浏览器 vs Node.js 事件循环差异(面试高频)

很多开发者以为事件循环是 "统一标准",但浏览器和 Node.js 的实现差异巨大,这也是面试常考的 "加分项"。

1. 核心差异对比表

|---------|-------------------------------------------------------------|--------------------------------------|-----------------------------------|
| 对比维度 | 浏览器环境(Chrome/Firefox) | Node.js 环境(v11+,与浏览器趋同) | Node.js 环境(v10 及以下,差异大) |
| 任务队列结构 | 1 个微任务队列 + 多个宏任务队列(按类型分优先级) | 1 个微任务队列 + 多个宏任务队列(按类型分优先级) | 1 个微任务队列 + 1 个宏任务队列(无类型优先级) |
| 宏任务优先级 | setTimeout/setInterval > setImmediate(无,浏览器无 setImmediate) | setImmediate > setTimeout(延迟 0ms 时) | 所有宏任务优先级相同,按入队顺序执行 |
| 微任务执行时机 | 每个宏任务执行完后,清空所有微任务 | 每个宏任务执行完后,清空所有微任务 | 每个宏任务执行完后,清空所有微任务 |
| 特殊 API | MutationObserver | process.nextTick、setImmediate、I/O | process.nextTick、setImmediate、I/O |

2. 关键差异实战案例(Node.js 特有)
复制代码

// Node.js环境下执行(v11+)

setTimeout(() => {

console.log("setTimeout");

}, 0);

setImmediate(() => {

console.log("setImmediate");

});

  • 输出结果:不确定(可能setImmediate先执行,也可能setTimeout先执行);
  • 原因:Node.js 中,setTimeout(..., 0)实际最小延迟是 1ms(底层限制),若主线程执行速度快,setImmediate会先入队,优先执行;若主线程有轻微阻塞,setTimeout延迟满足后入队,可能先执行。

Node.js v10 及以下差异

  • 宏任务队列无优先级,按入队顺序执行,因此上述代码会固定输出setTimeout → setImmediate(因为setTimeout入队时会有 1ms 延迟,setImmediate先入队)。
3. Node.js 特有的 "process.nextTick" 优先级
复制代码

// Node.js环境

Promise.resolve().then(() => {

console.log("Promise.then");

});

process.nextTick(() => {

console.log("process.nextTick");

});

queueMicrotask(() => {

console.log("queueMicrotask");

});

输出结果:process.nextTick → queueMicrotask → Promise.then;

原因:Node.js 中process.nextTick属于 "微任务队列的独立子队列",优先级高于其他所有微任务,会在其他微任务执行前先清空。

五、实战应用:事件循环在开发中的核心场景

1. 解决 "DOM 操作时机" 问题(浏览器环境)
复制代码

// 场景:修改DOM后,立即获取修改后的样式(如offsetHeight)

const div = document.createElement("div");

div.style.height = "100px";

document.body.appendChild(div);

// 错误写法:同步执行,此时DOM未渲染,获取到的offsetHeight为0

console.log(div.offsetHeight); // 0

// 正确写法:用微任务等待DOM渲染(微任务执行在UI渲染前)

queueMicrotask(() => {

console.log(div.offsetHeight); // 100

});

原理:微任务执行在 "宏任务执行完" 和 "UI 渲染" 之间,此时 DOM 已更新但未渲染,可获取最新 DOM 属性。

2. Vue.nextTick 的底层实现(框架源码场景)

Vue 的nextTick是事件循环的典型应用,核心逻辑:

  • 优先使用Promise.then(微任务);
  • 若浏览器不支持 Promise,降级使用MutationObserver(微任务);
  • 最后降级使用setTimeout(宏任务);
  • 目的:确保回调函数在 Vue 组件 DOM 更新后执行。
复制代码

// Vue.nextTick简化源码

function nextTick(cb) {

if (typeof Promise !== "undefined") {

// 优先用微任务(效率最高)

Promise.resolve().then(cb);

} else if (typeof MutationObserver !== "undefined") {

// 降级用微任务

const observer = new MutationObserver(cb);

const textNode = document.createTextNode("1");

observer.observe(textNode, { characterData: true });

textNode.data = "2";

} else {

// 最后降级用宏任务

setTimeout(cb, 0);

}

}

3. 避免 "异步顺序依赖" bug(实际开发场景)
复制代码

// 场景:先请求用户信息,再用用户ID请求订单信息

let userId;

// 错误写法:两个异步操作并行,订单请求可能先执行(userId未获取)

fetch("/api/user").then(res => res.json()).then(data => {

userId = data.id;

});

fetch(`/api/orders?userId=${userId}`).then(res => res.json()); // userId可能为undefined

// 正确写法:利用事件循环的任务顺序,嵌套或用async/await

// 方式1:Promise嵌套(微任务顺序执行)

fetch("/api/user")

.then(res => res.json())

.then(data => {

userId = data.id;

return fetch(`/api/orders?userId=${userId}`); // 第一个微任务执行完,再触发第二个请求

})

.then(res => res.json());

// 方式2:async/await(语法糖,本质还是微任务)

async function getOrders() {

const userRes = await fetch("/api/user");

const user = await userRes.json();

const orderRes = await fetch(`/api/orders?userId=${user.id}`);

const orders = await orderRes.json();

}

六、90% 开发者踩过的 5 个误区(避坑指南)

1. 误区 1:setTimeout (fn, 0) 立即执行
  • 错误认知:延迟 0ms 就是 "马上执行";
  • 正确结论:setTimeout最小延迟是 4ms(浏览器环境)/1ms(Node.js 环境),且属于宏任务,需等待所有微任务执行完;
  • 示例
复制代码

setTimeout(() => console.log("setTimeout"), 0);

Promise.resolve().then(() => console.log("Promise"));

// 输出:Promise → setTimeout

2. 误区 2:微任务和宏任务是 "一对一" 执行
  • 错误认知:一个微任务对应一个宏任务;
  • 正确结论 :一个宏任务执行完后,会清空所有微任务,再执行下一个宏任务;
  • 示例
复制代码

setTimeout(() => console.log("宏任务1"), 0);

Promise.resolve().then(() => {

console.log("微任务1");

return Promise.resolve().then(() => console.log("微任务2")); // 新增微任务

});

// 输出:微任务1 → 微任务2 → 宏任务1

3. 误区 3:Node.js 和浏览器的事件循环完全一致
  • 错误认知:Node.js 的事件循环和浏览器一样;
  • 正确结论:Node.js 有process.nextTick、setImmediate等特有 API,且宏任务优先级与浏览器不同(如 v11 + 中setImmediate优先级高于setTimeout(..., 0))。
4. 误区 4:async/await 是 "同步代码"
  • 错误认知:await会阻塞所有代码;
  • 正确结论:await后的代码会转化为微任务(Promise.then),不会阻塞主线程其他同步任务;
  • 示例
复制代码

async function fn() {

console.log("同步1");

await Promise.resolve();

console.log("微任务1"); // 转化为微任务

}

fn();

console.log("同步2");

// 输出:同步1 → 同步2 → 微任务1

5. 误区 5:UI 渲染在每个事件循环都执行
  • 错误认知:微任务执行完后一定会触发 UI 渲染;
  • 正确结论:浏览器会根据 "是否有 DOM 变化""性能优化" 等因素决定是否渲染,通常每 60ms(16.67fps)渲染一次,并非每次循环都渲染。

七、面试高频真题解析(95 分必备)

真题 1:写出以下代码的输出顺序(浏览器环境)
复制代码

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. 同步任务执行:输出 1、6,调用栈清空;
  1. 微任务队列执行:输出 4,新增宏任务setTimeout(5);微任务队列清空;
  1. 宏任务队列执行第一个任务(setTimeout(2)):输出 2,新增微任务Promise(3);
  1. 微任务队列执行:输出 3,清空;
  1. 宏任务队列执行下一个任务(setTimeout(5)):输出 5。
真题 2:Node.js 环境下,以下代码输出顺序(v11+)
复制代码

process.nextTick(() => console.log("1"));

setImmediate(() => console.log("2"));

setTimeout(() => console.log("3"), 0);

Promise.resolve().then(() => console.log("4"));

console.log("5");

输出顺序:5 → 1 → 4 → 2 → 3;

解析步骤

  1. 同步任务执行:输出 5,调用栈清空;
  1. 微任务执行:先执行process.nextTick(优先级最高)输出 1,再执行Promise.then输出 4;
  1. 宏任务执行:setImmediate优先级高于setTimeout(..., 0),输出 2,再输出 3。
真题 3:解释 Vue.nextTick 的原理和作用

答案核心

  • 原理:利用事件循环的微任务(优先)或宏任务,确保回调函数在 Vue 组件 DOM 更新后执行;
  • 作用:解决 "修改数据后,立即获取 DOM 属性(如 offsetHeight)为旧值" 的问题;
  • 底层实现:优先使用Promise.then,降级使用MutationObserver,最后使用setTimeout。

八、总结:事件循环的 "道" 与 "术"

  • :事件循环是 JS 单线程模型下的 "异步调度核心",本质是 "调用栈→微任务→宏任务" 的循环执行规则;
    1. 记准宏微任务分类,明确执行顺序;
    1. 区分浏览器与 Node.js 的差异,避免跨环境 bug;
    1. 利用微任务优化 DOM 操作、异步依赖顺序,利用宏任务处理延迟执行;
  • 终极认知:所有 JS 异步 API(Promise、setTimeout、fetch、Vue.nextTick)的底层都依赖事件循环 ------ 理解事件循环,才能真正掌控 JS 的异步行为,从 "被动调试" 变为 "主动设计" 异步流程。

事件循环看似抽象,但只要抓住 "同步→微任务(所有)→宏任务(一个)" 的核心流程,结合双环境差异和实战场景,就能彻底掌握。它不仅是面试的 "分水岭",更是成为高级前端工程师的 "必修课"。

相关推荐
Codebee15 小时前
ooder A2UI ES6版本正式发布:现代化模块架构,MIT开源许可
前端
Devin_chen15 小时前
4.前端使用Node + MongoDB + Langchain消息管理与聊天历史存储
前端·langchain
前端er小芳15 小时前
前端文件 / 图片核心 API 全解析:File、FileReader、Blob、Base64、URL
前端
twl15 小时前
探索Agent RAG: 一文讲清楚从理论到具体落地
前端
FinClip15 小时前
赢千元好礼!FinClip Chatkit “1小时AI集成挑战赛”,邀你来战!
前端
weixin_4331793315 小时前
python - for循环,字符串,元组基础
开发语言·python
实习生小黄15 小时前
vue3静态文件打包404解决方案
前端·vue.js·vite
啃火龙果的兔子15 小时前
Capacitor移动框架简介及使用场景
前端
yuanyxh15 小时前
程序设计模版
前端