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 等操作的回调都在此列。
相关推荐
知识分享小能手2 小时前
React学习教程,从入门到精通,React 单元测试:语法知识点及使用方法详解(30)
前端·javascript·vue.js·学习·react.js·单元测试·前端框架
余衫马3 小时前
Windows 10 环境下 Redis 编译与运行指南
redis·后端
青柠编程5 小时前
基于Spring Boot的竞赛管理系统架构设计
java·spring boot·后端
Min;5 小时前
cesium-kit:让 Cesium 开发像写 UI 组件一样简单
javascript·vscode·计算机视觉·3d·几何学·贴图
EveryPossible5 小时前
展示内容框
前端·javascript·css
子兮曰6 小时前
Vue3 生命周期与组件通信深度解析
前端·javascript·vue.js
拉不动的猪6 小时前
回顾关于筛选时的隐式返回和显示返回
前端·javascript·面试
s9123601016 小时前
【rust】 pub(crate) 的用法
开发语言·后端·rust
gnip6 小时前
脚本加载失败重试机制
前端·javascript
颜酱8 小时前
实现一个mini编译器,来感受编译器的各个流程
前端·javascript·编译器