用事件循环详细分析一个简单的 promise 案例

三葉Leaves Author

基础概念

更多关于事件循环的概念,可见 JS 事件循环

为了彻底理解,我们首先要明确 JavaScript 运行时环境的几个核心组成部分:

  1. 调用栈 (Call Stack):一个后进先出(LIFO)的数据结构。当一个函数被调用时,它会被推入栈中;当函数执行完毕返回时,它会从栈中被弹出。JavaScript 主线程的所有同步代码都在这里执行。

  2. 堆 (Heap):一块内存区域,用于存储对象、数组等复杂数据结构。我们代码中的 promise 对象就存放在这里。

  3. Web APIs / C++ APIs:由浏览器(或 Node.js)提供的 API,它们不是 JavaScript 引擎的一部分。例如 setTimeout, DOM 事件, AJAX (fetch) 等异步操作。它们在后台执行,不会阻塞主线程。

  4. 任务队列 (Task Queue / Macrotask Queue):一个先进先出(FIFO)的队列。当 Web API 完成其任务后(比如 setTimeout 的计时器到期),会将其对应的回调函数放入这个队列中。

  5. 微任务队列 (Microtask Queue):另一个先进先出(FIFO)的队列,但它的优先级高于任务队列Promise.then(), .catch(), .finally() 的回调函数,以及 queueMicrotask()MutationObserver 的回调会进入这个队列。

  6. 事件循环 (Event Loop):一个持续运行的进程,它的核心职责是:

    • 监控调用栈是否为空。

    • 如果调用栈为空,它会去检查微任务队列

    • 如果微任务队列不为空,它会依次执行队列中所有的微任务,直到微任务队列清空。

    • 当微任务队列清空后,它会去任务队列(宏任务队列)取出一个任务(如果有的话),并将其推入调用栈执行。

    • 重复以上过程。


代码执行全过程详解

我们把整个过程分解成详细的步骤,并追踪每个核心部分的状态。

初始状态

  • 调用栈: 空

  • Web APIs: 空

  • 微任务队列: 空

  • 任务队列: 空


第 1 步:全局代码执行(作为第一个宏任务)

  1. 整个 script 标签中的代码开始作为第一个宏任务执行。全局执行上下文被推入调用栈

  2. const promise = new Promise(...) 被执行。

  3. 关键点Promise 的构造函数 new Promise(executor) 中的 executor 函数 (resolve, reject) => { ... }立即同步执行的。

  4. executor 函数被推入调用栈

  5. executor 内部,if (true) 条件成立。

  6. setTimeout(...) 被调用。setTimeout 是一个 Web API,它不会在调用栈里等待。JavaScript 引擎将它交给浏览器的 Web API 环境处理。

  7. Web API 环境接收到 setTimeout 的指令,开始一个 2000毫秒 的计时器。同时,它会持有 () => { resolve(...) } 这个回调函数。

  8. setTimeout 函数本身执行完毕,从调用栈中弹出。

  9. executor 函数执行完毕,从调用栈中弹出。

  10. promise 对象已创建,其初始状态为 pending

  11. 代码继续向下执行,遇到 promise.then(...)。由于 promise 状态还是 pending.then 里的回调函数 (params) => { console.log([params]); } 被注册到该 promise 内部的 “onFulfilled” 列表中,等待被触发。

  12. 代码继续向下执行,遇到 .catch(...)。同样,.catch 里的回调函数被注册到该 promise 内部的 “onRejected” 列表中。

  13. 全局脚本的所有同步代码执行完毕。全局执行上下文从调用栈中弹出。

此时的状态 (在 2 秒计时器结束前):

  • 调用栈:

  • Web APIs: 正在运行一个 2000ms 的计时器,关联着一个回调函数。

  • 微任务队列: 空

  • 任务队列: 空

  • Promise 对象: 状态为 pending


第 2 步:计时器到期

  1. 大约 2000 毫秒后,Web API 环境中的计时器完成。

  2. Web API 将它持有的回调函数 () => { resolve("我是传给 .then 的值...") } 放入任务队列(宏任务队列)中排队

此时的状态 (刚过 2000ms):

  • 调用栈: 空

  • Web APIs: 计时器已完成。

  • 微任务队列: 空

  • 任务队列: [() => { resolve(...) }] (有一个待执行的宏任务)


第 3 步:事件循环处理宏任务

  1. 事件循环发现调用栈是空的。

  2. 它检查微任务队列,发现也是空的。

  3. 然后它检查任务队列,发现有一个任务 () => { resolve(...) }

  4. 事件循环将这个任务从任务队列中取出,并将其推入调用栈执行。

此时的状态:

  • 调用栈: [() => { resolve(...) }]

  • 微任务队列: 空

  • 任务队列: 空


第 4 步:Promise 状态变更并触发微任务

  1. 调用栈中的 () => { resolve(...) } 函数开始执行。

  2. resolve("我是传给 .then 的值...") 被调用。

  3. 核心关键点:resolve() 函数的调用会做两件事:

    a. 将 promise 对象的状态从 pending 变为 fulfilled,并保存结果值为 “我是传给 .then 的值…”。

    b. 检查该 promise 上是否有注册的 “onFulfilled” 回调(我们在第1步通过 .then() 注册了)。它发现有一个。

    c. 这个 “onFulfilled” 回调 (params) => { console.log([params]); } 被放入微任务队列中。

  4. resolve() 函数执行完毕。

  5. () => { resolve(...) } 这个宏任务回调函数执行完毕,从调用栈中弹出。

此时的状态:

  • 调用栈:

  • 微任务队列: [(params) => { console.log([params]); }] (有一个待执行的微任务)

  • 任务队列: 空


第 5 步:事件循环处理微任务

  1. 在一个宏任务(第4步中的 setTimeout 回调)执行完毕后,并且在开始下一个宏任务之前,事件循环必须清空整个微任务队列。

  2. 事件循环检查到微任务队列不为空。

  3. 它从微任务队列中取出 .then 的回调函数 (params) => { console.log([params]); },并将其推入调用栈

此时的状态:

  • 调用栈: [(params) => { console.log(...) }]

  • 微任务队列: 空

  • 任务队列: 空


第 6 步:执行 .then 的回调

  1. 调用栈中的 .then 回调函数开始执行。

  2. 参数 params 接收到 promiseresolve 时传递的值,即 "我是传给 .then 的值,会被作为 .then 的参数"

  3. console.log([params]) 被执行。

  4. 控制台输出:["我是传给 .then 的值,会被作为 .then 的参数"]

  5. 该回调函数执行完毕,从调用栈中弹出。

此时的状态 (最终):

  • 调用栈:

  • 微任务队列: 空

  • 任务队列: 空

  • 整个程序执行完毕,等待新的事件。

总结

这个过程的核心在于理解不同任务的调度时机:

  1. new Promiseexecutor同步执行的。

  2. setTimeout 的回调是一个宏任务 (Macrotask),它由 Web API 在计时结束后放入任务队列。

  3. promise.then 的回调是一个微任务 (Microtask),它在 promise 状态变为 fulfilled 时被放入微任务队列。

  4. 事件循环在一个 “tick” 中,会先执行完调用栈中的同步代码,然后清空所有微任务,最后才会去取一个宏任务来执行。这个优先级是整个异步流程的关键。

  • 标题: 用事件循环详细分析一个简单的 promise 案例
  • 作者: 三葉Leaves
  • 创建于 : 2025-07-25 00:00:00
  • 更新于 : 2025-08-13 16:31:27
  • 链接: https://blog.oksanye.com/dcfb1de47793/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论