JavaScript :事件循环机制的深度解析

问你一个问题:代码的执行顺序 ... 真的是从上到下吗?

你或许会毫不犹豫地回答:"当然!",但是,如果你看完这篇文章,了解了事件循环机制之后,或许,你会有新的答案也说不定。

一、事件循环是什么?

首先,你或许已经知晓,JavaScript 它是一个单线程执行的脚本,这意味着它同一时刻只能执行一个任务。

现实中,却有着许多耗时的操作(比如:网络请求、定时器、文件读写等),如果等待这些操作完成,页面将完全卡死,无法响应用户交互或渲染更新。

于是,我们就有了事件循环(Event Loop) 机制,事件循环可以协调同步任务、异步任务的执行,让单线程的 JavaScript 也能高效处理并发操作。

事件循环的作用:

  1. 避免阻塞:它可以将耗时任务移出主线程,确保页面交互流畅
  2. 任务调度:它可以按照优先级(同步 > 微任务 > 宏任务)的顺序执行代码
  3. 响应异步:它能处理 I/O、定时器等操作结果,实现非阻塞运行

二、事件循环的核心概念介绍

1. 进程与线程

  • 进程:程序执行的独立实例(如一个浏览器标签页),它拥有独立内存空间。
  • 线程 :进程内的执行单元。就像JS , 它就是 单线程的,这意味着它只有一个调用栈,一次只能执行一段代码。

2. 同步任务 vs 异步任务

  • 同步任务立即在主线程(调用栈)上执行的任务,执行顺序就是你书写的代码顺序。它能保证尽快完成页面渲染和交互响应。
  • 异步任务不会立即执行结果的任务,它一般会被交给浏览器的其他线程(如定时器线程、网络请求线程)处理。完成后,其回调函数会被放入相应的任务队列,等待事件循环调度到主线程执行。

3. 宏任务 vs 微任务

宏任务和微任务,它们本质上其实都属于异步任务。

  • 宏任务 :相对独立的工作单元,常见类型有setTimeout, setInterval, , I/O 操作, UI 渲染等。
  • 微任务 :更紧急、需在当前宏任务结束后、渲染前尽快执行的任务,常见类型有Promise.then, MutationObserver, queueMicrotask, process.nextTick等。

三、事件循环执行顺序

事件循环的运行机制一般遵循固定的步骤,如图所示:

  1. 执行初始宏任务 :通常就是执行当前 <script> 标签内的所有同步代码 ,一个 <script>就是一个宏任务。
  2. 清空微任务队列 :当前宏任务的所有同步代码执行完毕后(调用栈为空),JS 引擎会立即、连续地执行微任务队列中的所有可执行微任务,直到队列清空。
  3. 渲染更新(可选):浏览器可能会在这个时机进行 UI 渲染。
  4. 取下一个宏任务 :从宏任务队列中取出最先进入的宏任务执行。
  5. 循环步骤2-4:执行宏任务的同步代码 -> 清空微任务队列 -> (可能渲染)-> 取下一个宏任务...

结合代码分析

案例 1:基础顺序

javascript 复制代码
console.log('任务开头'); // 同步任务1

setTimeout(() => { console.log('执行setTimeout'); }, 0); // 宏任务1 (回调)

Promise.resolve().then(() => { console.log('执行Promise'); }); // 微任务1

console.log('任务结尾'); // 同步任务2

输出结果:

javascript 复制代码
任务开头
任务结尾
执行Promise
执行setTimeout

执行步骤:

  1. 执行初始宏任务 (<script>):

    • 执行同步任务1: 输出 任务开头
    • 遇到 setTimeout: 其回调函数 () => { console.log('执行setTimeout') } 被放入宏任务队列。
    • 遇到 Promise.resolve().then(...): 其回调函数 () => { console.log('执行Promise') } 被放入微任务队列。
    • 执行同步任务2: 输出 任务结尾
    • 初始宏任务执行完毕,调用栈清空。
  2. 清空微任务队列:

    • 检查微任务队列,发现有 1 个微任务(微任务1)。
    • 执行微任务1的回调: 输出 执行Promise
    • 微任务队列清空。
  3. (可能发生的 UI 渲染)

  4. 取下一个宏任务:

    • 从宏任务队列中取出宏任务1(setTimeout 的回调)。
    • 执行该回调: 输出 执行setTimeout
    • 该宏任务执行完毕,调用栈清空。
  5. 检查微任务队列: 此时微任务队列为空。

  6. (可能发生的 UI 渲染)

  7. 取下一个宏任务: 宏任务队列为空,事件循环等待新任务。

