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 等操作的回调都在此列。
相关推荐
Likeyou710 分钟前
HTML无尽射击小游戏包含源码,纯HTML+CSS+JS
javascript·css·html
Befool16 分钟前
elpis - 前端全栈框架
javascript
前端Hardy16 分钟前
这个你一定要知道,如何使用Pandoc创建HTML网页版文档?
前端·javascript·css
babii17 分钟前
将数组数据下载为 csv 文件,上传 csv 文件解析为数组
javascript
前端小嘎19 分钟前
常见前端面试题 之 AI打字机效果是如何实现的?
前端·javascript
回家路上绕了弯21 分钟前
Redis 全方位实战指南:从入门到精通的缓存利器
redis·后端
小高00721 分钟前
💥React 事件绑定与响应:从“懵圈”到“秒懂”,附 5 个实战技巧
前端·javascript·react.js
coding随想25 分钟前
数据库操作的“魔法公式”:深入浅出常用数据库操作
后端
肖恩想要年薪百万25 分钟前
ElementUI常用的组件展示
前端·javascript·elementui
麦兜*26 分钟前
Spring Integration 整合 Web3.0网关:智能合约事件监听与Spring Integration方案
java·spring boot·后端·spring·spring cloud·web3·智能合约