深入理解 JavaScript 事件循环:宏任务、微任务与实战解析
# 深入理解 JavaScript 事件循环宏任务、微任务与实战解析  本文从 JavaScript 单线程模型出发逐步拆解事件循环Event Loop的核心机制包括调用栈、宏任务队列、微任务队列的协作方式并通过实战案例帮助你真正理解代码的执行顺序。 ## 一、为什么 JavaScript 是单线程 JavaScript 最初被设计为浏览器脚本语言核心用途是操作 DOM 和处理用户交互。如果它支持多线程——一个线程在修改 DOM另一个线程在删除 DOM——浏览器就无法决定以谁为准。 因此JavaScript 从诞生起就是**单线程**语言同一时间只能做一件事。 但单线程不意味着卡死。浏览器提供了**事件循环机制**让 JavaScript 可以处理异步任务而不阻塞主线程。理解这个机制是写出高性能前端代码的基础。 ## 二、调用栈Call Stack 调用栈是 JavaScript 引擎追踪函数执行的机制——**后进先出LIFO**。 javascript function multiply(a, b) { return a * b; } function square(n) { return multiply(n, n); } function printSquare(n) { const result square(n); console.log(result); } printSquare(5); // 输出: 25 执行过程 1. printSquare(5) 入栈 2. square(5) 入栈 3. multiply(5, 5) 入栈 4. multiply 返回 25出栈 5. square 返回 25出栈 6. console.log(25) 入栈执行后出栈 7. printSquare 出栈栈清空 如果某个函数执行时间很长比如同步的网络请求后面的代码就会被阻塞——这就是卡顿的根源。 ## 三、异步任务与 Web API 浏览器提供了一套 **Web API**如 setTimeout、fetch、DOM Events这些 API 不在 JavaScript 引擎中执行而是由浏览器的 C 底层处理。 当 JavaScript 调用 setTimeout 时引擎不会等待而是把它交给浏览器处理自己继续执行下一行代码。当计时结束后回调函数会被放入**任务队列**等待执行。 javascript console.log(1. 开始); setTimeout(() { console.log(3. 定时器回调); }, 0); console.log(2. 结束); // 输出顺序: 1 → 2 → 3 即使 setTimeout 的延迟设为 0回调也不会立即执行——它必须等到当前调用栈清空后才会被处理。 ## 四、宏任务与微任务 任务队列不止一个。ES6 规范中异步任务分为两类 | 类型 | 示例 | 优先级 | |------|------|--------| | **宏任务Macrotask** | setTimeout、setInterval、I/O、UI 渲染 | 低 | | **微任务Microtask** | Promise.then、MutationObserver、queueMicrotask | 高 | 关键规则**每次宏任务执行完毕后引擎会清空所有微任务队列然后才执行下一个宏任务。** 这意味着微任务总是在宏任务之前执行前提是它们在同一个事件循环轮次中。 ## 五、事件循环的完整流程 1. 从宏任务队列中取出**一个**任务执行 2. 执行过程中如果产生微任务加入微任务队列 3. 当前宏任务执行完毕后**清空所有微任务** 4. 检查是否需要渲染通常 60fps约 16ms 一次 5. 回到步骤 1重复 ┌─────────────────────────────┐ │ 宏任务队列 │ │ [setTimeout, I/O, ...] │ └──────────┬──────────────────┘ │ 取出一个执行 ▼ ┌─────────────────────────────┐ │ 调用栈 │ │ 执行同步代码 回调 │ └──────────┬──────────────────┘ │ 栈清空后 ▼ ┌─────────────────────────────┐ │ 微任务队列 │ │ [Promise.then, ...] │ │ ← 全部执行完毕 │ └──────────┬──────────────────┘ │ 微任务清空后 ▼ ┌─────────────────────────────┐ │ 渲染可选 │ │ requestAnimationFrame │ └──────────┬──────────────────┘ │ ▼ 回到宏任务队列 ## 六、实战你能说对这段代码的输出顺序吗 javascript console.log(A); setTimeout(() { console.log(B); }, 0); Promise.resolve().then(() { console.log(C); }); Promise.resolve().then(() { console.log(D); }); console.log(E); // 输出顺序是什么 **答案A → E → C → D → B** 解析 1. console.log(A) — 同步代码立即执行 2. setTimeout — 回调加入**宏任务队列** 3. 两个 Promise.then — 回调加入**微任务队列**顺序C 在前D 在后 4. console.log(E) — 同步代码立即执行 5. 调用栈清空 → 执行所有微任务 → 输出 C、D 6. 微任务清空 → 执行下一个宏任务 → 输出 B 再看一个更复杂的例子 javascript async function async1() { console.log(async1 start); await async2(); console.log(async1 end); // 这行何时执行 } async function async2() { console.log(async2); } console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); async1(); new Promise((resolve) { console.log(promise); resolve(); }).then(() { console.log(promise then); }); console.log(script end); **答案** script start async1 start async2 promise script end async1 end promise then setTimeout 关键点await 会暂停当前 async 函数的执行await 之后的代码相当于 .then() 回调——是**微任务**。因此 async1 end 和 promise then 都在同步代码执行完毕后才输出。 ## 七、常见陷阱与最佳实践 ### 1. 不要在微任务中创建无限循环 javascript // ❌ 危险这会阻塞所有渲染和宏任务 function loop() { Promise.resolve().then(loop); } loop(); 微任务队列永远不会清空页面会完全冻结用户无法交互。 ### 2. 大量同步计算用 setTimeout 分片 javascript // ✅ 将耗时任务拆分成小块每块执行不超过 50ms function chunk(array, process, callback) { const items [...array]; function processChunk() { const start Date.now(); while (items.length 0 Date.now() - start 50) { process(items.shift()); } if (items.length 0) { setTimeout(processChunk, 0); // 让出主线程 } else { callback(); } } processChunk(); } ### 3. requestAnimationFrame 优于 setTimeout 做动画 javascript // ✅ 与浏览器渲染节奏同步不会丢帧 function animate() { // 更新动画状态 requestAnimationFrame(animate); } requestAnimationFrame(animate); setTimeout(fn, 0) 的实际最小延迟约为 4ms而且可能在渲染之前执行导致动画卡顿。requestAnimationFrame 保证在下次重绘前调用天然流畅。 ### 4. 注意 Promise 链的错误处理 javascript // ❌ 未捕获的 Promise 错误不会阻塞后续微任务 Promise.resolve().then(() { throw new Error(oops); }); Promise.resolve().then(() { console.log(仍然会执行); // 这行会输出 }); // ✅ 正确处理 Promise.resolve() .then(() { throw new Error(oops); }) .catch(err console.error(err.message)); ## 八、总结 | 概念 | 要点 | |------|------| | 调用栈 | 同步代码的执行顺序LIFO | | 宏任务 | setTimeout、setInterval、I/O 等 | | 微任务 | Promise.then、queueMicrotask 等 | | 执行优先级 | 同步代码 微任务 宏任务 | | 渲染时机 | 微任务清空后下一个宏任务前 | 理解事件循环的核心就一句话 **每次宏任务结束后清空所有微任务微任务全部执行完毕后才进入下一个宏任务。** 掌握了这个规则你就能准确预测任何 JavaScript 代码的执行顺序写出更可靠的异步代码。 --- *本文为原创技术分享欢迎交流讨论。*