结论:

一般情况下,执行顺序为 同步任务 (整个script宏任务) -> 微任务 -> 下一个宏任务


案例 2:微任务插队 and 宏任务内的微任务

javascript 复制代码
console.log("开头"); // 同步1

process.nextTick(() => { console.log("微任务:Process Next Tick"); }); // 微任务1 (Node)
Promise.resolve().then(() => { console.log("微任务:Promise"); }); // 微任务2

setTimeout(() => { // 宏任务1 (回调)
    console.log("宏任务:setTimeout");
    Promise.resolve().then(() => { console.log("微任务:setTimeout --> Promise"); }); // 宏任务1内部的微任务
}, 0);

console.log("结尾"); // 同步2

输出结果:

javascript 复制代码
开头
结尾
微任务:Process Next Tick
微任务:Promise
宏任务:setTimeout
微任务:setTimeout --> Promise

执行步骤:

  1. 执行初始宏任务 (<script>):

    • 输出 开头 (同步1)
    • process.nextTick 回调放入微任务队列 (微任务1)
    • Promise.then 回调放入微任务队列 (微任务2)
    • setTimeout 回调放入宏任务队列 (宏任务1)
    • 输出 结尾 (同步2)
    • 初始宏任务执行完毕。调用栈清空。
  2. 清空微任务队列:

    • 执行微任务1: 输出 微任务:Process Next Tick (Node 中 nextTick 优先级通常高于 Promise)
    • 执行微任务2: 输出 微任务:Promise
    • 微任务队列清空。
  3. (可能发生的 UI 渲染)

  4. 取下一个宏任务 (宏任务1):

    • 执行宏任务1的回调:
      • 输出 宏任务:setTimeout
      • 遇到 Promise.resolve().then(...): 其回调 () => { console.log("微任务:setTimeout --> Promise") } 被放入微任务队列
    • 宏任务1执行完毕。调用栈清空。
  5. 清空微任务队列 (由宏任务1产生的):

    • 执行刚加入的微任务: 输出 微任务:setTimeout --> Promise
    • 微任务队列清空。
  6. (可能发生的 UI 渲染)

  7. 取下一个宏任务: 宏任务队列为空。

结论:

  1. 同步任务最先执行完 (开头, 结尾)。
  2. 初始宏任务结束后,所有微任务 (Process Next Tick, Promise) 被立即、连续执行完
  3. 然后才执行宏任务 (setTimeout)。
  4. 宏任务 (setTimeout) 执行过程中产生的微任务 (setTimeout --> Promise),必须在该宏任务自身执行完毕后、下一个宏任务开始前,立即执行

案例 3:微任务与宏任务混合

javascript 复制代码
console.log('同步开始'); // 同步1

const promise1 = Promise.resolve('第一个 promise');
const promise2 = Promise.resolve('第二个 promise');
const promise3 = new Promise(resolve => { resolve('第三个 promise'); });

setTimeout(() => { // 宏任务1
    promise2.then(value => { // 宏任务1内部的微任务1
        console.log("下一轮再见");
        const promise4 = Promise.resolve('第四个 promise');
        promise4.then(value => { console.log(value); }); // 宏任务1内部的微任务1产生的微任务
    })
}, 0);

setTimeout(() => { // 宏任务2
    console.log("下两轮再见");
}, 0);

promise1.then(value => console.log(value)); // 微任务1
promise2.then(value => console.log(value)); // 微任务2
promise3.then(value => console.log(value)); // 微任务3

console.log('同步结束'); // 同步2

输出结果:

arduino 复制代码
同步开始
同步结束
第一个 promise
第二个 promise
第三个 promise
下一轮再见
第四个 promise
下两轮再见

