ARTICLE DETAIL

资讯详情

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

彻底搞懂 JavaScript Promise:状态机、链式调用与错误处理

彻底搞懂 JavaScript Promise:状态机、链式调用与错误处理 前端干了这么多年Promise 是我见过最容易被“用错但用对”的概念。你说不会吧.then、.catch、async/await用得飞起你说会吧控制台蹦出uncaught (in promise) error就开始懵一听到“手写 Promise”就发怵。ES6 引入 Promise 已经很多年它是 JavaScript 异步编程的基石也是面试笔试绕不开的考点。这篇文章我想把 Promise 拆开揉碎讲一遍结合我实际踩过的坑说说它为什么这样设计以及怎么用才不容易出问题。无论你是刚接触前端的小白还是写过几年业务但没深究过原理的开发者应该都有自己的收获。1. 回调地狱不是段子Promise出现之前的世界1.1 一个真实的嵌套场景先说点历史。ES6 之前JS 处理异步基本靠回调函数。单个回调还好可一旦异步之间有依赖关系代码就失控了。我早期写过一段接口联调代码需求很朴素先拿用户信息再拿这个用户的文章列表最后拿第一篇文章的评论。用回调写出来大概长这样getUser(userId, function (user) { getPosts(user.id, function (posts) { getComments(posts[0].id, function (comments) { renderComments(comments); }, handleError); }, handleError); }, handleError);四层嵌套每层都要把handleError传进去一遍。看着就已经很费劲了对吗这只是三个请求。如果后面产品说“还要文章作者信息”“还要文章里的图片列表”这个金字塔还会继续往上盖。我当时改这种代码最怕的不是逻辑复杂而是改完一层括号没对上或者错误被某个回调吞了根本不知道哪里出的问题。后来再看这段代码明白了它真正的痛点不是缩进难看而是异步流程的控制权被分散到了每一层回调里你没法从头部一眼看出整个流程的走向。1.2 回调方案的三宗罪我把原生回调方案的痛点总结成三个可读性差横向缩进越来越深代码像一座向右倾斜的比萨斜塔。你不是在读代码你是在翻越代码。错误处理分散每一层都要单独传错误回调漏一处错误就像掉进黑洞如果中间某层既没传错误处理又抛了个异常整个流程就静默失败线上问题排查起来非常痛苦。流程复用的成本高回调里套回调每一步的逻辑都和上一步的变量强绑定你很难抽出一个独立函数去做单元测试。想并行发三个请求再统一处理结果纯回调告诉你要么手动维护一个计数器要么借助第三方库。Promise 并不是发明了什么全新的机制它只是把“回调”这套朴素的写法做了一次系统化的升级把异步结果抽象成对象把流程控制收拢到一条链上把错误处理集中到一个出口。理解这一点就抓住了 Promise 的核心价值。2. 状态机才是Promise的灵魂三个状态和两种转变2.1 三个状态和一次转变Promise 本质是一个状态机它内部只有三个状态pending进行中、fulfilled已完成、rejected已失败。状态只能从pending出发变成fulfilled或rejected一旦转变到位就永远冻结了。可以类比成点外卖你下单之后订单状态是“商家制作中”这是pending。之后要么“骑手已送达”这是fulfilled要么“订单已取消/配送失败”这是rejected。订单不可能从“已送达”又变回“制作中”也不可能送了两次。Promise 也一样resolve之后再调reject后一次操作会被忽略const p new Promise((resolve, reject) { resolve(第一次成功); reject(第二次失败); // 无效 }); p.then(value { // 只会走到这里 console.log(value); // 第一次成功 });这个“状态不可逆”的设计有个重要的实际意义你可以放心地把同一个 Promise 传给多个消费者比如多个.then都挂在同一个 Promise 上不管它什么时候 resolve所有消费者拿到的结果都一致而且不会因为某个消费者先触发就改变状态、影响其他消费者。2.2 resolve 和 reject 的参数不简单很多人以为resolve只能传成功数据reject只能传错误对象这个理解基本对但有个细节容易忽略resolve 里面可以传另一个 Promise。假如你写const p1 new Promise((resolve) { setTimeout(() resolve(p1 的结果), 1000); }); const p2 new Promise((resolve) { resolve(p1); // resolve 里传了一个 Promise }); p2.then((value) { console.log(value); // 1秒后输出: p1 的结果 });p2会等着p1完成再把p1的结果透传下去。这个行为看起来简单但它是后面很多高级写法的地基比如串行任务里return promise之所以能自动等待靠的就是这套机制。reject那边还有个大家默认接受但知道的人不多的事实不管reject里传什么它都会把状态标记为rejected并让后续的onRejected回调拿到这个值。你甚至可以reject一个普通字符串不推荐但语法上合法。2.3 then 返回值为什么必须是全新 Promise.then()方法永远返回一个新的 Promise而不是返回原来的那个。这是整条链式调用的核心机制const original Promise.resolve(1); const next original.then(value { return value 1; }); console.log(original next); // false每次.then都开了一个新的“炉子”。这个新 Promise 的状态由什么决定呢取决于你传给.then的两个回调里的返回值如果回调返回普通值新 Promise 就resolve这个值如果回调返回 Promise新 Promise 会等这个 Promise 的结果如果回调抛出异常新 Promise 就变成rejected。这就像一个流水线原料从上一站流过来你加工一下放到下一站的入口至于下一站做什么跟你无关。这种设计让 Promise 链天然支持“分段处理”每一段只需要关心自己收到的输入和自己的输出。3. 手写一个可用的miniPromise从零理解核心机制3.1 为什么建议你手写一遍听课一万遍不如自己写一遍。手写 Promise 不是为了在面试里背代码而是为了把状态机、任务队列、链式调用这几个概念彻底融进你的肌肉记忆。我写第一版的时候一个最直观的感受就是原来.then的本质是“把回调存进队列等状态定了再挨个取出来执行”这就是发布订阅和 DOM 事件监听没有本质区别。下面这个MiniPromise是一个简化但五脏俱全的版本我把注释写得详细一些class MiniPromise { constructor(executor) { // 初始状态恒为 pending this.state pending; // 成功值后续 resolve 时写入 this.value undefined; // 失败原因后续 reject 时写入 this.reason undefined; // 当 then 调用时状态还是 pending就先存起来 this.fulfilledQueue []; this.rejectedQueue []; const resolve (value) { // 状态一旦脱离 pending就再也不能改 if (this.state ! pending) return; this.state fulfilled; this.value value; this.fulfilledQueue.forEach((fn) fn(value)); }; const reject (reason) { if (this.state ! pending) return; this.state rejected; this.reason reason; this.rejectedQueue.forEach((fn) fn(reason)); }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { // 如果没有传对应处理函数做透传和抛错保证链式不中断 onFulfilled typeof onFulfilled function ? onFulfilled : (v) v; onRejected typeof onRejected function ? onRejected : (e) { throw e; }; // 每次 then 都返回新的 MiniPromise return new MiniPromise((resolve, reject) { const handle (handler, isFulfilled) { // 用 queueMicrotask 模拟真实 Promise 的微任务行为 queueMicrotask(() { try { const result handler(isFulfilled ? this.value : this.reason); if (result instanceof MiniPromise) { result.then(resolve, reject); } else { resolve(result); } } catch (err) { reject(err); } }); }; if (this.state pending) { this.fulfilledQueue.push((v) handle(onFulfilled, true)); this.rejectedQueue.push((e) handle(onRejected, false)); } else if (this.state fulfilled) { handle(onFulfilled, true); } else { handle(onRejected, false); } }); } catch(onRejected) { return this.then(null, onRejected); } }3.2 七个关键设计点逐个看第一constructor里用try/catch包裹executor是有讲究的。因为executor是同步执行的你写new Promise(() { throw new Error(bad) })时原生 Promise 会在构造阶段就捕获这个错误并让 Promise 直接进入rejected状态。MiniPromise做了同样的事。第二resolve和reject里都有一句if (this.state ! pending) return;。这就是状态守卫对应前面说的不可逆。真实的 Promise 里同样有类似的保护。第三then里如果不传处理函数会补默认函数。特别注意的是onRejected的默认值是(e) { throw e; }这意味着错误不会被吞掉而是继续往链下游抛。所以写p.then(成功函数)而不写第二个参数时错误会自动继承给下一个.catch这就是异常在链上传播的机制。第四then内部返回new MiniPromise。这是链式调用的核心也是手写代码里最绕的一环回调里return x时我们要让新 Promise 以这个结果为基准进入fulfilled回调里返回 MiniPromise 时我们要等它结束再做下一步。第五queueMicrotask用来模拟微任务队列。原生 Promise 的回调不会像 setTimeout 那样拖到宏任务才执行而是在当前宏任务结束后立刻执行。手写版用queueMicrotask逼近这个时序能保证“先同步注册回调、后触发 resolve”时回调依然按微任务顺序执行。第六pending状态下的fulfilledQueue/rejectedQueue是标准的发布订阅模式。异步场景中你经常会遇到这个顺序.then已经注册了但resolve还没被调用。此时回调无处安放只能放进队列等状态变更时统一触发。第七catch其实就是then(null, onRejected)的语法糖。这一点不理解的人很多其实catch并不特殊它就是链式处理的便捷入口。3.3 和原生 Promise 还差多少这个MiniPromise能正常处理绝大多数业务链式调用但它和原生 Promise 还有明显差距主要在三处没有完整实现Promise Resolution Procedure原生 Promise 在处理resolve传入的值时会判断它是不是thenable也就是看起来像 Promise 的对象而MiniPromise只认自己是instanceof MiniPromise的这个类。因此它无法和真实 Promise 互相打通。微任务时序不够精确queueMicrotask是标准的微任务调度但原生 Promise 采用的是引擎内部更早初始化的 microtask checkpoint在某些极端的执行顺序上会有差异。手写版用来理解机制够用线上别当真。缺少静态方法和全局行为原生 Promise 还有all、race、allSettled、any以及unhandledrejection的全局反馈机制MiniPromise都没有。一句话总结手写 Promise 的目标是理解原理不是写一个真正用在生产环境的替换品。4. 链式调用与异常处理uncaught in promise是怎么来的4.1 报错根因一条断头的链先看报错本身。uncaught (in promise) error中文翻译过来就是“promise 里有一个错误没有被捕获”。这和普通throw new Error不一样因为 Promise 里的错误是延迟发生的它只在 Promise 链上传播如果链上没有任何.catch或第二参数处理它这个错误就“无家可归”最终被引擎抛到全局浏览器控制台就会显示这个刺眼的红字。一个很典型的错误写法function requestData() { return new Promise((resolve, reject) { reject(接口请求失败); }); } requestData().then(data { // 这里只处理了成功没有处理失败 console.log(data); }); // 控制台立马报: uncaught (in promise) error整条链只有一个.then且.then没有第二参数错误没有后续出口于是被系统丢弃到全局。这个报错不是运行时性能问题它意味着你的程序里有一块异常逻辑正在沉默地失败——用户看不到反馈但后续逻辑可能已经全乱了。4.2 复盘一次事件监听器报错的排查下面这个坑我印象很深。某天我在一个内部项目里给列表添加了一个点击监听写得很顺手listEl.addEventListener(click, async () { const detail await fetchDetail(listEl.dataset.id); renderDetail(detail); });看着没问题吧但实际运行后只要fetchDetail因为接口超时 reject控制台就会冒出一句类似uncaught (in promise) error: a listener indicated an asynchronous response b的报错。这句报错最迷惑人的地方是它并没有直接指出我哪一行代码出问题而是指向“监听器返回了异步响应”这个事实。我当时的排查顺序是这样的第一步先看完整堆栈。顺着堆栈往上找发现报错来源不是fetchDetail内部而是整个addEventListener的回调区域。第二步意识到async函数本质上会返回一个 Promise。也就是说addEventListener收到的其实不是我没有关心的回调而是一个可能 reject 的 Promise。浏览器的事件系统拿到这个 Promise发现它失败了但事件系统本身并不知道该拿这个失败怎么办于是直接把它丢给全局报了这个错误。第三步修复。解决方式是在回调内部把所有逻辑包上try/catch或者在监听函数内部加一个兜底listEl.addEventListener(click, async () { try { const detail await fetchDetail(listEl.dataset.id); renderDetail(detail); } catch (err) { console.error(列表详情加载失败, err); showToast(详情加载失败请稍后重试); } });修复完这个报错自然就消失了。这个案例的启示是所有以事件监听器、定时器、异步回调为入口的函数如果用了 async/await都不能假设异常会被外部接住。它们是流程的“末端”异常在这类入口必须就地处理。4.3 一个防呆习惯链尾必带catch不要在生产环境写“断头链”。我的习惯是只要一个 Promise 链用于入口流程不是作为返回值交给上层末尾永远挂.catch要么就包 try/catch。这样既能在出错时记录日志也能给用户一个提示而不是让错误消失在控制台里。另外如果链中间某段有 catch但 catch 之后还有后续流程要格外小心 catch 的返回值。比如fetchUser() .catch(err { // 这里如果不重新 throw错误就被吞了 }) .then(() { // 错误之后依然会执行到这里 });如果你想让错误终止整个链catch 里要么throw要么返回一个Promise.reject。否则 catch 就真的“接住”之后顺手把流程放行了这有时候是你想要的比如降级处理有时候是灾难比如关键业务在失败后继续提交脏数据。怎么区分记住一句话catch 之后是否需要继续执行完全由你决定不要因为写了 catch 就默认流程安全。5. 并发控制all、race、allSettled怎么选才对5.1 四个 API 的行为对比Promise 的静态方法在不同业务场景里差别非常大。我经常在代码里看到Promise.all被无脑使用但实际上并不是所有并发场景都适合它。先把四个 API 的行为对比清楚API成功条件失败行为典型场景Promise.all所有 Promise 都成功返回成功结果数组只要有一个失败立即失败并丢弃其他结果多个接口都成功才能进入下一步Promise.race第一个 settle 的 Promise 决定结果不管成功还是失败第一个失败就失败第一个成功就成功超时控制、竞速Promise.allSettled所有 Promise 都结束不管成功失败返回状态对象数组永不失败批量上报、日志记录、逐个任务独立处理Promise.any第一个成功的 Promise 决定结果全部失败才失败有一个成功就成功多备用资源想要最快可用的一份5.2 用错的典型Promise.all 会短路Promise.all的短路行为是最容易被忽略的。它只要遇到一个 reject立刻进入 reject 分支并且其他还没完成的 Promise 虽然仍在执行但其结果已经被抛弃。看这个例子const p1 Promise.reject(错误); const p2 fetch(/api/important-data); const result Promise.all([p1, p2]);p2这个请求已经发出去了但因为Promise.all已经失败你永远等不到p2的响应处理逻辑。如果p2是一个有副作用的操作比如创建订单、上传文件你就会面临“请求到底成功了吗”的悬案——前端显示失败后端可能已经写库了。这种问题排起查来非常折磨人。所以在需要“几个任务互相独立谁失败不影响谁”的场景我不会用Promise.all而是用Promise.allSettledconst results await Promise.allSettled([ uploadFile(fileA), uploadFile(fileB), uploadFile(fileC), ]); results.forEach((item, index) { if (item.status rejected) { console.error(第${index 1}个文件上传失败, item.reason); } });这样每个文件的上传结果都能拿到失败的文件单独重传成功的照常处理谁也不会拖累谁。Promise.race还有一个典型的用法是给请求加超时这个我几乎每周都在用function withTimeout(promise, timeout 5000) { const timeoutPromise new Promise((_, reject) { setTimeout(() reject(new Error(请求超时)), timeout); }); return Promise.race([promise, timeoutPromise]); }把真实请求和超时 Promise 放进race谁先到谁说了算。这是最优雅的超时实现不用侵入请求本身也不用自己维护定时器的清理逻辑。有一点要注意真实请求并不会因为race失败而自动取消它仍在后台运行只是结果已经被忽略了。因此如果请求里有取消手段比如 AbortController最好在 catch 里顺手取消。6. async/await语法糖不背锅但要理解它的代价6.1 等价改写async/await是 ES2017 引入的它本身并不是 Promise 的替代品而是 Promise 的语法糖。下面这两段代码行为几乎等价// 写法一纯 Promise function getUser() { return fetchUser().then(res res.json()); } // 写法二async/await async function getUser() { const res await fetchUser(); return res.json(); }async 函数有个必须记住的性质不管函数内部返回什么它都会包成一个 Promise。哪怕函数里只写return 1调用方拿到的也是一个 Promise 对象。这一点很多人栽过跟头尤其是事件监听器场景就像第 4 章那个例子你只需要返回true/false的普通值结果却返回了 Promise。await的作用是“等一个 Promise 落定然后取出成功值”。如果这个 Promise rejectawait就相当于在当前上下文中 throw 一个错误让你可以用try/catch捕获这就是 async/await 让代码更好读的原因——错误处理从链式回调变成了熟悉的同步写法。6.2 循环里用 await 的串行陷阱async/await 写流程很爽但它在并发场景里有个隐蔽的坑在 forEach 里加 await 不会按你想的那样等待。const ids [1, 2, 3]; ids.forEach(async (id) { await fetchDetail(id); // 你以为这里是并行的其实这里的 await 不控制外层 }); console.log(全部完成); // 这一行会先执行因为forEach本身不会等待回调里的 Promise。你传入的async回调确实会执行但forEach不会等待它结束。如果想要串行等待用for...ofconst ids [1, 2, 3]; for (const id of ids) { await fetchDetail(id); // 一个一个来执行完才进入下一次循环 } console.log(全部完成);反过来如果三个请求彼此无依赖只是为了拿结果完全没必要串行用Promise.all并行更快const result await Promise.all(ids.map(id fetchDetail(id)));串行和并行的区别非常本质。接口串行时总耗时是三个请求耗时的累加并行时总耗时约等于最慢的那个请求。多接口无依赖时还写成循环 await就是在白白浪费性能。我自己见过不少这样的代码一改效率立刻上来了。6.3 我的选型建议我把自己的习惯整理成一个简单的判断规则流程是依次执行的并且每一步都依赖上一步的结果用async/await 单个await代码最平铺直叙流程是并发的彼此无依赖用Promise.all或Promise.allSettled流程需要“最快的一个响应”超时、备用资源用Promise.race流程里含事件监听、定时器、公共入口等“出口型函数”一律在内部 try/catch不要指望外部接住。async/await不是银弹它只是让你在写“顺序”时更舒服。一旦你开始写并发回到 Promise API 反而更直接。两者可以随意混用因为 async 函数本来就在操作 Promise完全不需要担心“对面是不是 Promise”这样的兼容问题。最后再分享一个小技巧。调试 Promise 链时可以在链中间加一个专门打日志的.thenfetchUser() .then(res { console.log(step1:, res); return res; }) .then(process) .catch(handleError);它不会改变链的流向因为返回原值透传但能帮你在每一步确认数据形态。这个方法我用了很多年比断点调试效率高多了。Promise 这东西上手容易真正用得稳靠的是对状态机和链路传播的理解。希望这篇文章能帮你在下一次遇到 Promise 报错时心里有点底气而不是只能对着控制台发懵。
返回列表