
Promise 大概是前端面试里最高频、也最容易被问到语塞的知识点。我这些年面试过不少候选人发现一个规律项目里能熟练用 Promise 的人很多但能把“then 为什么能链式调用”“await 到底切走了什么”“一个 rejected 的 Promise 为什么报 Uncaught (in promise)”讲清楚的人真的不多。这篇文章就把 Promise 从原理到面试题一次性捋顺不管你是准备跳槽的前端开发还是刚学异步编程的新手都可以照着这份材料查漏补缺。先说明一下这篇不是那种“API 抄一遍就完事”的文档。我会从设计思路出发把 Promise 的状态机、链式调用、微任务、async/await、错误处理、并发控制这些核心话题全部串起来最后还会带上我实际开发中踩过的一些坑和排查经验。面试官问什么、怎么答才能加分文中都会提到。1. Promise 到底解决了什么问题从回调地狱到状态机1.1 没有 Promise 的时代异步代码长什么样很多人学 Promise 是直接从语法入手的这会导致一个后果你记住了resolve、reject、then、catch但遇到真实的异步场景还是不知道怎么组织代码。所以我建议先回到源头看看 Promise 出现之前前端是怎么写异步的。早期的 Web 开发异步操作主要是三类网络请求XHR、定时器setTimeout/setInterval、事件监听DOM 事件。这些操作都有一个共同点结果不是函数返回值而是通过回调函数交给后续逻辑。于是代码写起来常常是这种画风getUser(userId, function (user) { getOrders(user.id, function (orders) { getOrderDetail(orders[0].id, function (detail) { getRecommendations(detail.sku, function (recommends) { // 业务逻辑终于轮到了 }); }); }); });这种层层嵌套的结构行业内叫“回调地狱”。它的问题不只是难看更致命的是逻辑分支越多代码的可读性和可维护性越差。你为了处理“某一步失败了怎么办”“某一步超时了怎么办”还得往每一层里塞 error callback最后整个代码像一棵疯狂发芽的圣诞树。而且回调函数之间的流程完全靠约定维护一旦中间某一步忘记传回调整个链条就静默断掉。Promise 出现之前社区也提出过不少方案比如 async.js 的 waterfall、series但那些本质上还是在管理回调函数的执行顺序并没有改变“结果靠回调传递”这个底层机制。真正让异步代码发生质变的是 Promise 引入的状态机模型——它把异步操作的最终结果变成了一个可以被传递、被组合、被统一处理的对象。1.2 Promise 的核心设计一个不可逆的状态机Promise 的设计核心是三个状态和两条不可逆的迁移路径pending进行中fulfilled已成功rejected已失败状态迁移只有两种情况pending - fulfilled或者pending - rejected。一旦状态变成fulfilled或rejected就永久锁死不会再变。这个设计和现实中的快递订单很像。你下单之后订单是“处理中”快递到了变成“已签收”快递丢了变成“已退款”。订单不可能从“已签收”又变回“处理中”也不可能既“已签收”又“已退款”。Promise 的状态也是这样这种“一旦定案永不翻案”的保证让我们在处理异步逻辑时可以放心依赖状态结果。有了状态机之后异步代码的组织方式彻底变了getUser(userId) .then(getOrders) .then((orders) getOrderDetail(orders[0].id)) .then(getRecommendations) .catch(handleError);从“回调嵌套”变成“链式调用”流程是线性的错误处理是统一的。这背后依赖的不是什么魔法而是 Promise 的几个关键设计then返回一个新 Promise、状态不可逆、回调一定在微任务中异步执行。1.3 为什么说 Promise 是“可以编排的异步结果”回调函数时代异步结果是“散落”在回调参数里的Promise 时代异步结果被“封装”成了一个对象。这个对象可以像普通值一样被存储、传递、组合const p1 fetchData(url1); const p2 fetchData(url2); // 两个异步任务可以同时发起不用等前一个完成也正因如此我们才能用Promise.all、Promise.race、Promise.allSettled这些静态方法对多个异步任务做编排。这是回调函数做不到的回调函数里的异步任务一旦发起你就只能被动等待结果而 Promise 对象允许你在结果还没回来的时候就先把它放进组合逻辑里。理解这一点后面学 API 就不会觉得是死记硬背了。每学一个方法先问自己这个方法是用来组合异步任务的还是用来转换异步结果的。2. 核心 API 全梳理别只会用还得知道为什么2.1 then 的链式调用与“值穿透”现象then是 Promise 最核心的方法它干了两件事给当前 Promise 注册回调并返回一个新 Promise。这个“返回新 Promise”的设计是链式调用的基础。每次then回调的返回值会决定下一个then拿到什么返回一个普通值下一个then的onFulfilled会收到这个值返回一个 Promise下一个then会等待这个 Promise 落定然后拿到它的结果什么都不返回返回undefined下一个then会收到undefined。这里有一个容易被忽略的现象叫“值穿透”。看这段代码Promise.resolve(1) .then(2) .then((val) console.log(val)); // 1then(2)传入的不是函数Promise 规范会把它忽略相当于then(null)所以值1原封不动地透传到下一个then。这在面试里经常作为一道“陷阱题”出现。你可以这么理解then期待的是函数不是函数就当没看见当前状态和值直接透传。链式调用的好处是每一步异步操作都可以拆成一个独立的then中间想插入日志、想修改数据、想分支处理都非常灵活。但要注意上面说的“返回新 Promise”有一个隐含前提不要在 then 回调里执行耗时同步任务比如巨量数据的 for 循环。因为 then 回调本身是同步执行的代码它阻塞的是整个 JS 主线程不是“异步”的。2.2 then 的第二个参数为什么常常被忽略then(onFulfilled, onRejected)是支持两个参数的第二个参数是处理失败的。但实际项目中很多人习惯只用.catch()兜底于是产生了一个疑问第二个参数是不是没用当然不是。关键在于then的第二个参数只能捕获“当前 Promise”的 rejection而.catch捕获的是整条链上尚未被处理的 rejection。两者有本质区别。举个例子Promise.resolve() .then(() { throw new Error(第一个 then 抛出错误); }, (err) { console.log(捕获到了吗, err); }) .catch((err) { console.log(catch 捕获到了, err.message); });这段代码里第一个then的 onFulfilled 抛出了错误第二个参数onRejected并不会捕获这个错误因为 onRejected 是针对上一个 promise 状态的而 onFulfilled 抛出的错误由返回的新 Promise 承接最后由下一个catch捕获。如果想让第一个 then 自己处理自己回调里抛出的错误就得在 onFulfilled 内部 try/catch或者再链一个 catch。所以第二个参数并非无效它适合用在“只关心当前这一步失败不想影响整条链”的场景。但绝大多数情况下推荐用统一的.catch收口代码更清晰。面试时如果问到这个点你可以把上面这个区别讲出来已经能超过一半候选人。2.3 静态方法all、race、allSettled、any 到底怎么选四个静态方法是面试高频考点必须先明确它们的语义差异。我把它们放在一起对比方法成功条件失败条件典型场景Promise.all全部 fulfilled任意一个 rejected立刻失败多个接口必须全部成功才能继续Promise.race任意一个落定成功或失败任意一个 rejected超时控制、竞态判断Promise.allSettled所有任务都落定不管成败永不 rejected批量请求需要各自结果Promise.any任意一个 fulfilled全部 rejected快速获取第一个可用的数据这里最容易混淆的是race和any。race是“谁先落定听谁的”成功失败都算any是“谁先成功听谁的”忽略失败除非全部失败。另外要注意Promise.all的一个经典坑如果传给 all 的数组里有一个 Promise 立刻 reject其他 Promise 并不会被取消它们该发的请求还是会发只是结果被忽略了。这就是“快路径失败慢路径仍执行”的现象。面试时有人会误以为Promise.all会中断其他任务这是一个很常见的错误认知。顺带一提Promise.all并不保证“并发”一定比逐个 await 快。如果多个任务互相依赖只能串行时用 all 反而会浪费。它是“同时发起、一起等待”的语义不是“自动并发优化”。3. 从使用到底层手写一个 Promise 的完整思路3.1 为什么要手写 Promise不少候选人觉得手写 Promise 是“面试八股”实际工作中“又不用写”。这话对一半。手写 Promise 本质上是在考你对状态管理、异步调度、链式调用三个核心概念的理解这三件事在实际开发中天天用。而且手写过程中会遇到很多边界问题比如 then 回调抛异常怎么处理、回调里返回同一个 Promise 怎么办、状态已经落定之后再注册 then 是否还能生效——这些问题的答案直接对应你使用 Promise 时的正确姿势。手写 Promise 不需要和规范一模一样但核心逻辑必须对。下面我带大家一步步写一个可用的版本重点在思路不在抠标准。3.2 第一版状态机 then 异步调度先定义一个极简的MyPromise包含状态、值、回调队列和 resolve/rejectclass 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) { return new MyPromise((resolve, reject) { // 这里先简化处理完整版见下一小节 }); } }这里有几个细节要重点说明resolve和reject里都要判断state ! pending因为状态一旦改变就不能再变。如果 executor 里既调 resolve 又调 reject只有第一个生效。executor 执行时机是构造函数内部同步执行的所以如果 executor 里抛异常要能被捕获并 reject。回调数组的设计是为了处理“异步落定”的情况。比如 executor 里调setTimeout(() resolve(1), 1000)then 注册的回调要先存起来等 resolve 时再逐个执行。第一版里最偷懒的部分是then。它需要支持两种场景当前 Promise 已经落定则立即调度回调还没落定则把回调存起来。真正的完整逻辑要复杂得多往下看。3.3 第二版链式调用与 resolvePromise核心难点在then的返回值处理和 Promise 解析过程。按照规范then返回的新 Promise 的落定结果取决于onFulfilled或onRejected的返回值 x如果 x 是普通值直接 resolve如果 x 是 Promise则等待 x 落定如果 x 是自身即返回了 then 返回的 promise要抛 TypeError防止自等待死循环。实现如下class MyPromise { // ...上面构造函数部分省略 then(onFulfilled, onRejected) { onFulfilled typeof onFulfilled function ? onFulfilled : (value) value; onRejected typeof onRejected function ? onRejected : (reason) { throw reason; }; const newPromise new MyPromise((resolve, reject) { const handle (callback, arg) { setTimeout(() { try { const x callback(arg); this.resolvePromise(newPromise, x, resolve, reject); } catch (err) { reject(err); } }, 0); }; if (this.state fulfilled) { handle(onFulfilled, this.value); } else if (this.state rejected) { handle(onRejected, this.reason); } else { this.onFulfilledCallbacks.push(() handle(onFulfilled, this.value)); this.onRejectedCallbacks.push(() handle(onRejected, this.reason)); } }); return newPromise; } resolvePromise(newPromise, x, resolve, reject) { if (newPromise x) { reject(new TypeError(Chaining cycle detected)); return; } if (x instanceof MyPromise) { x.then( (value) this.resolvePromise(newPromise, value, resolve, reject), reject ); } else { resolve(x); } } }我这里用setTimeout模拟异步调度真实 Promise 用的是微任务队列。手写时用宏任务模拟虽然语义不精确但用来理解链式调用的控制流足够了。这段代码里最关键的是resolvePromise方法。它递归处理“x 也是 Promise”的情况保证一旦回调返回 Promise新 Promise 一定会等它落定。很多手写 Promise 的版本会漏掉递归导致.then(() Promise.resolve(2))拿到的不是 2 而是一个 Promise 对象这就是对“值的解析”理解不到位。3.4 手写过程中最容易翻车的三个点第一个点是then回调执行顺序。真实规范里即便 Promise 已经 fulfilledthen里注册的回调也不能同步执行必须进入微任务队列。手写时很多人直接调用handle导致回调同步执行这样写出的逻辑完全不是异步的。第二个点是resolve一个 Promise 时的情况。resolve(value)如果 value 是 Promise规范的 Promise Resolution Procedure 会要求递归解析如果 value 和当前 promise 是同一个对象要抛错。手写时忽略这一点就会出现Unhandled promise rejection或代码卡住的情况。第三个点是异常捕获的边界。executor 要 try/catchthen 回调也要 try/catchPromise.resolve 静态方法也要处理“传入的是 Promise 就原样返回”。漏一个遇到异常时行为就不同了。手写一遍 Promise 之后再看那些“Promise 为什么能链式调用”“then 回调里返回 Promise 会怎样”的问题会清晰很多。4. async/await 是语法糖但别只会泡糖水4.1 async 函数返回什么、await 到底 await 了什么async/await是 ES2017 引入的语法底层依然是 Promise。一句话总结async 函数必定返回一个 Promiseawait 表达式会暂停当前 async 函数的执行等到右侧的 Promise 落定后恢复并把结果作为整个 await 表达式的值。看几个容易错的点async function foo() { return 42; } foo(); // Promise {fulfilled: 42}返回值 42 会被自动包成 Promise。这意味着在 async 函数里return一个 rejected Promise也会成为这个 async 函数返回的 Promise 的拒绝原因。async function bar() { throw new Error(oops); } bar(); // Promise {rejected: Error: oops}同步抛出的异常也会被捕获并变成一个 rejected Promise。所以你在调用 async 函数时要么 await 它要么给它加 catch否则一样会触发 Uncaught (in promise)。await右侧不一定要是 Promise普通值时直接返回这个值但会额外产生一次微任务调度。这也是为什么有些性能敏感代码里await一个常量会被认为“多余”——它确实会改变执行时序。4.2 微任务执行顺序面试必考的“打印顺序”面试中经常出现“以下代码打印顺序是什么”的题目它考察的是对事件循环、宏任务、微任务的理解。Promise 的 then 回调、async/await 的恢复都属于微任务会在当前宏任务结束前执行setTimeout 属于宏任务要等下一轮事件循环。看一道经典题目console.log(1); setTimeout(() { console.log(2); }, 0); Promise.resolve().then(() { console.log(3); Promise.resolve().then(() console.log(4)); }); console.log(5); // 输出1 5 3 4 2执行顺序拆解同步代码先执行打印 1注册 setTimeout 宏任务注册 then 微任务打印 5。当前宏任务结束清空微任务队列打印 3注册新的微任务打印 4再执行打印 4。微任务队列清空事件循环进入下一轮宏任务打印 2。再给一个 async/await 版本async function async1() { console.log(async1 start); await async2(); console.log(async1 end); } async function async2() { console.log(async2); } console.log(script start); async1(); new Promise((resolve) { console.log(promise); resolve(); }).then(() console.log(then)); console.log(script end); // script start // async1 start // async2 // promise // script end // async1 end // then这里最容易被绕晕的是await async2()的执行时机。async2()是同步调用的所以会先打印async2然后await让出控制权把后面的代码作为一个微任务排队。等当前宏任务里所有同步代码执行完先执行这个await之后的微任务打印 async1 end再执行new Promise注册的 then 微任务打印 then。记住一个结论await 右侧会先同步执行await 之后的代码是异步执行。这个理解能解决大部分打印顺序题。4.3 并发循环、错误捕获与 try/catch 的边界用 async/await 写循环时最容易犯的错误是把并行的请求写成串行。看这段代码const ids [1, 2, 3]; for (const id of ids) { await fetch(/api/${id}); }这是串行请求三个请求一个接一个发出总耗时是三者之和。如果想并行应该用Promise.allconst promises ids.map((id) fetch(/api/${id})); const results await Promise.all(promises);一个更隐蔽的问题map里直接写 async 函数返回的是 Promise 数组如果忘记用Promise.all接收这些 Promise 内部的错误就无人处理直接变成 unhandledrejection。错误处理方面try/catch只能捕获 await 之后的 rejection。如果 async 函数内部在第一个 await 之前就抛错情况是这样的async function risky() { throw new Error(sync error); } try { await risky(); } catch (e) { console.log(捕获到了, e.message); }这个能捕获到因为 async 函数会把同步错误包装成 rejected Promise。但如果是普通 Promise 链中某个 then 回调抛错没有被 catch错误会沿着链一路往下直到链条末端最终触发全局 unhandledrejection。这就是为什么每条 Promise 链都建议有一个 catch 收口。5. 面试高频场景题与实战坑位5.1 “第二层 then 的第二个参数是不是无效”细说 onRejected 的层层传递这是一个被问到很多次的细节题。先看代码Promise.resolve(1) .then( (val) val 1, (err) { console.log(第一个失败, err); } ) .then( (val) val * 2, (err) { console.log(第二个失败, err); } );这里第二个 then 的 onRejected 并不是无效的只是在前面的 Promise 成功、且第一个 then 的 onFulfilled 没有抛错时永远不会执行。它的职责是处理第一个 then 返回的新 Promise 的 rejection——也就是第一个 then 的 onFulfilled 内部抛出的错误。一句话总结三层关系如果第一个 then 不传 onRejected原始 Promise 的 rejection 会继续往下传直到遇到第一个 onRejected 或 catch。如果第一个 then 传了 onRejected且 onRejected 正常执行没有抛错那么后续 then 的 onFulfilled 会执行如果 onRejected 抛错了后续 then 的 onRejected 才会触发。第二个 then 的 onRejected 只对“它前面的 promise”负责不受原始 Promise 状态直接影响。这个点很多人答不对是因为心里默认“Promise 的错误会冒泡到最外层”。实际上错误传递是逐层找最近的 onRejected找到了并且处理成功了链就恢复为 fulfilled。5.2 让人崩溃的 Uncaught (in promise) 到底怎么排查浏览器控制台最常见的报错之一就是Uncaught (in promise) ...它出现的唯一原因是一个 Promise 被 rejected 了但没有任何代码处理这个 rejection。报错信息来自浏览器或 Node 的全局 unhandledrejection 机制只是提示你“有个错误没人接”。常见场景有三种第一种忘记 return。比如在 async 函数里调用一个返回 Promise 的函数但没加 awaitasync function run() { fetchData().then(handleData); // 这里如果 fetchData 内部 reject这里没有 catch }第二种链式调用末尾漏了 catch。这是最常见的自查方法是每个以new Promise开头、或者以fetch、axios等返回 Promise 的操作结尾的代码都问一句“这条链最末端有没有 catch”。第三种某些浏览器 API 返回 Promise但开发者不知道它会 reject。最典型的是video.play(); // play() 返回 Promise可能因为自动播放策略被拒绝这里报错通常是NotAllowedError: play() failed because the user didnt interact with the document first。这不是 Promise 本身的 bug而是浏览器自动播放策略。解决方式是捕获这个 Promiseconst playPromise video.play(); if (playPromise ! undefined) { playPromise.catch((err) { console.warn(Play failed:, err); // 显示自定义播放按钮引导用户手动触发 }); }还有一个容易踩的坑fetch的 response 报Uncaught (in promise) SyntaxError通常是response.json()调用时机或数据格式问题。比如响应体不是合法 JSON或者同一个 response 被.json()消费了两次Response body 只能读一次。排查时先看网络面板里响应内容再确认是否重复消费。想全局兜底的话浏览器端可以监听window.addEventListener(unhandledrejection, (event) { console.error(捕获到一个未处理的 Promise 错误, event.reason); });Node 端是process.on(unhandledRejection, handler)。但这只是最后防线绝不能替代每个 Promise 链的 catch。5.3 Promise 会引发内存泄漏吗排查思路严格来说Promise 本身不会制造内存泄漏但它很容易“放大”内存泄漏。常见的泄漏模式和排查思路我梳理一下。模式一setInterval或事件监听器里创建 Promise但没清理。比如setInterval(() { computeAsyncData().then((data) { // 这里引用了 DOM 节点或大对象而 setInterval 没被 clear }); }, 1000);模式二一个 Promise 永远不落定导致它的回调链无法释放。像是用Promise包了一个没有回调、也没有取消机制的第三方事件function neverSettle() { return new Promise(() { socket.on(message, (msg) { // 这个 socket 如果一直不关Promise 永远 pending }); }); }排查内存泄漏Chrome DevTools 的 Memory 面板是核心工具。先录制一个 Heap Snapshot做几次操作等一会儿让垃圾回收跑完再录制第二个快照对比两次快照里新增的 Promise、闭包和 detached DOM 节点。如果是 Node 服务可以用--inspect启动后连接到 Chrome 的 devtools思路一样。我自己的习惯是在处理 Promise 回调时尽量少引用大型对象用完及时置空长生命周期的定时器、监听器里创建的 Promise务必在组件卸载或页面销毁时主动清理。前端开发里 90% 的“内存泄漏”其实是“该清理的引用没清理”Promise 只是被这个错误连带拖下水。6. 实战演进从超时重试到并发控制6.1 给 Promise 加超时与重试实际工作中接口超时是家常便饭。用Promise.race做一个超时包装很方便function withTimeout(promise, ms, message 请求超时) { let timer; const timeoutPromise new Promise((_, reject) { timer setTimeout(() reject(new Error(message)), ms); }); return Promise.race([promise, timeoutPromise]).finally(() { clearTimeout(timer); }); }注意finally的用法清除定时器避免响应回来之后定时器还在等待。调用方式const data await withTimeout(fetch(/api/list), 5000);重试逻辑也可以封装。核心思想是如果失败且未超过最大重试次数就递归调用自身否则抛出最后一个错误async function retry(fn, times 3, delay 1000) { try { return await fn(); } catch (err) { if (times 1) { throw err; } await new Promise((resolve) setTimeout(resolve, delay)); return retry(fn, times - 1, delay); } }这里要注意重试的边界条件times表示剩余可重试次数times 1时说明已经尝试过最后一轮直接抛错。实际项目里重试只适合幂等请求比如查询类、上传分片类如果是支付、下单这类非幂等操作乱加重试会造成重复数据风险很大。6.2 手写一个简单的并发池并发控制是 Promise 综合应用里的经典题目。比如有 100 个文件要上传你不能一次性发 100 个请求得限制同时最多 5 个。一个常见的实现思路是维护一个执行队列每完成一个任务就从队列里取出下一个。async function runWithConcurrency(tasks, limit) { const results new Array(tasks.length); let index 0; async function worker() { while (index tasks.length) { const current index; try { results[current] await tasks[current](); } catch (err) { results[current] err; } } } const workers Array.from({ length: Math.min(limit, tasks.length) }, () worker()); await Promise.all(workers); return results; }这个实现有几个细节值得琢磨index是共享的多个 worker 并发执行时通过index抢占下一个任务天然保证了任务不会重复执行。results预分配了数组任务完成后按原顺序放入结果不会因为并发执行而乱序。worker 内部对单个任务做了 try/catch这样某个任务失败不会拖垮整个 worker 循环最终统一在results里体现。面试时如果再被问到“怎么控制并发”建议先说出这个基础版然后可以补充还可以用p-limit这类库的思路用信号量模型实现如果要支持失败重试、超时、取消那就在任务外包一层 Promise把控制权收回来。这样至少展示了两个层次的掌握程度。6.3 Promise 与 Worker 组合处理大文件上传前端上传大文件时一个常见做法是把文件切片交给 Web Worker 做哈希计算再把每个分片通过 Promise 并发上传。这个场景里Promise 承担的是“异步任务编排”Worker 承担的是“不让耗时计算卡住主线程”。简单示意一下流程const file input.files[0]; const chunks createChunks(file); const hash await new Promise((resolve, reject) { const worker new Worker(hash-worker.js); worker.postMessage({ chunks }); worker.onmessage (e) resolve(e.data); worker.onerror (e) reject(e.error); });这段代码把“等待 Worker 计算结果”这个异步过程包成了一个 Promise。注意 worker 用完后要terminate()否则会一直挂在内存里。当 Promise 组织多个分片上传时const uploadTasks chunks.map((chunk, index) { return uploadChunk(chunk, index).then(() { // 更新进度可以在这里做节流 }); }); await runWithConcurrency(uploadTasks, 3);如果说 Promise 是异步流程的“骨架”那么 Worker、定时器、事件监听就是“血肉”。只要行动线是异步的就一定有地方会被 Promise 化。这也是为什么面试官喜欢从 Promise 切入、一路问到事件循环、内存、性能、工程化——它是整个前端异步体系的入口。我个人在带新人时有一个习惯不让他们背题而是拿真实项目里的异步逻辑重构几遍。把回调改成 Promise把 Promise 改成 async/await把串行改成并发控制再亲手封装一个带超时和重试的工具函数。做完这三件事面试里关于 Promise 的大部分问题都是送分题。最后再分享一个实用的小技巧团队协作时可以在代码仓库里约定一个utils/async.js把超时、重试、并发池这些通用逻辑集中管理不写在业务代码里。这样既能统一错误处理风格也方便测试维护。Promise 这个知识点说到底不是为了面试而学是为了让异步代码真正变得可控、可维护这件事值得每个前端开发认真花时间。