ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从状态机到微任务:ES6 Promise 异步编程核心原理与工程实践

从状态机到微任务:ES6 Promise 异步编程核心原理与工程实践 从 Promise 的底层状态机开始拆解 ES6 异步编程的核心思路。记得我第一次用 ES6 的 Promise 重写一段多层嵌套的回调代码时最大的感受不是代码变短了而是整个程序的节奏感变了。以前写异步逻辑脑子里要同时维护三个东西当前逻辑在哪一层、上一层的数据怎么传下来、如果出错要往哪一层抛。用 Promise 之后这些事交给状态机去管我只需要关注接下来做什么和失败怎么办。这篇文章不打算再讲一遍 MDN 上的 API 清单我想从 Promise 的设计逻辑、运行机制、实际场景和踩坑记录这几个方向把这块硬骨头嚼碎。适合谁看已经写过一段时间 JavaScript、被回调地狱折磨过、想真正理解 Promise 而不只是会用 then 和 catch 的开发者。看完你应该能回答这几个问题Promise 为什么不可取消then 回调为什么一定是异步的那些 Uncaught (in promise) 错误到底怎么来的1. 从回调到 Promise我们到底在解决什么问题所有现代异步方案的出发点都是同一个痛点JavaScript 是单线程的但业务场景不允许卡住等结果。从最早的 setTimeout 到 XMLHttpRequest到 Node.js 的 EventEmitter 和回调约定再到 Promise 和 async/await解决的核心矛盾始终是如何把一个稍后才会出现的结果安全、可组合、可维护地接入当前代码流。1.1 回调地狱的本质不是嵌套是信任缺失网上聊回调地狱大多数例子是三层以上的 setTimeout 嵌套看起来是代码缩进太深的问题。但我实际项目里遇见最难受的不是写起来丑而是控制权完全交出去了回调函数什么时候执行、执行几次、参数到底怎么传这些全部由调用方决定。比如用第三方 SDK 加载资源你根本不知道它内部会不会出错后不调用回调会不会一个成功回调触发两次。这就是所谓的信任边界问题。换个角度理解普通同步代码的执行我们可以天然信任——要么返回结果要么抛出异常不会有第三种情况。回调函数把这套信任模型打破了。Promise 干的头等大事不是让你的代码变短而是把结果容器和业务逻辑绑定起来Promise 的状态一旦决定就永远固定不管执行方怎么折腾你拿到的结果一定是确定的。1.2 Promise 的三态理念把异步当状态机看待Promise 的英文原意是承诺这个命名很准。一个 Promise 对象内部维护三个状态pending进行中、fulfilled已成功、rejected已失败。状态只能从 pending 变到 fulfilled 或 rejected一旦变更就冻结任何手段都无法撤销。这张状态表值得贴在显示器旁边状态含义触发条件后续行为pending结果未定刚创建executor 尚未调 resolve/reject等待状态迁移fulfilled执行成功调用 resolve 且未被 reject调用后续 then 的成功回调rejected执行失败调用 reject 或抛出未捕获异常调用后续 catch/then 的失败回调这个状态机设计最妙的地方在于它保证了结果只产生一次。我之前遇到过极端情况某个支付回调函数里因为网络抖动导致代码重复触发了资源释放逻辑资源被释放了两次。如果当初把释放逻辑放在 Promise 里这种隐患根本不存在——resolve 之后的状态已经是 fulfilled你再调 resolve 也没用。2. Promise 核心 API 背后的设计逻辑光知道 API 的用法是入不了门的因为每个 API 的设计都在回答一个具体问题then 返回什么catch 能接住什么allSettled 和 all 有什么区别搞清楚这些问题你才能在真实业务里选对工具。2.1 then 链的秘密返回值决定下一步的路线图then 方法每次返回的都是一个新的 Promise 对象而不是原来的那个。这是我见过最多人忽略的细节。如果回调中返回一个普通值那么这个值会被包装成 fulfilled 状态的 Promise 继续往下传如果返回一个 Promise那么后续的 then 等的是这个 Promise 的结果如果在回调里 throw 一个新错误后续的 catch 会接到它。这个返回值机制有个特别实用的推论你可以随时用 then 的返回值为异步流插入一个数据变换节点。比如请求用户信息后紧接着在 then 里把头像地址拼上 CDN 前缀再往下传的就是处理完毕的数据。代码读起来像流水线一个节点只做一件事。另一个容易忽略的点是 then 的回调永远在当前同步代码执行完之后才触发。就算 Promise 已经 resolve 了注册到 then 上的回调也只会在微任务队列里执行不会在当前调用栈里立即执行。很多诡异的执行顺序问题根源都是这个时间差。我在后面排查章节会讲一个具体例子。2.2 catch 与 finally 的定位不是语法糖那么简单很多教程把 catch 当成then 里的第二个回调的简写这么理解在功能上没错但容易让人忽略一个重要差异then 的第一个参数是 successHandler第二个参数是 errorHandler但如果你写的成功回调里抛了一个异常第二个参数接不到。而 catch 注册的回调能接到链条上任何位置抛出的异常。有个经典面试题正好说明这一点Promise.resolve() .then(() { throw new Error(a); }, () { console.log(这里接不到); }) .catch((err) { console.log(这里能接到, err.message); });至于 finally我实际项目里大部分用来做无论成败都要执行的收尾动作关 loading、释放锁、记录日志。注意 finally 的回调不接收任何参数而且如果 finally 回调自身返回一个 pending 状态的 Promise外层会等它完成后再继续。这个行为可以用来做优雅关闭连接之类的操作。2.3 四个静态方法覆盖了并发场景的半壁江山Promise.all、Promise.race、Promise.allSettled、Promise.any 各有各的适用场景。方法行为典型场景all全部成功才 fulfilled一个失败即 rejected并行拉取多个接口的数据race谁先改变状态就用谁的结果超时控制allSettled所有 Promise 都完成后返回结果数组包含每个的状态批量操作中允许部分失败any只要有成功就 fulfilled全部失败才 rejected多个备用资源任选一个我在一些小册子和开源项目里见过一个共性错误不管什么场景一上来就 Promise.all。但类似批量重新上传压缩图片这种任务其中一张图片可能永远处理失败此时全链路直接 reject其他成功数据全丢。正确做法是 allSettled拿到每条结果后自己筛选。关于 Promise.race 做超时的原理一句话就能说清把业务 Promise 和一个 setTimeout 后 reject 的定时炸弹赛跑谁先到就采用谁的结果。但这里有个很隐蔽的坑我留在第 4 部分展开。3. 实操手写一个简版 Promise 并完成三个经典场景知识点讲再多不如动手写一遍。我建议每个想彻底搞懂 Promise 的开发者都尝试在不看源码的情况下自己实现一个简版 Promise。这个过程会让你对 then 的注册时机、状态回调的执行顺序、嵌套 Promise 的拍平等细节有肌肉记忆。3.1 手写简版 Promise 的逐步拆解核心设计并不复杂维护 state、value、reason 三个内部变量外加一个回调队列严格说是 then 回调的订阅者数组。executor 在构造时同步执行resolve/reject 会迁移状态并清空队列。class MyPromise { constructor(executor) { this.state pending; this.value undefined; this.reason undefined; this.onFulfilledCallbacks []; this.onRejectedCallbacks []; const resolve (value) { if (this.state ! pending) return; this.state fulfilled; this.value value; this.onFulfilledCallbacks.forEach((fn) fn()); }; const reject (reason) { if (this.state ! pending) return; this.state rejected; this.reason reason; this.onRejectedCallbacks.forEach((fn) fn()); }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { if (this.state fulfilled) { onFulfilled(this.value); } else if (this.state rejected) { onRejected(this.reason); } else { this.onFulfilledCallbacks.push(() onFulfilled(this.value)); this.onRejectedCallbacks.push(() onRejected(this.reason)); } } }这个版本是最基础的骨架能跑通最简单的例子。但它没有实现 then 返回新 Promise、回调异步执行这些关键行为所以真正生产级的手写版本要复杂得多。我上面故意保留简化版就是要让你看到核心机制状态的单向迁移和回调数组的消费逻辑。一个实用的练习思路写完基础版后测试三个问题——同一个 Promise 多次 then 是否都会执行resolve 之后再调用 reject 是否无效then 回调里抛错能否被共享给后面的 catch每修一个 bug你对 Promise 的理解就深一层。3.2 场景实战用 async/await 重构回调代码Promise 真正融入工程是靠 2017 年进入标准的 async/await。async 函数本质上就是返回一个 Promiseawait 则是在等一个 Promise 的结果并把后续代码挂到微任务上。来看一个真实的项目片段用户上传身份证图片需要先压缩、再上传、再提交表单。用原来的回调写法三层嵌套配合 loading 状态控制很痛苦。async function handleUpload(file) { try { const compressed await compressImage(file); // 返回 Promise const uploaded await uploadFile(compressed); // 返回 Promise const formData await submitForm(uploaded); // 返回 Promise return formData; } catch (error) { trackError(error); throw error; } finally { closeLoading(); } }这里的语义非常接近同步代码了。需要留意的是如果你在一个 async 函数里使用 try/catchcatch 拿到的其实就是 Promise 链 reject 出来的 reason。所以 async/await 和 Promise 不是替代关系而是同一枚硬币的两面——await 帮你抹平了 then 的回调嵌套但底层的微任务机制一点都没变。3.3 场景实战深拷贝与异步任务的结合点搜索热词里有个es6 深拷贝刚好可以拿来做个延伸场景。普通的深拷贝是同步操作但如果数据量巨大——比如一个几万节点的大对象同步拷贝会长时间阻塞主线程页面这时就是假死状态。结合 Promise可以把大对象拆成若干个分片每个分片用 setTimeout 放到任务队列里异步执行最后再用 Promise.all 汇总结果。function deepCopyAsync(source, chunkSize 500) { return new Promise((resolve, reject) { const keys Object.keys(source); let index 0; const result Array.isArray(source) ? [] : {}; function nextChunk() { if (index keys.length) { resolve(result); return; } const end Math.min(index chunkSize, keys.length); setTimeout(() { try { for (let i index; i end; i) { const key keys[i]; const value source[key]; result[key] value typeof value object ? deepCopySimple(value) : value; } index end; nextChunk(); } catch (err) { reject(err); } }, 0); } nextChunk(); }); }这个例子不是让你把深拷贝全改成异步而是要理解 Promise 的边界它能帮你把大任务切小片这件事变得有序可管理。实际项目中我更多是把这个思路用于大列表数据的渲染分片。4. 排查实录为什么总在控制台看到 Uncaught (in promise)很多新手包括以前的我最崩溃的瞬间就是在控制台看到红色大字Uncaught (in promise) Error。这个报错本身很违背直觉——我明明在代码里写了 catch为什么还是报 Uncaught4.1 错误出在哪里链条之间的黑洞核心原因就一句话你写的 catch 挂在一条 Promise 链上但错误发生在另一条链上。Promise 链在订阅关系上是独立的A 链上的 catch 接不到 B 链上的错误。典型的例子const p1 someAsyncTask(); // 这条链没有 catch const p2 p1.then((val) { return val.data; // 这里发生错误 }); p2.catch((err) { /* 接的是 p2 链的错误 */ });我踩过最深的一种坑某个接口请求失败时返回的数据结构里没有 data 字段此时方法直接被调用产生 TypeError但项目里有依赖注入关系这段逻辑被注入到另一个模块的 Promise 链里造成错误在原链路上无人处理控制台直接报 Uncaught (in promise)。提示不管你的 catch 写在哪只要还有一条分支没有消费 reject浏览器就会认为异常未被处理从而打印 Uncaught (in promise)。4.2 热词里的那个 a listener indicated an asynchronous response搜索热词中出现了 uncaught (in promise) error: a listener indicated an asynchronous response b。这个报错的完整形态通常是 Chrome 扩展 API 开发时遇到某个事件监听器的回调里返回了 Promise但事件的注册机制期望你同步返回或者在回调里调用了一个异步操作导致扩展的 sendResponse 机制等待超时。这类问题严格来说不完全是 Promise 的语法错误而是Promise 与宿主环境规则冲突。处理办法是检查事件监听器的回调里是否使用了 async 关键字并且没有显式调用 sendResponse。如果业务需要异步响应要么在回调中调用 event.preventDefault() 显式告诉扩展我要异步响应要么干脆去掉 async改回传统的同步返回加回调写法。这种报错给我的启示是Promise 只是你代码的工具它不会自动适配所有宿主环境的事件约定。读框架源码或插件文档时先搞清楚它们对回调的期望是同步还是异步。4.3 事件循环里的排查技巧微任务队列的执行顺序我自己定位 Promise 相关 bug 的经验是先用 console.log 给每个 then、catch、finally 加标记把它们真实执行顺序打出来。再用 Node 的 --trace-warnings 或浏览器的 Promise rejection 面板辅助判断。这里分享一个我自己整理过的关键时间线同步代码 → Promise 回调微任务→ 渲染/IO 回调宏任务。在同一轮事件循环里所有微任务会在宏任务之前清空。这个顺序决定了一个现象即使业务 Promise 立即 resolve它的 then 回调也排在当前同步代码之后、setTimeout 之前。setTimeout(() console.log(macro)); Promise.resolve().then(() console.log(micro)); console.log(sync); // 输出顺序sync → micro → macro你没看错setTimeout 哪怕延时为 0也要等微任务全部清空。排查异步顺序问题时先想清楚这个顺序能省很多时间。5. 工程化落地的几点建议与总结性心得把 Promise 用好不只是会写 then 和 await而是要建立一整套异步编码的规范感。有些规范是我们的团队在大量 code review 里沉淀出来的分享给你参考。5.1 善用泛型与类型定义让 Promise 不再黑盒在 TypeScript 项目里不要写裸的 Promise 类型。比如function getUser(id: string): PromiseUser { return fetch(/api/user/${id}).then((res) res.json()); }这个写法在静态检查阶段就能发现数据结构不匹配的问题。类型定义等于给 Promise 的内容提前画了蓝图至少能挡住一半的字段拼写错误。5.2 避免 Promise 地狱组合优于嵌套Promise 地狱是我自创的讲法有些人用 Promise 后确实不回调嵌套了但把链式 then 变成了一长串电线。实践上优先把准备数据、格式化、调用接口、后处理这几个阶段拆成小函数再用 async/await 在一个流程函数里组合它们。每个函数只做一件事Promise 只是它们之间的传输通道。5.3 批量任务里的优雅降级给一个我在维护一个中后台项目时处理过的真实场景表单里 20 个字段分别来自不同的接口我希望整体一起加载但不希望某一个接口失败就整页白屏。我的方案是const results await Promise.allSettled([ fetchFieldA(), fetchFieldB(), fetchFieldC(), ]); const errorFields results.filter((r) r.status rejected);拿到 errorFields 之后页面展示局部错误提示其余字段正常渲染。这个方案的代价只是多写了几行过滤代码但用户体验差别巨大。5.4 最后分享一个小技巧给 Promise 增加超时保护与可取消标志DOM 里确实没有原生的 Promise 取消方法但可以通过标志位实现逻辑取消。最常见的做法是结合 AbortController——fetch 请求本身能取消但你业务里的复杂 Promise 链条可以在外层包一个状态对象function createCancellablePromise(executor) { let cancelFlag false; const wrapped new Promise((resolve, reject) { executor( (value) (cancelFlag ? reject(new Error(cancelled)) : resolve(value)), (reason) reject(reason) ); }); wrapped.cancel () { cancelFlag true; }; return wrapped; }这个模式在我处理用户快速切换 Tab 导致旧的请求结果覆盖新页面这些问题时非常有用。核心思路不是真正撤销而是让过期的结果失去生效资格。说了这么多Promise 本质上就是把稍后才有结果这件事用状态机的方式变得可控、可组合、可信任。多写多查多思考熟练之后异步流就没有那么悬了。
返回列表