告别Promise.then地狱:用async/await重写优雅异步代码与避坑手册
说实话做前端这几年我见过的最多的代码问题不是逻辑错误而是“写法太绕”。尤其是异步操作很多同事写 Promise 写得很辛苦then 一层套一层最后自己都看不懂自己在干嘛。最近在给团队做代码评审时又看到一批类似这样的代码一个请求回来后要根据结果再发另一个请求每个请求都接一个 .then处理完数据又返回新的 Promise中间还夹着 .catch看起来好像挺工整但改起来真要命。我直接把其中一大部分改成 async/await 之后代码量缩了三分之一可读性提升了不止一个档次。这篇文章就想聊聊为什么我说有些 Promise.then 其实白写了以及 async/await 这个新语法到底是怎么让异步代码变得更优雅的。顺便把新手必踩的一个报错——“await”运算符只能用于异步方法中——从头到尾讲透。无论你是刚入门 JavaScript 的小白还是写了好几年却一直用 then 链的老开发这篇都值得花十分钟看完。1. 为什么说 then 链没有你想的那么优雅1.1 Promise 的本质把回调从“地狱”变成了“链”先回顾一下历史。早年间 JavaScript 处理异步全靠回调函数代码一旦涉及多个依赖步骤就会出现传说中的“回调地狱”getUser(userId, function (user) { getOrders(user.id, function (orders) { getOrderDetail(orders[0].id, function (detail) { render(detail); }); }); });这种层层嵌套的写法缩进越来越深逻辑越来越难梳理一旦中间某个环节报错错误处理也要跟着嵌套可以说是维护噩梦。Promise 的出现解决了一部分问题。它把异步结果抽象成一个“未来会有的值”然后用 .then 把操作串起来getUser(userId) .then(function (user) { return getOrders(user.id); }) .then(function (orders) { return getOrderDetail(orders[0].id); }) .then(function (detail) { render(detail); }) .catch(function (err) { handleError(err); });比起回调嵌套这段代码确实工整了不少每一步的返回都能传给下一步错误也能统一在 .catch 里处理。但说实话这只是把“横向嵌套”变成了“纵向链式”本质上你还是在一个接一个地拼函数。1.2 then 链最容易被忽视的三个问题我用 then 写了两年多代码后来回头看发现它的问题比表面看上去多得多尤其是下面这三个。首先是变量作用域问题。then 的回调函数是独立的函数作用域你在第一个 then 里得到的 user想在第三个 then 里用就得像上面那样一路 return 传下去或者在外部定义一个临时变量来接。这导致代码里到处都是“中转变量”或者被迫写出多层闭包。我在评审时经常看到这种代码函数顶部先 let 一个 user然后 then 里赋值再在后面的 then 里读取。这种写法虽然能跑但变量被多个回调共享顺序一旦乱了排查起来非常头疼。其次是中间态难调试。then 链中的每一步返回的都是一个新的 Promise断点调试时你很难直观地看到“当前走到了哪一步”只能在每个回调里打日志。而且如果某一层的 return 写漏了整个链条的数据传递就会断掉还不好排查。真实场景里我见过一个线上 bug就是因为某个 then 回调里忘了 return导致下一步拿到的是 undefined前端页面直接白屏。这种问题在代码里不仔细看根本发现不了。最后是分支逻辑不好写。一旦遇到“如果用户是会员就走 A 流程否则走 B 流程”这种条件分支then 链的写法会迅速变得臃肿。你看一下这段代码getUser(userId) .then(function (user) { if (user.vip) { return getVipData(user.id).then(function (vipData) { return { user: user, data: vipData }; }); } return getNormalData(user.id).then(function (normalData) { return { user: user, data: normalData }; }); }) .then(function (result) { render(result.user, result.data); });这才一个条件分支就已经很绕了要是再多几个步骤、几个分支then 链基本就变成了一锅粥。这也是我在代码评审时最常吐槽的点。有同事跟我争辩说 then 链能写得很工整但那是在理想情况下真实业务里接口返回、权限判断、缓存逻辑一掺和马上原形毕露。2. async/await 的底层逻辑它到底“新”在哪2.1 用写同步代码的方式写异步async/await 并不是一个新的异步机制它本质上是 Promise 的语法糖。它没有取代 Promise而是建立在 Promise 之上提供了一种更接近人类思维的表达方式。这点非常重要意味着你之前学的 Promise 知识完全不会浪费只是换了一种更舒服的姿势去使用它。很多人第一次看到 async/await 时有个疑问这不就是同步代码吗对它要的就是这个效果。await 关键字的作用是“暂停”当前函数的执行等待后面的 Promise 状态变为 fulfilled然后把结果取出来赋给左边。如果 Promise 变成了 rejected就直接抛出异常。看一个最简单的例子async function getUserInfo(userId) { const user await getUser(userId); const orders await getOrders(user.id); const detail await getOrderDetail(orders[0].id); return detail; }和前面的 then 链对比一下这段代码的每一步都是从上到下顺序执行的变量 user、orders 都能直接使用不需要额外传递逻辑一目了然。我第一次把这段代码拿给一个刚入行的新人看他直接说“这不就是普通函数吗”在我解释了后面那几行 await 的作用后他才意识到异步居然能写得这么直白。需要注意的一点是await 只能“暂停”它所在的 async 函数而不是整个程序。这是很多初学者会误解的地方。await 不会阻塞主线程在等待期间事件循环依然可以处理其他任务。它只是把当前函数后续的代码包装成了 Promise 的 .then 回调从效果上看像是“串行等待”但并不会卡住页面。所以不用担心写了 await 页面就僵住不动了那是不可能的。2.2 async 到底干了什么很多人只记住了“await 要用在 async 函数里”但没搞清楚为什么。其实 async 关键字就做了一件事让函数返回一个 Promise。你可以把 async 函数理解成一个会自动把返回值用 Promise.resolve 包装起来的函数。async function foo() { return 42; } foo().then(function (value) { console.log(value); // 42 });即使 foo 内部没有使用任何 await它返回的依然是一个 Promise所以在调用方可以用 .then 接收结果也可以继续用 await 接收。这个特性非常重要它意味着 async/await 和 Promise 是天然兼容的你可以在新旧代码之间无缝切换不需要推翻重写。我在实际项目里经常这样用底层比较老的模块返回的是 Promise上层新代码用 await 去接或者反过来某个 async 函数被旧代码调用旧代码用 .then 去拿它的结果。两者完全互通不会出现“数据类型对不上”的问题。2.3 “await”运算符只能用于异步方法中这个报错的来龙去脉标题里提到的那个报错几乎每个学 async/await 的人都遇见过错误 1“await”运算符只能用于异步方法中。请考虑用 “async” 修饰符标记此方法。这个报错翻译自英文的 “The await operator can only be used within an async method”常见于 TypeScript 或某些编译环境。它出现的根本原因就一句话你在一个没有标记 async 的函数里用了 await。看这段代码// 错误示例 function loadData() { const data await fetchData(); // 报错await 只能在 async 函数里用 return data; }报错信息的后半句已经给了修复方案——“请考虑用 async 修饰符标记此方法”也就是说只要把 function 前面加上 async 即可// 正确示例 async function loadData() { const data await fetchData(); return data; }为什么必须加 async因为 await 需要把函数剩余部分的代码“挂起”并交给事件循环函数必须是 async 类型运行时才知道该怎么处理这种挂起。换句话说async 是 await 的入场券没有这个标记编译器无法确认你写这段代码的意图只能直接报错。这里有个很容易踩的坑回调函数里用 await同样需要把回调函数标记为 async。比如数组的 forEach 回调// 错误示例 const ids [1, 2, 3]; ids.forEach(function (id) { const data await fetchData(id); // 报错 }); // 正确示例 ids.forEach(async function (id) { const data await fetchData(id); });箭头函数也是一样的道理() {} 想用 await就得写成 async () {}。这个细节特别容易漏因为报错信息往往指向的不是 async 函数本身而是内部那一行 await初学者经常找半天才发现问题是函数签名上少了 async。我见过不下一百次这种报错每一次解决方案都是一样找到那个函数把 async 加上。3. 同一个需求两种写法从实战看差距3.1 用户信息加载的真实场景光讲理论不够我拿一个非常常见的业务场景来对比。需求是这样的进入页面后根据 URL 上的用户 ID 先加载用户基本信息然后根据用户等级加载对应的权限配置最后把两者合并渲染到页面上。先用 then 链实现function loadPage(userId) { let user null; getUser(userId) .then(function (res) { user res; return getPermissionByLevel(res.level); }) .then(function (permission) { render(user, permission); }) .catch(function (err) { showError(err); }); }这里就因为“后面还要用 user”被迫在外部声明了一个 let user 来中转。这种写法在代码少的时候看着还能忍但一旦函数多了、逻辑复杂了这个中转变量很容易在某个分支里忘记赋值或者被别的逻辑覆盖非常脆弱。再看 async/await 写法async function loadPage(userId) { try { const user await getUser(userId); const permission await getPermissionByLevel(user.level); render(user, permission); } catch (err) { showError(err); } }区别很明显变量不需要中转逻辑顺序完全贴合业务描述先拿用户再拿权限最后渲染。try/catch 包住整段逻辑任何一步出错都能被抓住。最重要的是这段代码的“呼吸感”完全和同步代码一样你不需要在脑子里反复横跳着追踪 then 的返回值。我自己在改完这种代码之后最大的感受是以后维护这个函数哪怕隔三个月再回来看也能在三秒内看懂它在干什么。3.2 错误处理try/catch 完胜 .catch 的地方then 链的错误处理其实也不算差.catch 能统一捕获链上的异常。但它有个特性经常被忽略catch 后面的 then 还能继续执行。如果你不小心把 .catch 写在了中间它后面接的 .then 依然会走这可能导致错误被“吞掉”一半页面出现半个失败状态。我在一个老项目里就遇到过这种问题有一个请求链中间某个接口偶尔会超时代码里在中间位置加了一个 .catch 来记录日志结果这个 catch 后面的 .then 照样执行了页面渲染出了一半有数据、一半是空白的奇怪状态。排查了很久才发现是 catch 位置不当导致的。async/await 配合 try/catch错误处理会更符合直觉async function loadPage(userId) { try { const user await getUser(userId); const permission await getPermissionByLevel(user.level); render(user, permission); } catch (err) { // 不管是 getUser 失败还是 getPermissionByLevel 失败都会走到这里 showError(err); } }如果你只想让某一段出错才处理也可以只包住那一段这比 then 链灵活得多。比如权限接口失败时你想降级用默认权限而不是直接报错那就单独包住那段let permission defaultPermission; try { permission await getPermissionByLevel(user.level); } catch (err) { // 权限获取失败降级使用默认权限不影响主流程 }这种精细化控制在 then 链里写起来要多绕有多绕但用 try/catch 就是几行的事。另外还有个细节try/catch 捕获的是“await 表达式抛出的异常”所以你在 catch 里拿到的 err就是 Promise rejected 时传入的那个值。这一点和 .catch 完全一致不会因为换了写法就丢失错误信息。所以代码评审时我从来不担心同事从 .catch 换成 try/catch 会丢错误反而更放心了。经验之谈处理单个异步操作时我通常直接用 try/catch如果多个操作有各自的错误处理逻辑我宁可多写几个 try/catch也不要写一个大 try 全包住因为后面排查问题时会很难定位到底哪一步出了问题。3.3 并发场景Promise.all 依然是主角有一个常见的误解认为用了 async/await 就该把循环里的异步任务一个个 await。这种“串行”写法在大量并发场景下会拖慢性能。比如同时拉取 5 个商品的详情最直观但不推荐的写法是async function loadProducts(ids) { const products []; for (const id of ids) { // 一个一个等5 次请求是串行的总共耗时是 5 次请求之和 const product await fetchProduct(id); products.push(product); } return products; }正确做法是先把 Promise 发出去再用 Promise.all 统一等待async function loadProducts(ids) { // 先把 5 个请求全部发出并发执行 const promises ids.map((id) fetchProduct(id)); const products await Promise.all(promises); return products; }用 Promise.all 之后5 个请求同时发出总耗时约等于最慢的那一个请求而不是全部请求的耗时之和。这一点在接口耗时明显时差距非常大。我在一个真实项目里优化过一个列表页原本用 for 循环逐个 await 请求详情5 个商品要等差不多 2 秒多改成 map Promise.all 之后直接压到 500 毫秒以内页面的加载体验完全不是一个级别。async/await 并没有取代 Promise.all它只是让等待多个并发结果时的代码更清晰。如果某个请求失败了你不想影响其他结果可以用 Promise.allSettled这个 API 会等所有请求结束然后返回每个请求的成功或失败状态const results await Promise.allSettled(ids.map((id) fetchProduct(id))); const validProducts results .filter((item) item.status fulfilled) .map((item) item.value);这段代码在“部分失败也不能整体崩溃”的场景下特别有用比如批量展示商品时个别商品接口挂了不应该让整个列表空白。我在做数据大屏的时候没少用这个模式几十个数据指标同时拉取谁挂都不影响整体展示这比用一个 Promise.all 一挂全挂要稳健得多。4. 避坑指南与调试实录那些年我踩过的 async/await 的坑4.1 高频报错速查表除了“await 只能用于异步方法中”这个最常见的报错我在实际开发中还会频繁遇到下面这些问题整理成一张表方便你对照排查。错误 / 现象原因解决办法“await”运算符只能用于异步方法中在非 async 函数里使用了 await把所在函数标记为 async或改为 .then 写法await is only valid in async functions英文报错同上常见于 Node.js 环境同上await is a reserved word变量名或函数名使用了 await重命名变量避免用保留关键字async 函数返回 undefined忘记 return await 或 return 写错位置检查函数体是否有 return异步结果需要显式返回数组循环里 await 不生效在 forEach 里直接 await回调同步返回改用 for...of 循环或用 map 配合 Promise.all页面一直转圈不渲染某个 await 一直 pending检查对应的 Promise 是否 resolve是否有死循环或永不回调的网络请求这里面最迷惑的是在 forEach 里用 await。很多新手以为 forEach 里加了 await 就会逐个等待实际上不会。forEach 的回调函数是同步调用的里面的 await 只对回调函数内部有效外部根本等不到。你看起来是把异步任务写在了循环里实际效果是几个任务同时发出去了而且 forEach 本身返回 undefined。正确写法是用 for...ofasync function loadList(ids) { for (const id of ids) { const data await fetchData(id); // 逐个等待 render(data); } }如果既想要并发又想要按顺序处理结果那就用前文说的 map Promise.all。这里我再补充一点并发请求的结果顺序和 map 的原始顺序是一致的Promise.all 返回的数组顺序不会因为某个请求先返回就打乱。所以如果你需要按 ID 顺序展示数据map Promise.all 依然是对的。4.2 一个真实的线上问题await 之后的 undefined有一次我在处理一个老项目的重构时遇到一个特别刁钻的问题接口数据偶尔会是 undefined但这个接口本身没有问题。排查了很久最后发现是同事在 async 函数里写了这样的代码async function getConfig() { if (cache.has(config)) { return cache.get(config); } const res await fetchConfig(); cache.set(config, res); // 忘记 return res }注意看await 拿到 res 之后存进了缓存但函数没有把 res return 出去。由于函数是 async 的没写 return 就会默认返回 undefined。调用方拿到 undefined自然渲染不出东西。这个 bug 的坑在于它不报错、不崩溃数据就是时有时无非常隐蔽。第一次请求时缓存没有走 fetch 逻辑结果没返回第二次请求时缓存有了直接返回缓存值页面反而正常了。这种“时好时坏”的问题最让人抓狂。排查这类问题有个经验当 async 函数的返回值和你预期不一致时第一步就是检查函数里有没有把该 return 的值 return 出去。因为 async 函数会自动包装返回值但不会帮你补 return。这个坑其实不是 async/await 特有的回调写法也会遇到但写回调时大家习惯在最后显式 return反而更少犯这个错写 async 函数时因为看起来像同步代码大家容易放松警惕。4.3 写测试时的注意点async/await 对单元测试也有影响。如果你用的测试框架是 Jest 或者 Vitest测试用例本身必须返回 Promise 或用 async 函数包裹否则测试可能在异步操作完成之前就结束了。我第一次写 async 函数的测试时就吃过这个亏// 错误示例测试函数是同步的断言在异步结果返回前就执行了 it(should load user, () { const result loadUser(1); expect(result.name).toBe(张三); // 此时 result 还是 Promise根本拿不到 name }); // 正确示例用 async 包裹测试函数内部 await it(should load user, async () { const result await loadUser(1); expect(result.name).toBe(张三); });这个小细节排查起来很费时间因为测试结果往往是“期望 undefined 等于张三”这种莫名其妙的失败信息。记住原则凡是涉及异步逻辑的测试测试函数要么返回 Promise要么直接声明为 async。这个原则在写接口测试、组件测试时都适用养成习惯之后能少踩很多坑。4.4 性能不是免费的但也没你想的那么贵有同事问过我async/await 比 Promise.then 慢吗这个问题的答案要分情况说。单纯从微任务的调度开销来看async/await 会比手写 then 链多一层包装但性能差距通常在纳秒级业务开发中完全感知不到。真正的性能差异往往来自写法本身比如前面说过的那种连环 await 导致请求串行这种写法带来的等待时间才是数量级的差距远远大于语法层面的开销。所以我说很多时候 Promise.then 白写了不是因为性能而是因为可读性和维护成本。所以我的建议是不要为了“微优化”去手写 then 链。先追求代码的可读性和可维护性如果真的遇到性能瓶颈再用工具定位大概率问题出在请求次数、数据量或者慢查询上绝对不是 async/await 这几个关键字的问题。我自己遇到过一次页面卡顿一开始也怀疑是 await 太多结果分析下来是后端接口太慢跟前端写法一点关系都没有。5. 如何让团队代码平滑切换到 async/await5.1 从重构一个函数开始如果你所在的项目还堆着大量 then 链不需要“推倒重来”那样风险太大。我建议的做法是从最核心、最常被改动的那条异步链路开始重构。先选一个读起来最费劲的函数把它改写成 async/await让团队里其他人看到对比效果自然会有更多人愿意跟着改。重构时有个小技巧不需要改全部两者可以混用。一个 async 函数里你既可以用 await 接收某个 Promise 的结果也可以对某个返回值 .then 处理完全没问题。async/await 和 Promise 是同一套体系的两种写法兼容性很好。我在重构一个用了五年多的老模块时就是一段一段改的改一段测一段完全不影响线上功能。我还习惯在重构时保留原来的 then 版本做对照跑一遍测试确认行为一致后再删掉。用 git 的 diff 能看到改动前后逻辑完全等价这样更容易说服团队的保守派。毕竟有些人会对“新语法”天然警惕拿出真实对照和测试结果比空口说“这样更好”更有说服力。5.2 代码评审时我会重点看什么在评审别人的异步代码时我通常会从这么几个角度去看。第一函数是否需要标记 async。如果函数内部用到了 await 却没标 async直接指出。这个错误最常见也最好修。我见过一个函数因为在递归调用里用了 await 但外层函数没标 async导致整个递归链路报错改起来倒简单加个 async 就行。第二循环里是不是串行 await 了。判断标准很简单循环里的每个任务之间有没有依赖关系如果没有依赖就应该用 Promise.all 并发执行只写 for 循环 await 会让请求一个一个排队页面加载慢到怀疑人生。我在评审时甚至会直接帮他们改成 map Promise.all并附上一句“耗时从 N 倍变成 1 倍”。第三错误处理是否覆盖了所有步骤。一个大 try/catch 包住所有逻辑虽然能用但遇到精细化错误提示时不够用。比如“用户信息加载失败给你弹一个提示权限加载失败弹另一个提示”这种就得拆开处理。我会建议他们按步骤拆 try/catch或者把不同步骤的 Promise 分别保存再逐段处理。第四有没有不必要的 return await。在某些情况下直接 return promise 和 return await promise 效果类似但前者能少一层包装// 推荐写法直接返回 Promise让调用方决定如何处理 function getUser(userId) { return fetchUser(userId); } // 这样写也不是错但多了不必要的包装 async function getUser(userId) { return await fetchUser(userId); }不过这里有个例外如果你在 try/catch 里 return await那是必要的因为直接 return promise 会导致 catch 捕获不到内部抛出的错误。细节很多建议多写多踩坑慢慢就有感觉了。我见过团队里有人为了风格统一把所有返回 Promise 的函数都写成 async return await结果没 catch 到错误反而引入了 bug所以这个点我会专门提醒。5.3 给新手的几条主线建议如果你刚开始接触 async/await我的建议是把这几件事按顺序做完你就能超过大多数只会写 then 的老同事搞清楚 Promise 的三种状态和 then/catch/finally 的语义这是地基。不要跳过这一步直接学 async/await否则你理解不了 await 背后到底在等什么。学会用 async 关键字标记函数用 await 接收 Promise 结果。先写最简单的“一个 await”用例跑通再往后学。熟练写出“串行等待”和“并行等待”两种模式前者用 for...of await后者用 map Promise.all。这两个模式覆盖了日常开发中绝大多数异步场景。掌握错误处理try/catch 包住关键逻辑记住 catch 拿到的是 Promise 的 rejection 原因。想清楚“哪些错误要吞掉哪些错误要抛出去”这是区分新手和熟练工的分水岭。遇到报错先看函数签名八成问题出在“这个函数是不是 async 的”。把这个条件反射练出来你调试 async/await 相关问题的速度会快很多。最后再分享一个我个人的小习惯在写任何异步函数之前先在脑子里问自己一句——这个函数最终要返回什么如果答案是“要拿到异步结果继续处理”那就不要犹豫直接写 async 函数用 await 一步步拿结果。如果答案是“只是把一个 Promise 传给上层”那就不需要 async直接 return promise 就行。这套思路我用了好几年代码评审的通过率明显变高了团队的异步代码质量也肉眼可见地提升。按这套思路重构之后项目的异步代码从一团乱麻变成了清晰的流水线。我最大的感受是编程语言给你提供新语法不是让你追新而是真的能解决实际问题。Promise.then 没做错什么它只是把异步表达得略笨拙而 async/await 让代码回到了“读起来像讲故事”的样子。我在实际项目里也一直在用这套标准要求自己新代码优先 async/await遇到老代码再顺手慢慢改不激进也不保守。希望这篇能帮你少走我踩过的那些弯路。