执行步骤:

  1. 执行初始宏任务 (<script>):
    • 输出 同步开始
    • 创建 promise1, promise2, promise3
    • 将宏任务1 () => { promise2.then() } 放入宏任务队列
    • 将宏任务2 () => { console.log("下两轮再见") } 放入宏任务队列
    • 将微任务1 () => console.log(promise1的值)、微任务2 () => console.log(promise2的值)、微任务3 () => console.log(promise3的值) 放入微任务队列
    • 输出 同步结束
    • 初始宏任务执行完毕。调用栈清空。
  2. 清空微任务队列:
    • 按入队顺序执行微任务1, 2, 3:
      • 微任务1: 输出 第一个 promise
      • 微任务2: 输出 第二个 promise
      • 微任务3: 输出 第三个 promise
    • 微任务队列清空。
  3. (可能发生 UI 渲染)
  4. 取下一个宏任务 (最先进入宏任务队列的 - 宏任务1):
    • 执行宏任务1的回调:
      • 执行 promise2.then(...): 因为 promise2 早已 fulfilled,所以其回调 value => { ... } 被立即放入微任务队列 (成为宏任务1内部的微任务1)。
    • 宏任务1执行完毕。调用栈清空。
  5. 清空微任务队列 (由宏任务1产生的):
    • 执行刚加入的微任务 (宏任务1内部的微任务1):
      • 输出 下一轮再见
      • 创建 promise4 (状态 fulfilled)
      • 执行 promise4.then(...): 其回调 value => { console.log(value) } 被立即放入微任务队列 (成为宏任务1内部的微任务1产生的微任务)。
    • 该微任务执行完毕,但微任务队列此时非空(刚放入了 promise4 的回调)!
    • 继续清空微任务队列 :
      • 执行 promise4 的回调: 输出 第四个 promise
    • 微任务队列真正清空
  6. (可能发生 UI 渲染)
  7. 取下一个宏任务 (宏任务2):
    • 执行宏任务2的回调: 输出 下两轮再见
    • 该宏任务执行完毕,调用栈清空。
  8. 检查微任务队列: 为空。
  9. (可能发生 UI 渲染)
  10. 取下一个宏任务: 宏任务队列为空。

结论:

  1. 同步任务 (同步开始, 同步结束) 最先执行。
  2. 初始宏任务结束后,其产生的所有微任务 (第一个 promise, 第二个 promise, 第三个 promise) 被立即连续执行。
  3. 执行第一个宏任务 (宏任务1)。宏任务内部可以产生新的微任务 (下一轮再见 对应的回调及其内部的 promise4.then 回调)。
  4. 关键点:宏任务1执行完毕后,必须清空此时微任务队列中的所有任务 (包括宏任务1内部产生的 下一轮再见 和它进一步产生的 第四个 promise),才会去执行下一个宏任务 (宏任务2 - 下两轮再见)。
  5. 微任务队列的清空是彻底的、递归的:只要执行一个微任务的过程中又向微任务队列添加了新任务,引擎会一直执行下去直到队列为空。这保证了微任务的高优先级和执行的连续性。

四、总结

  • 同步优先的情况:永远先执行完当前宏任务中的所有同步代码。
  • 微任务插队的情况 :每当一个宏任务执行完毕(调用栈清空),引擎会立即、连续、彻底地 执行完当前微任务队列中的所有任务。这是实现 Promise 链式调用、async/await 看似同步风格的核心保障。
  • 宏任务接力的情况 :只有在一个宏任务及其产生的所有微任务都执行完毕后,才会从宏任务队列中取出一个 任务开始执行下一轮循环。setTimeoutsetInterval、I/O 等操作的回调都在此列。
相关推荐
Maybyy33 分钟前
力扣242.有效的字母异位词
java·javascript·leetcode
Real_man43 分钟前
新物种与新法则:AI重塑开发与产品未来
前端·后端·面试
小彭努力中43 分钟前
147.在 Vue3 中使用 OpenLayers 地图上 ECharts 模拟飞机循环飞行
前端·javascript·vue.js·ecmascript·echarts
老马聊技术1 小时前
日历插件-FullCalendar的详细使用
前端·javascript
zhu_zhu_xia1 小时前
cesium添加原生MVT矢量瓦片方案
javascript·arcgis·webgl·cesium
咔咔一顿操作1 小时前
Cesium实战:交互式多边形绘制与编辑功能完全指南(最终修复版)
前端·javascript·3d·vue
小马爱打代码1 小时前
Spring Boot:将应用部署到Kubernetes的完整指南
spring boot·后端·kubernetes
卜锦元1 小时前
Go中使用wire进行统一依赖注入管理
开发语言·后端·golang
coding随想2 小时前
JavaScript中的系统对话框:alert、confirm、prompt
开发语言·javascript·prompt