ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Promise状态机与宏任务微任务执行顺序全解析

Promise状态机与宏任务微任务执行顺序全解析 前段时间在技术群里帮人排查一个线上问题代码很简单就是一个接口请求返回后更新页面的状态但偶发出现状态没更新的情况。后来定位到根因Promise 的 pending 状态一直没落定任务队列里的微任务却没按预期顺序执行。这让我意识到很多人对 Promise 的三种状态和宏任务、微任务的调度关系其实只停留在“背概念”的层面真到了写代码、排bug的时候就露馅了。这篇文章不打算给你念文档我会把 Promise 的状态机、宏任务和微任务的穿插规则结合真实可跑的代码例子拆开揉碎了讲清楚。无论你是刚接触 JavaScript 不久的新手还是写了好几年业务代码但一直没系统梳理过事件循环的老手看完应该都会有收获。文章里有我真实验证过的执行顺序案例也有线上排查报错的思路总结希望能帮你把这块硬骨头啃下来。1. 从回调地狱说起为什么 Promise 值得好好学讲 Promise 之前得先聊聊它出现之前的世界。早期的 JavaScript 异步编程主要靠回调函数。请求一个接口就在参数里传一个回调等数据回来了回调被调用你再在里面继续请求下一个接口。如果业务链路长一点比如先拿用户信息再拿用户订单再拿订单详情代码就会变成一串层层嵌套的结构英文社区管这个叫 “callback hell”中文叫回调地狱。回调地狱的痛点不只是丑。嵌套太深以后代码的可读性、可维护性会急剧下降排查问题的时候你得一层层往上翻找到底是哪一层出的错。更麻烦的是错误处理每一层回调都要单独处理 error漏掉一层异常就可能直接吞掉或者冒泡到不可预期的地方。Promise 的出现就是为了解决这些问题。Promise 本质上是一个对象它代表一个异步操作的最终结果。有了它异步代码可以像同步代码一样用链式调用的方式组织起来先干什么、成功之后干什么、失败之后干什么一目了然。它的核心是状态机pending进行中、fulfilled已成功和 rejected已失败。这三种状态的变化是有严格规则的理解了这套规则后面宏任务和微任务怎么穿插调度才谈得上深入理解。简短总结一下 Promise 解决的三个核心问题解决了回调地狱造成的代码嵌套过深、可读性差的问题统一了异步操作的错误处理方式不再需要每层回调单独捕捉异常提供链式调用机制让异步流程的表达更接近同步代码的直觉2. Promise 三种状态的核心机制2.1 状态机模型pending、fulfilled、rejected 各是什么Promise 的状态机是理解整个机制的第一块基石。我习惯用生活中的场景来类比你点了一份外卖下单之后、骑手送到之前这个订单处于 “pending”也就是进行中。骑手把餐送到你手上订单就是 “fulfilled”成功完成了。如果骑手路上出了状况平台通知你送不了订单就是 “rejected”失败了。对应到代码里Promise 对象创建的那一刻它一定是 pending 状态。构造函数里传进去的那个函数叫 executor它会立刻执行。executor 接收两个参数通常命名为 resolve 和 reject它们都是函数。你在异步操作成功的地方调用 resolvePromise 就从 pending 变成 fulfilled在失败的地方调用 reject就会从 pending 变成 rejected。const promise new Promise((resolve, reject) { // 这里是同步执行的代码 console.log(executor 执行中); // 模拟异步操作 setTimeout(() { const success true; if (success) { resolve(数据加载成功); } else { reject(数据加载失败); } }, 1000); }); console.log(Promise 状态, promise); // 此时是 pending有个细节值得注意new Promise的构造函数里executor 是同步执行的也就是说console.log(executor 执行中)会立即打印不会等到 setTimeout 的回调执行。很多人以为 Promise 里的代码都是异步的这是个误区。真正异步的是 resolve 或 reject 之后触发的 then 回调。2.2 状态一旦落定就不可逆Promise 的状态机有一个非常严格的规定状态只能从 pending 流转到 fulfilled或者从 pending 流转到 rejected一旦状态确定下来就永远不能更改了。不存在从 fulfilled 变回 pending 的情况也不存在从 fulfilled 变成 rejected 的情况。这个特性我称之为 “one-way door”单行道。实际写代码的时候这个特性很重要。比如你向服务器发了一个请求然后用户又取消了操作这时候你不能在代码里把已经 fulfilled 的 Promise “撤销”成 pending。你能做的是在 Promise 外面加一层控制逻辑比如 AbortController 之类的工具而不是试图修改 Promise 本身的状态。也正因为状态不可逆resolve和reject在并发场景下只有一个能生效。先调用谁谁就定了状态后调用的直接忽略。const promise new Promise((resolve, reject) { resolve(第一次成功); reject(第二次失败); // 这条不会生效 resolve(第三次成功); // 这条也不会生效 }); promise.then(value { console.log(value); // 只会输出第一次成功 });这个特性在面试里经常被考察但我发现很多同学只知道“状态不可逆”这句话并不知道真正的原因。所谓不可逆是 Promise/A 规范里定义的它的好处是让异步操作的消费者可以放心地依赖结果不需要担心结果被篡改这在多模块协同的时候特别重要。2.3 状态切换背后的执行细节再往深挖一层状态切换之后二级的 then 回调是什么时候执行的呢答案是微任务。当你调用 resolve() 的时候Promise 的状态变为 fulfilled所有通过 then 方法注册的回调会被放入微任务队列。当前宏任务执行完毕之后事件循环会优先把微任务队列里的任务全部执行干净然后再取下一个宏任务。这里我直接用一个例子来说明状态切换后回调的执行时机console.log(① 同步代码 start); const promise new Promise((resolve) { console.log(② executor 中的同步代码); resolve(已成功); console.log(③ resolve 之后的代码仍然同步执行); }); promise.then((value) { console.log(⑤ then 回调微任务阶段才执行, value); }); console.log(④ 同步代码 end);输出顺序是 ① ② ③ ④ ⑤。第 ⑤ 步的 then 回调虽然写代码的时候位置很靠前但是要等到当前宏任务的所有同步代码执行完之后进入微任务检查阶段才会执行。这个顺序不是拍脑袋定的是 JavaScript 的事件循环机制决定的也就是咱们下一个大节要讲的内容。3. 宏任务与微任务JavaScript 的幕后调度机制3.1 事件循环Event Loop的运行逻辑JavaScript 是单线程的这意味着同一时间只能做一件事。但浏览器里又有那么多异步操作点击事件、定时器、网络请求、渲染页面……这些任务是怎么排队的答案是事件循环Event Loop。事件循环本质上是一个永不停歇的 “取任务→执行任务→取下一个任务” 的循环。但任务不是一个大队列而是分成了两类宏任务MacroTask和微任务MicroTask。事件循环的每一轮大致是这样的流程从宏任务队列中取出一个宏任务执行执行过程中如果产生了新的微任务把它们放进微任务队列当前宏任务执行完毕事件循环检查微任务队列如果有微任务就依次全部执行直到微任务队列清空微任务清空后如果需要渲染浏览器会执行渲染回到第 1 步从宏任务队列中取下一个新的宏任务所以微任务的优先级是高于宏任务的。同一个事件循环 tick 里面所有微任务都会在下一个宏任务开始之前执行完。这句话是理解整个执行顺序的核心建议你多读几遍。我打个比方宏任务像一个个 “公交车班次”微任务像乘客每次公交车到站宏任务执行完乘客微任务会先挤上车下趟车下一个宏任务才轮到。如果乘客里还有乘客微任务里又产生微任务那也要等新乘客上完公交车才能发车。3.2 宏任务有哪些宏任务的常见来源你得心里有数setTimeout 和 setInterval 的回调I/O 操作文件读写、网络请求的回调等UI 事件click、input、scroll 等requestAnimationFrame严格来说它和宏任务不完全等同但在大多数讨论场景里可以归到这一侧页面的渲染流程在 Node.js 环境里宏任务还包括 setImmediate、I/O 回调等。需要注意虽然不同宿主环境的实现细节有差异但 Promise 微任务、setTimeout 宏任务这套大框架是统一的。3.3 微任务有哪些微任务的常见来源相对少一些但每一个都很关键Promise 的 then、catch、finally 回调MutationObserver 的回调queueMicrotask 注册的回调Node.js 环境里的 process.nextTick注意nextTick 的优先级比 Promise 微任务还高它是 Node.js 里特殊的微任务这里要重点强调一下Promise 的 then 回调不是执行完 resolve 就立刻同步调用的而是作为微任务被排入队列。所以你在 then 里拿到的值一定是异步的。就算 resolve 是同步调用的then 里的代码也要等待当前同步代码执行完。3.4 宏任务与微任务的执行顺序实战理论讲多了容易飘直接上一个交错出现的经典例子。先别看答案自己心里推演一遍console.log(A); setTimeout(() { console.log(B); }, 0); Promise.resolve() .then(() { console.log(C); }) .then(() { console.log(D); }); console.log(E);我在本地不同浏览器环境里都跑过输出结果是一致的A、E、C、D、B。为什么 C 在 B 前面因为Promise.resolve().then()产生的回调是微任务而 setTimeout 的回调是宏任务。当前宏任务也就是这整段 script 代码执行完毕后事件循环先去清空微任务队列所以 C 和 D 都会在 B 之前输出。如果你觉得这个例子简单那再看一个微任务里又创建宏任务、宏任务里又创建微任务的例子setTimeout(() { console.log(① setTimeout 外层); Promise.resolve().then(() { console.log(② Promise 内层); }); }, 0); Promise.resolve().then(() { console.log(③ Promise 外层); setTimeout(() { console.log(④ setTimeout 内层); }, 0); });这个例子输出顺序是 ③ ① ② ④。推理过程是这样的代码先执行遇到外层 setTimeout注册一个宏任务 A继续往下遇到 Promise.resolve().then注册一个微任务 M1。当前宏任务结束事件循环优先执行微任务 M1也就是第三行输出“③ Promise 外层”。在 M1 里面又注册了宏任务 B也就是输出“④”的那个。此时微任务队列空了事件循环取出下一个宏任务 A输出“① setTimeout 外层”。A 执行时里面注册了一个微任务 M2输出“② Promise 内层”。A 执行完后微任务队列非空优先执行 M2。所以最终顺序是 ③ ① ② ④。这个例子如果能在不看答案的情况下推对说明你对宏任务微任务的理解已经到位了。4. 任务队列与 Promise 结合的执行顺序实战4.1 then 方法的微任务入队规则then 方法的行为是理解 Promise 链的关键。then 接收两个参数第一个是 fulfilled 时的回调第二个是 rejected 时的回调。then 会返回一个新的 Promise这是链式调用的基础。then 回调什么时候入微任务队列呢有几种情况如果调用 then 的时候原 Promise 已经是 fulfilled 状态那么回调会立即被放入微任务队列如果原 Promise 还是 pending 状态那么回调会被内部保存等到状态变为 fulfilled 时再被放入微任务队列同一个 Promise 可以被调用多次 then每次调用都会注册一个独立的回调这些回调会按调用顺序依次进入微任务队列有一段代码可以验证第 3 点const p Promise.resolve(hello); p.then((value) { console.log(001:, value); }); p.then((value) { console.log(002:, value); }); p.then((value) { console.log(003:, value); });输出是 001、002、003 按顺序打印不会被随便打乱。这说明同一个 Promise 的多个 then 回调是按注册顺序入队并执行的。好多人在这个环节容易疏忽的是每次 then 都返回一个新的 Promise所以后续 catch 接到的不是前一个 then 的结果而是前一个 then 回调返回的结果。如果 then 回调里再 return 一个 Promise那么后面的链式调用会等待这个 Promise 落定这就形成了 Promise 串联的真正威力。4.2 async/await 的语法糖本质很多人学了 async/await 就把 Promise 本身丢了遇到问题一脸懵。实际上async/await 就是 Promise 的语法糖async 函数一定会返回一个 Promise 对象。await 关键字会暂停 async 函数的执行等待右侧表达式的 Promise 落定然后继续执行。这里面最关键的执行顺序考点是await 之后的代码什么时候执行答案还是——微任务。我拆给你看async function test() { console.log(① 进入 async 函数); await Promise.resolve(数据); console.log(② await 之后的代码); } console.log(③ 调用前); test(); console.log(④ 调用后);输出顺序是 ③ ① ④ ②。为什么 ② 在 ④ 的后面因为 await 相当于把 Promise.resolve().then() 后面的部分包装成了微任务回调。test() 函数体从第 ① 步执行到 await 时await 右侧的 Promise.resolve 已经完成但“继续执行 await 之后的代码”这一步会被排入微任务队列所以要等当前宏任务的同步代码全部执行完即第 ④ 步打印完成后才轮到 ②。理解了这一点很多 async/await 的陷阱就清楚了。比如在 for 循环里直接 await会导致每次循环都要等一个微任务周期效率不高但如果在并发场景里想用 Promise.all 加速你又需要理解微任务的特性合理设计代码结构。4.3 复杂嵌套场景的执行顺序拆解实际业务里代码不会像教科书那样一个 setTimeout 加一个 Promise 这么干净。这里我给一个更接近工程实际的嵌套场景一步一步拆假设页面里有一段这样的逻辑先执行一个同步任务然后发起两个并行请求 A 和 B等 A 和 B 都返回后再做下一步处理同时页面里还有一个 setTimeout 定时器到点了。console.log(step 0); setTimeout(() { console.log(step timer); }, 0); const requestA Promise.resolve(A 数据); const requestB Promise.resolve(B 数据); Promise.all([requestA, requestB]) .then((results) { console.log(step all:, results); }) .then(() { console.log(step all-after); }); requestA .then((data) { console.log(step A-then:, data); }); console.log(step end);执行顺序是step 0、step end、step A-thenA 数据、step allA 数据,B 数据、step all-after、step timer。推理过程同步代码直接输出 step 0 和 step end同时把 setTimeout 宏任务、Promise.all 的 then 链微任务、requestA 的 then 微任务都注册好。当前宏任务结束后清微任务队列。微任务入队的顺序是requestA.then 注册的回调先入队代码顺序在前Promise.all 的 then 回调后入队代码顺序在后所以先输出 step A-then再输出 step all。step all 执行完后它返回的新 Promise 进入 fulfilled触发下一个 then输出 step all-after。微任务队列清空最后才执行 setTimeout 宏任务输出 step timer。这个例子展示了一个容易被忽略的事实微任务队列是 FIFO先进先出的同一个 tick 里注册的所有微任务会按注册顺序依次执行。注册顺序取决于代码的执行顺序而不是 Promise 对象创建的顺序。5. 高频报错与排查技巧5.1 未捕获的 Promise 异常写 Promise 代码最常遇到的一类报错就是 “Uncaught (in promise) ...”。这个报错的意思是有一个 Promise 变成了 rejected 状态但是你没有给它加任何 rejected 处理逻辑于是它“把错误丢到了全局”。JavaScript 引擎看到这种没人管的 rejected Promise会直接抛到全局异常。很多线上问题就是这么来的某个异步流程失败了忘了写 catch页面倒不会崩溃但控制台会刷一堆红色报错而且你很难定位到底是哪段逻辑里的哪个 Promise 出的问题。我举一个最常见的写法这真的是我在 code review 里反复看到的function fetchData() { return new Promise((resolve, reject) { // 模拟请求失败 reject(请求失败); }); } fetchData();这段代码执行后控制台会输出Uncaught (in promise) 请求失败。因为 fetchData() 返回的 Promise 变成了 rejected又没有人调用 .catch() 或者 await 它所以被当作未处理的异常。解决办法也很简单fetchData().catch((err) { console.error(处理失败, err); });或者用 async/await 配合 try/catchasync function run() { try { const data await fetchData(); console.log(data); } catch (err) { console.error(捕获到错误, err); } }但要注意一个细节await 只会在 try/catch 里捕获到 rejected 的 Promise如果 await 外面没有 try/catch那么异常同样会成为 unhandled rejection。所以工程上我建议所有 async 函数体内部要么手动包一层 try/catch要么在调用方统一 catch不要裸奔。5.2 常见 Promise 错误速查表这几年我在真实项目里积累了不少和 Promise 相关的错误信息整理成一个速查表方便你排查的时候对照。错误信息可能的含义排查方向Uncaught (in promise) TypeError: Cannot read properties of undefined (reading xxx)then 回调里尝试访问 undefined 的属性说明异步数据没有按预期返回或者接口返回结构变了在 then 回调开头打日志先打印完整返回数据确认数据结构Uncaught (in promise) NotFoundError: Failed to execute insertBefore on NodePromise 回调执行时对应的 DOM 节点已经被移除导致插入操作找不到父节点检查页面卸载、路由切换时是否有未取消的异步回调在操作 DOMUncaught (in promise) Error: A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received浏览器扩展通信或者消息通道场景下异步响应超过生命周期确认 postMessage 或 Extension 消息监听的回调里异步返回的时机是否过晚Unhandled promise rejection TypeError: WebAssembly.instantiate()WebAssembly 加载或实例化失败的 Promise 没有被处理确认 wasm 文件路径、MIME 类型以及浏览器是否支持相关特性Stream disconnected before completion: too many pending requests并发请求数量超过流连接限制增加请求并发控制或复用连接池避免一次性发太多未完成的请求其中前两类在业务代码里最常见。我还想单独强调一条任何Unhandled promise rejection类型的报错都不要只加一个 console.log 骗自己。在 nextTick 或者 setTimeout 里统一让全局错误上报能把这种隐性 bug 提前暴露出来比等用户反馈强得多。5.3 几个实用的调试小工具与习惯回调地狱难排查Promise 链变长了其实也不好排查。好在现在有会话式调试的手段。我自己常用的套路是下面这三个一是善用 Chrome DevTools 的 Promise 面板。新版 DevTools 在 Sources 面板里可以查看 Promise 的当前状态和值。先在代码里打好断点然后逐步执行可以看到每个 Promise 现在是 pending、fulfilled 还是 rejected以及它的 value 是什么。这对于排查“状态为什么一直 pending”这一类问题非常有帮助。二是在 then 链里插桩。与其靠猜不如在每一步 then 的前面加一行日志输出把输入和输出都记录下来api.fetchUser() .then((user) { console.log(user then:, user); return api.fetchOrders(user.id); }) .then((orders) { console.log(orders then:, orders); return orders; }) .catch((err) { console.error(catch:, err); });日志输出之后一眼就能看出数据在哪一步断了是返回 undefined 了还是根本没执行到那一步还是被 catch 拦截了。三是利用全局事件监听未处理的 Promise 异常。在浏览器环境里做全局上报window.addEventListener(unhandledrejection, (event) { console.error(捕获到未处理的 Promise 异常, event.reason); // 这里可以上报到监控平台 event.preventDefault(); });在 Node.js 环境里则是监听 process 的unhandledRejection事件。有了这个兜底至少线上问题不会被吞掉你能及时看到错误痕迹。6. 最后的几点手记写了这么多最后再分享一点个人经验。宏任务和微任务的机制最有效的学习方式不是背规则而是拿笔在纸上推演执行顺序。找几个经典的混合例子把代码一行一行展开画两个队列手动模拟事件循环的一轮轮推进推错的题目就标记出来下次再练。我在带新人时试过这个办法效果比直接看文档快得多。另外Promise 和事件循环不是割裂的两个知识点。Promise 之所以是微任务是因为它要保证异步回调的及时性和优先级避免因为一个宏任务的延迟导致整个异步链路卡顿。理解了设计动机很多细节就容易记住了而不是死记硬背“先微后宏”。最后一点线上出问题的时候别急着抄代码改代码。先深呼吸把日志打开把数据流捋一遍很多 Promise 相关的 bug 其实都是数据流断在了某个地方而不是 Promise 本身的问题。记住工具是你手里的剑但清晰的逻辑才是你的剑谱。
返回列表