ARTICLE DETAIL

资讯详情

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

Promise与async/await深度解析:从回调地狱到异步编程最佳实践

Promise与async/await深度解析:从回调地狱到异步编程最佳实践 JavaScript 开发做了十来年Promise 和 async/await 已经不是“新知识”而是每天都要打交道的必修课。这一篇把它彻底说透从 ES6 的 Promise 基础原理讲起一路聊到 async/await 的底层机制、常见报错排查、性能优化思路把你在项目里踩过的坑一次性扫干净。无论你是刚接触 ES6 语法的新手还是用了很久 Promise 但总感觉没吃透的进阶选手这篇文章都值得完整读一遍。1. 从回调地狱到 async/awaitES6 异步方案的演进逻辑1.1 没有 Promise 之前的异步代码长什么样我最早写 JavaScript 的时候异步全靠回调函数。比如一个典型的场景用户登录成功后要先拿用户信息拿到用户信息再拉订单列表代码大概长这样login(function (user) { getUserInfo(user.id, function (info) { getOrders(info.id, function (orders) { // 终于拿到订单了 console.log(orders); }, function (err) { console.error(获取订单失败, err); }); }, function (err) { console.error(获取用户信息失败, err); }); }, function (err) { console.error(登录失败, err); });这种嵌套结构就是经典的“回调地狱”。每嵌套一层代码的可读性和可维护性就肉眼可见地下降一层。当嵌套到三四层的时候你想修改中间某一步的逻辑得小心翼翼地对齐括号稍不留神就改错作用域。而且错误处理非常割裂每个回调都要单独传一个 error 参数很容易漏掉。当时我们缓解这个问题的方式也很原始把回调函数拆成具名函数、用 async 流程控制库比如 async.js 的 series、waterfall或者干脆把嵌套层数控制在一两层以内。但这些方案本质上都只是在“绕开”问题并没有真正解决“异步流程被回调割裂”这个核心痛点。1.2 Promise 解决了什么又留下了什么ES6 正式把 Promise 纳入标准以后回调地狱终于有了体面的解药。同样是上面的场景Promise 写法是这样的login() .then(user getUserInfo(user.id)) .then(info getOrders(info.id)) .then(orders { console.log(orders); }) .catch(err { console.error(任意一步出错都会走到这里, err); });代码被拍平了错误处理也从“每个回调各自处理”变成了“链尾统一 catch”。这种结构让代码的意图清晰了很多也减少了漏处理错误的情况。但使用一段时间后你会发现Promise 也不是万能解药。当业务链路长、分支多、循环多时then 链依然会变得绕。比如你需要并发请求两个接口等两者都返回后再做后续处理又要在一个 Promise 链里塞 Promise.all如果需要根据某一步的结果决定走哪个分支then 链会越拉越长逻辑的可读性反而下降了。还有一个很现实的问题Promise 的链式调用限制了“变量在多个步骤之间共享”的写法不得不用闭包或外层变量来兜数据写久了总觉得别扭。1.3 async/await 带来的核心变化async/await 是 ES2017也就是常说的 ES7引入的但它和 ES6 的 Promise 属于同一套异步生态体系所以现在讨论 ES6 异步必须把它一起聊明白。它的出现并不是要替代 Promise而是把“异步代码像同步代码一样写”这件事变成了现实。同样的场景用 async/await 是这样的async function loadUserOrders() { try { const user await login(); const info await getUserInfo(user.id); const orders await getOrders(info.id); console.log(orders); } catch (err) { console.error(任意一步出错都会走到这里, err); } }现在你发现了吗它的逻辑结构完全就是同步代码的线性结构一行接一行执行变量可以直接在作用域里共享不需要闭包传递。错误处理也变了直接一个 try/catch 包住所有步骤和写同步代码的异常处理方式一模一样。这种从“链式思维”到“顺序思维”的转变才是 async/await 最核心的价值。2. async 与 await 的核心原理和必须知道的细节2.1 async 关键字给函数套了一层 Promise 外壳很多人以为 async 关键字本身是“让函数变成异步”这个理解其实不准确。async 更准确的作用是让一个函数“总是返回 Promise”。举个例子async function fn1() { return 你好; } async function fn2() { throw new Error(出错了); } console.log(fn1()); // Promise {fulfilled: 你好} console.log(fn2()); // Promise {rejected: Error(出错了)}即使你写的 async 函数里没有任何异步操作哪怕只写了一个 return它返回的也不是普通的字符串而是一个被 Promise.resolve 包裹过后的 Promise 对象。这一点非常重要意味着你可以在任何 async 函数外部继续使用 .then/.catch也可以直接在一个 async 函数内部 await 另一个 async 函数的调用。另一个常见误区是async 函数里面的代码是不是会自动变成异步执行其实不然。async 函数的执行是同步开始、直至遇到第一个 await。函数体前半段没有 await 的代码仍然是在调用该函数的那一刻同步执行的。比如async function test() { console.log(1); await Promise.resolve(); console.log(2); } test(); console.log(3); // 输出顺序1 3 2console.log(1) 是同步输出的console.log(3) 在微任务队列执行之前输出console.log(2) 因为 await 被放到了微任务阶段。这个执行顺序很多人面试栽过跟头后续会在事件循环和排查章节详细展开。2.2 await 关键字暂停、等待、恢复await 是 async/await 组合的核心。它的语义是等待一个 Promise 落定settled然后返回其结果。如果等待的 Promise 成功await 表达式的值就是 resolve 出来的值如果等待的 Promise 失败await 会直接抛出异常。这里面的执行细节值得仔细想一遍求值右侧表达式。如果右侧不是 Promise而是普通值await 会把该值通过 Promise.resolve 包装成已解决的 Promise。让出执行权。当前 async 函数执行被挂起线程回到事件循环中继续执行同步代码。等 Promise 落定后把结果通过微任务队列“送回来”async 函数恢复到挂起点继续往下执行。有一个关键点经常被忽略await 一个普通值和 await 一个 Promise虽然最终结果一样但从微任务调度的角度前者同样会产生一次异步边界。也就是说即使右侧是普通值await 5也会让后续代码变成异步执行不会再同步跑完。这个特性在做测试断言、写框架代码时可能会影响到执行顺序需要心里有数。还要注意await 只能出现在 async 函数内部。普通函数里写 await引擎直接报 SyntaxError。除了顶层 awaitES2022 支持的模块顶层写法大多数业务代码里你都得先保证外层是一个 async 函数。2.3 async/await 不会让 Promise 消失这是我最想强调的一点。async/await 不是 Promise 的替代品它本质上是 Promise 的语法糖。你在 async 函数里 await 一个 Promise底层还是离不开 then 回调那一整套机制你写的 async 函数也能被 .then 正常调用。什么时候必须回头用 Promise当你需要在非 async 函数里处理异步结果时还是得 .then/.catch。当你需要一次性等待多个 Promise 并发完成时优先考虑 Promise.all、Promise.allSettled。当你想对异步操作做超时控制、竞态处理时全得靠 Promise.race。当你在 async 函数内部需要“只处理和消化某一个错误的场景而不是整个流程中断”时也许更合适对具体某个 Promise 单独 .catch。我自己经常会写这样的代码async function loadData() { const [user, config] await Promise.all([ fetchUser(), fetchConfig() ]); // 继续处理 }await 后面接的是 Promise.all 的返回值而这个返回值本身就是一个 Promise。所以你看async/await 和 Promise 是水乳交融的关系谁也不能替代谁。2.4 错误传播与 try/catch 的边界async/await 的错误处理比 then/catch 更符合直觉但也有容易忽略的边界。当 async 函数内部有 await 某个 Promise 失败时代码会在这里抛出异常。如果没有被 try/catch 捕获这个 async 函数返回的 Promise 会变成 rejected 状态。如果你在调用这个函数时没有 .catch也没有 await 加 try/catch就会触发“unhandled promise rejection”这一类问题。看这段代码async function getUser() { const res await fetch(/user); const data await res.json(); return data; } // 错误用法虽然 async 函数内部有 await但调用方没有兜底 getUser(); // 如果 fetch 失败这里就是一个 unhandled promise rejection正确的做法async function fetchUserData() { try { const user await getUser(); return user; } catch (err) { console.error(获取用户失败, err); // 可以降级返回空对象或抛出给上层 return null; } }除了 try/catchasync/await 还有一个独特的“错误消化”技巧。如果你只想处理某个 Promise 的成功结果、失败时给一个默认值可以这样async function loadUserName() { const result await fetchName().catch(() 匿名用户); return result; }把 .catch 直接挂在被 await 的 Promise 上错误就不会向外传播。这个写法在可选数据加载场景下特别好用。另外值得注意的一个细节try/catch 捕获的是同步代码和 await 表达式抛出的异常对于 await 之后产生的“异步异常”性质的事件比如 setTimeout 回调里抛错try/catch 就无能为力了必要时可以结合全局错误监听处理。2.5 async/await 与事件循环执行时机原理分析理解 async/await 的时机问题关键还是要理解事件循环里的任务队列宏任务和微任务队列微任务。JS 引擎每次执行完一个宏任务后会把微任务队列清空然后再取下一个宏任务。Promise 的 then 回调、async/await 的恢复逻辑都属于微任务。举个经典例子console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); Promise.resolve() .then(() { console.log(promise then); }); (async () { console.log(async start); await Promise.resolve(); console.log(async end); })(); console.log(script end);输出顺序script start async start script end promise then async end setTimeout这里有几个重点async 函数体内的前半段没有 await 的部分是同步执行的所以 async start 会立即输出。await 会让出执行后续代码进入微任务队列。先注册的 promise then 回调会排在 await 恢复之前因为微任务先进先出。setTimeout 属于宏任务永远在微任务清空之后才执行。这个执行顺序在实际项目中会造成一些很难排查的 bug。比如你在 async 函数里 await 一个 Promise然后依赖某个全局状态已被更新但那个更新动作恰好被放到了微任务之后就会拿不到预期值。所以我建议凡是涉及执行顺序、事件循环的代码先写清楚预期输出再动手或者直接做一次 console.log 验证。3. 实操把常见业务场景用 async/await 重写一遍3.1 串行请求像写同步代码一样处理有依赖关系的异步任务实际项目里接口之间存在依赖关系非常常见。比如先获取当前登录用户再用用户 ID 获取用户的收货地址列表。Promise 链写法function loadAddressesByUser() { let user null; return fetchUser() .then(data { user data; return fetchAddresses(user.id); }) .then(addresses { console.log(addresses, user); return addresses; }); }注意这里为了在最终结果里同时拿到 user 和 addresses不得不把 user 放到外层变量里然后通过闭包访问。这就是 Promise 链在共享状态时的笨拙之处。用 async/await 写同样的逻辑async function loadAddressesByUser() { const user await fetchUser(); const addresses await fetchAddresses(user.id); console.log(addresses, user); return { user, addresses }; }代码就读起来舒服多了。user 是函数内的普通变量后续步骤可以随意使用没有闭包的负担。串行执行天然是“慢”的因为每个请求都要等前一个完成。所以在设计业务时要分清哪些必须有依赖关系、哪些其实是独立的。很多性能问题就是从“把并行请求写成串行”开始的。3.2 并行请求并发请求的正确打开方式如果两个请求之间没有依赖关系就应该并发执行而不是逐个 await。很多刚上手 async/await 的同学会写出这样的代码// 低效写法user 和 config 没有依赖却串行等待 async function loadDashboardSlow() { const user await fetchUser(); const config await fetchConfig(); return { user, config }; }两个接口的总耗时会等于两个请求耗时的累加。正确姿势是先发起 Promise再统一 await// 高效写法两个请求并发发起 async function loadDashboardFast() { const userPromise fetchUser(); const configPromise fetchConfig(); const user await userPromise; const config await configPromise; return { user, config }; }更简洁的写法自然是 Promise.allasync function loadDashboard() { const [user, config] await Promise.all([fetchUser(), fetchConfig()]); return { user, config }; }除了 Promise.all还有一个适合容错场景的 Promise.allSettled。如果你的业务是“多个请求尽量都给我返回就算某个失败了其他数据也能正常展示”那就用 allSettledasync function loadMultiSourceData() { const results await Promise.allSettled([ fetchNews(), fetchWeather(), fetchStock() ]); const data results.map(res (res.status fulfilled ? res.value : null)); return data; }Promise.all 是“只要一个失败整个 Promise 就 rejected”适合整体性事务场景Promise.allSettled 是“各自落地互不拖累”适合容错性聚合场景。选哪个取决于业务意图不是简单替换。在实际优化中我还会结合一种更可控的并发策略分批并发。比如有 20 个请求不想一次性并发 20 个以免打满连接数也不想一个一个串行等太久。可以用一个小工具函数来做限量并发。async function runWithLimit(tasks, limit) { const results []; let index 0; async function worker() { while (index tasks.length) { const current index; results[current] await tasks[current](); } } const workers Array.from({ length: limit }, worker); await Promise.all(workers); return results; }调用时这样用const urls [/api/a, /api/b, /api/c]; const requests urls.map(url () fetch(url)); const results await runWithLimit(requests, 3);这个工具函数在真实项目中能解决不少性能与稳定性兼顾的问题建议收藏。3.3 循环里的 awaitfor、forEach、map 到底改选谁循环和 async/await 的组合是很多前端的重灾区。最常见的错误是在 forEach 里使用 await// 反模式forEach 不会等待异步回调完成 async function loadAll() { [1, 2, 3].forEach(async id { const data await fetchById(id); console.log(data); }); console.log(全部加载完成); // 这一行会先执行 }为什么因为 forEach 只是“遍历数组并同步调用回调函数”它不会等待回调函数返回的 Promise。回调函数虽然带 async但 forEach 本身不感知 Promise也不会阻塞遍历。所以写完这段代码后你看到的打印顺序往往是“全部加载完成”先于所有 data 出现。如果需要“逐个串行执行”用 for...ofasync function loadSequential(ids) { for (const id of ids) { const data await fetchById(id); console.log(data); } console.log(全部加载完成); }for...of 会真正等待每次 await 结束再进入下一轮循环所以循环体是串行执行的。如果需要“并发执行”用 map Promise.allasync function loadConcurrent(ids) { const results await Promise.all(ids.map(id fetchById(id))); console.log(results); }这里 map 返回的是 Promise 数组Promise.all 等待所有 Promise 完成。还有一个更细腻的场景并发请求结果需要按原顺序输出。用 Promise.all 天然保证结果顺序和传入顺序一致所以 map Promise.all 是对的解法。如果换成“保证执行顺序但限制并发数量”前面写的 runWithLimit 就是标准答案。这里再补充一个细节在 for...of 中 await 时如果其中某一项抛出错误整个循环会中断后续项不会继续执行。如果业务要求“某项失败不影响其他项”需要根据业务在循环内做单独 catchasync function loadResilient(ids) { for (const id of ids) { try { const data await fetchById(id); console.log(data); } catch (err) { console.warn(id ${id} 加载失败, err); } } }这种“部分失败不影响整体”的容错设计在批量导入、批量刷新等场景下非常重要。3.4 错误处理的最佳实践分段捕获还是整体兜底async/await 的错误处理有一个很现实的问题是搞一个大 try/catch 包住整个函数还是分段 try/catch我的建议是结合业务语义来看如果函数的流程是“任何一步失败整体都算失败不需要局部恢复”就用一个大的 try/catch 包住整体代码简洁清晰。如果函数内某一步失败后仍需要执行后续步骤或者要给用户降级数据那么这一步就应该单独 try/catch。如果错误需要带上不同的上下文信息是哪一步出的错建议直接抛出自定义错误对象或者在 catch 中追加上下文。看一个实际例子async function loadUserProfile() { try { const user await fetchUser(); try { const avatar await fetchAvatar(user.id); return { ...user, avatar }; } catch (err) { console.warn(头像加载失败使用默认头像); return { ...user, avatar: DEFAULT_AVATAR }; } } catch (err) { console.error(用户信息加载失败跳转登录页); redirectToLogin(); } }头像加载失败了不影响用户信息展示就给默认头像用户信息失败是致命错误整体跳转。这种分层处理是最常见的实用形态。还有一种用 catch 改写控制流的技巧如果某个失败是可预见的“业务性失败”你希望进入一个分支而不是中断整体可以考虑把失败转成返回值async function loadData() { const result await fetchData().then(data ({ ok: true, data })) .catch(err ({ ok: false, error: err })); if (!result.ok) { // 走降级逻辑 return fallback(); } return result.data; }这一招虽然用到了 then但在 async 函数里和 await 配合错误处理会变得非常灵活。3.5 请求超时与竞态处理async/await 进阶实战异步开发里容易被忽视的两个问题请求超时和响应竞态。先说超时。fetch 默认没有超时机制如果网络异常Promise 可能迟迟不落定。用 Promise.race 可以巧妙实现超时控制function fetchWithTimeout(url, timeout 5000) { return Promise.race([ fetch(url), new Promise((_, reject) setTimeout(() reject(new Error(请求超时)), timeout) ) ]); } async function loadData() { try { const res await fetchWithTimeout(/api/data, 3000); const data await res.json(); return data; } catch (err) { console.error(err.message); } }这里 Promise.race 会返回最先落定的那个 Promise 的结果。如果先超时错误就会被抛出如果请求先成功超时计时器虽然仍然到点但 Promise.race 的结果已经确定了后续计时器拒绝不会有任何影响。再说竞态。典型场景用户快速切换筛选条件先发的请求后返回后发的请求先返回旧响应覆盖了新数据。这是非常经典的异步竞态问题。轻量解法是“版本号标记”let requestId 0; async function search(keyword) { const currentId requestId; const res await fetch(/search?q${keyword}); const data await res.json(); if (currentId requestId) { renderResult(data); // 只有最新请求才渲染 } }更标准的做法是用 AbortController 取消过期请求let currentController null; async function search(keyword) { currentController?.abort(); const controller new AbortController(); currentController controller; try { const res await fetch(/search?q${keyword}, { signal: controller.signal }); const data await res.json(); renderResult(data); } catch (err) { if (err.name AbortError) { console.log(请求已取消); } else { console.error(err); } } }AbortController 是浏览器内置 API专门用来取消 fetch 请求。配合 async/await 使用能非常优雅地解决竞态问题。4. 常见报错与排查技巧实录4.1 “uncaught (in promise) ...”系列报错为什么 catch 没捕获到这个报错应该是前端控制台里最“眼熟”的了。它的完整形式通常是Uncaught (in promise) TypeError: Cannot read properties of undefined (reading xxx)末尾还会跟一个堆栈信息。出现这个报错的核心原因只有一个有一个 Promise 已经变成 rejected 状态但代码中没有为该 Promise 设置任何处理函数。举两个常见的例子// 例子1async 函数内部抛错调用方没有 catch async function load() { throw new Error(系统错误); } load(); // 控制台会报 Uncaught (in promise) Error: 系统错误// 例子2Promise 链里没有 catch fetch(/api/data).then(res res.json()); // 如果这里 /api/data 返回 500fetch 本身不会 reject但 res.json() 或后续访问属性时可能 throw排查这种问题的思路找到报错的 Promise 来源。看堆栈信息定位到具体是哪个 async 函数或 Promise 链。在该 Promise 上临时挂一个 catch输出错误详情。检查调用方是否忘记 await 或 .catch。在代码里加一个全局兜底监听避免漏网之鱼window.addEventListener(unhandledrejection, event { console.warn(未处理的 Promise 拒绝, event.reason); event.preventDefault(); // 避免控制台输出默认错误 });这个监听器可以帮你定位“哪个 Promise 没人处理”尤其是在大型项目的线上环境里能节省大量排查时间。4.2 “await 只能在 async 函数中使用”报错最常见的语法误区报错信息是SyntaxError: await is only valid in async functions and the top level bodies of modules。说白了你现在所在的作用域不是 async 函数不能用 await。导致这个报错的场景很常见在普通函数里直接写 awaitfunction getUserData() { const data await fetchUser(); // SyntaxError return data; }在 setInterval/setTimeout 回调里写 awaitsetTimeout(() { const data await fetchUser(); // SyntaxError }, 1000);在 forEach 回调里写 awaitasync function loadAll(ids) { ids.forEach(async id { const data await fetchById(id); // 这个其实不会报语法错误但行为不符合预期 }); }解决方式有几种。如果函数本身必须保持普通函数可以把 async 操作提出来用 .then 链解决如果确实想用 await就把外层函数改成 async。比如 setTimeout 场景可以用“async 包装器”setTimeout(async () { const data await fetchUser(); console.log(data); }, 1000);或者使用 Promise.then 的写法setTimeout(() { fetchUser().then(data { console.log(data); }); }, 1000);注意区分这种语法错误只要编译器/引擎报出来解决方法相对机械但 forEach 里不生效的问题不会报错属于行为 bug更难排查。写完 async/await 一定要想清楚“这里是否真的会等”。4.3 async 函数返回的是 Promise 而不是值为什么 console.log 打不出结果很多初学者会写出这种代码async function getUserName() { return 张三; } const name getUserName(); console.log(name); // Promise {fulfilled: 张三}打印出来是一个 Promise而不是字符串“张三”。这是因为 async 函数必然返回 Promise。在这里必须先 await 或 .then 才能取到值async function main() { const name await getUserName(); console.log(name); // 张三 }这个现象也经常出现在事件监听器里。如果监听器是一个 async 函数你想在函数内部 return 一个结果给外部事件系统是拿不到的。错误处理的语义也不一样。比如 click 监听器 return false 无法阻止默认行为因为 async 函数返回的是 Promise。所以在给 addEventListener 传 async 回调时要明确这一点避免踩坑。4.4 代码没有按顺序执行大概率是漏掉 await 或选错并发方式“为什么我的接口数据还没回来下面代码就跑了”这种问题排查下来十有八九是漏了 await。多个请求并发时有的想串行却写成了并行有的想并行却等成了串行也都会出现不符合预期的顺序。排查思路可以这样先看每个异步操作前有没有写 await。再看异步操作之间是否存在数据依赖。有依赖必须串行无依赖可以并行。确认循环里用的是 for 还是 forEach。想要串行等完一个再等下一个就用 for...of想要并发就 map Promise.all。在关键位置打 console.log用时间戳确认执行顺序是否符合预期。顺手分享一个我自己常用的调试技巧写一个带延时和标识的模拟请求函数用来快速验证控制流const mockFetch (name, delay) new Promise(resolve { setTimeout(() { console.log(${name} 完成); resolve(name); }, delay); }); async function testFlow() { console.log(开始); const a mockFetch(A, 1000); const b mockFetch(B, 500); const result await a; console.log(拿到 A 的结果, result); await b; console.log(全部完成); }这个模型能非常直观地看到并行和串行的区别。如果 a 和 b 并发执行总耗时约 1 秒如果先 await a 再 await b总耗时约 1.5 秒。做这种小实验比记概念要可靠得多。4.5 注意 async/await 中的同步阻塞CPU 密集型任务要小心很多人误以为 async/await 可以让代码“多线程化”让 CPU 密集计算不阻塞页面。这是个误区。JavaScript 仍然是单线程执行的。async/await 解决的是 I/O 等待网络请求、文件读取、定时器等它只是在等待期间让出执行权让其他任务有机会执行但真正的 CPU 密集型计算大量循环、复杂运算、数据转换还是会阻塞主线程。比如async function handleBigData() { const data await fetchBigData(); // 等待网络没问题 // 下面这段会阻塞页面 const result []; for (let i 0; i 100000000; i) { result.push(doHeavyWork(i)); } return result; }遇到耗时计算该用 Web Worker 还是得用 Web Workerasync/await 帮不上忙。我们有位同事曾在一个 async 函数里处理 30 万条数据的批量重组结果接口倒是返回了但后续计算把页面卡了四五秒。后来把重计算拆到 worker 里主线程才恢复流畅。有一点可以做的优化是“分片处理”比如把大批量计算拆成多个小块配合 await 让出主线程async function processInChunks(list, chunkSize 1000) { let index 0; const results []; while (index list.length) { const chunk list.slice(index, index chunkSize); results.push(processChunk(chunk)); index chunkSize; await new Promise(resolve setTimeout(resolve, 0)); // 让出主线程 } return results; }这里的await new Promise(resolve setTimeout(resolve, 0))相当于插入一个宏任务让浏览器有机会渲染动画、响应用户输入。这种方式在实现“大数据逐帧加载”时很实用。4.6 浏览器与 Node 环境的 async/await 差异浏览器环境和 Node.js 环境下使用 async/await 总体语法一致但细节差异值得注意在 Node.js 环境中顶层 await 的支持从 Node 14.8 开始并且只对 ES Module.mjs 文件有效。CommonJS 模块中不能直接使用顶层 await。Node.js 里如果出现未处理的 Promise 拒绝unhandledRejection默认行为在较新的版本中会让进程直接崩溃退出而浏览器只是控制台报错。所以 Node 项目里更要加强错误捕获意识。文件读取、数据库访问等场景最好使用对应 API 的 Promise 版本例如fs.promises.readFile而不要用回调版本再手动封装。浏览器环境下页面销毁后仍然有 Promise 回调在跑容易造成内存泄漏或报错建议配合 AbortController 和页面 unload 事件及时取消未完成的请求。先来看 Node 环境下的一个典型错误// Node.js 中未处理的 Promise 拒绝会导致进程退出 async function readConfig() { throw new Error(配置文件读取失败); } readConfig(); // 在较新的 Node 版本中进程会因此退出并打印错误解决方案同样简单调用 readConfig 的地方要么 await try/catch要么 .catch。如果是“先启动再异步检查”的姿态要特别注意这一点。对于浏览器环境页面切换导致“setState after unmount”之类的问题本质上也是异步竞态问题。建议在组件卸载时取消请求或者在状态更新前做标记。React/Vue 生态里有很多 handled 方案核心原理和 AbortController 一致。4.7 常见问题速查表错误/症状原因解决方案SyntaxError: await is only valid in async functions在普通函数作用域中使用 await将外层函数改为 async或用 .thenUncaught (in promise) TypeError: ...Promise 已 rejected 但没有处理函数确保调用处有 await try/catch 或 .catchUnhandled promise rejectionasync 函数抛错但未捕获调用处兜底处理可挂全局 unhandledrejection 监听forEach 里 await 不生效forEach 不等待异步回调改用 for...of 串行或 map Promise.all 并发async 函数return拿不到值async 函数必然返回 Promiseawait 该函数或 .then 处理打印输出顺序不对漏写 await或误用串行/并行检查每个异步操作是否 await分清依赖关系页面卡顿async 函数内 CPU 密集计算阻塞主线程拆分计算、分片处理、使用 Worker请求超时fetch 本身无超时机制Promise.race setTimeout 实现超时旧请求覆盖新响应异步竞态版本号标记 / AbortController 取消旧请求5. 如何写出更健壮的 async/await 代码5.1 统一异步接口风格让团队代码可预期在一个团队项目里最怕的是有人用 async/await有人用 .then/.catch有人混合用。代码风格一旦不统一排查问题往往要同时理解两种写法的心智模型非常消耗精力。建议团队内部明确一个原则新写的代码以 async/await 为主try/catch 处理错误。只有在非 async 环境比如事件监听、老代码适配里才用 .then/.catch。async 函数内部不要再混写 .then/.catch 链除非是“局部错误消化”的特殊情况。统一风格之后代码评审的难度会下降很多逻辑转移的成本也会明显降低。5.2 接口封装时一定要返回 Promise不要“双态”有的接口封装喜欢既支持回调又支持 Promise返回类型不确定。这种设计对调用方心智负担很大。建议接口封装统一返回 Promise调用方自由选择 await 和 .then。保持“一个函数返回类型确定”的原则能减少很多隐性 bug。另外接口封装时建议对网络错误先做本地归一化把不同的错误类型网络错误、HTTP 状态码错误、业务错误都转换成一个统一格式的错误对象这样调用方就不用反复在 catch 里做类型判断了。class ApiError extends Error { constructor(code, message, detail null) { super(message); this.code code; this.detail detail; } } async function request(url, options {}) { let res; try { res await fetch(url, options); } catch (err) { throw new ApiError(NETWORK_ERROR, 网络连接失败, err); } if (!res.ok) { throw new ApiError(res.status, 请求失败${res.status}); } const data await res.json(); if (data.code ! 0) { throw new ApiError(data.code, data.message || 业务错误); } return data.data; }这样封装之后所有业务页面只需要 catchApiError根据err.code做不同处理就行逻辑非常统一。5.3 善用工具函数并发限制、重试、超时一个不能少在实际项目中有几个工具函数是能明显提升稳定性的建议沉淀到团队公共库里第一个是前面提过的并发控制工具runWithLimit。第二个是自动重试。网络波动时简单的重试可以挽救很多偶发错误async function fetchWithRetry(requestFn, maxRetries 3, delay 500) { for (let attempt 1; attempt maxRetries; attempt) { try { return await requestFn(); } catch (err) { if (attempt maxRetries) { throw err; } await new Promise(resolve setTimeout(resolve, delay * attempt)); } } }第三个是超时控制前面提到的 fetchWithTimeout。这三个工具组合起来基本能应对绝大多数网络不确定场景。有一点要提醒重试必须考虑接口是否是幂等的。如果是下单、转账这种非幂等操作盲目重试可能造成重复扣款。这是业务层面的红线编码时要特别小心。5.4 可观测性给 async/await 加日志和埋点异步代码出问题最难的部分是“复现”。很多异步 bug 不是必然发生而是特定时序才会触发。所以在上线前考虑好日志和埋点能省下大量排查时间。建议在关键业务入口、出口和 catch 处统一记录日志async function loadOrderDetail(orderId) { console.log([order] 开始加载订单, orderId); try { const detail await fetchOrderDetail(orderId); console.log([order] 加载成功, orderId); return detail; } catch (err) { console.error([order] 加载失败, orderId, err); throw err; } }上线看到这类日志定位问题就很快。如果是线上环境可以考虑接入一个简单的日志上报函数把 catch 到的错误统一上报再配合 uuid 等标识关联请求链路。5.5 测试异步代码用假数据把流程跑稳写异步代码同样要写测试。最简单的测试思路是用 mock 函数替代真实请求验证业务逻辑是否按预期顺序执行、错误分支是否正确。以 Jest 或 Vitest 为例测试一个包含 async/await 的业务模块时只需要将网络层 mock 掉import { loadUserProfile } from ./profile; import { fetchUser, fetchAvatar } from ./api; jest.mock(./api); test(loadUserProfile 正常流程, async () { fetchUser.mockResolvedValue({ id: 1, name: 张三 }); fetchAvatar.mockResolvedValue(avatar-url); const result await loadUserProfile(); expect(result.name).toBe(张三); expect(result.avatar).toBe(avatar-url); }); test(loadUserProfile 头像失败时返回默认头像, async () { fetchUser.mockResolvedValue({ id: 1, name: 张三 }); fetchAvatar.mockRejectedValue(new Error(网络错误)); const result await loadUserProfile(); expect(result.avatar).toBe(DEFAULT_AVATAR); });测试异步代码的关键不是“测 Promise 本身”而是验证业务分支。把异步边界封装好之后业务函数的行为就能像同步函数一样被可靠地测试。6. 我的习惯与建议把 async/await 融入日常开发说了这么多最后分享几个我自己实际使用 async/await 的心得。一是“能用 async/await 的地方优先用 async/await但底层的 Promise 心智不能丢”。async/await 的写法让你可以把复杂异步流程写得很平、很直但遇到 Promise.all、Promise.race、错误消化这些场景时还是要能随时切回 Promise 思维。这两种思维不是对立的而是同一枚硬币的两面。二是“错误处理要层层设防但也要避免过度防御”。该让错误抛出去的时候就让错误抛出去不要在每一层都 try/catch 然后吞掉。过度防御的代码往往很难排查问题错误被雪藏了调用方完全不知道发生了什么。我习惯的做法是底层只做错误转换中间层只记录必要的日志顶层再做用户提示或降级处理。三是“写之前先想清楚执行顺序”。只要涉及三个以上异步操作的组合我会先在草稿纸上写一下执行顺序、是串行还是并行、错误如何传播。这一步看起来繁琐但能避免百分之八十的异步 bug。如果你不想写草稿也可以直接写一个 mock 模型测试就像前面展示的那样几分钟就能验证清楚。四是“async/await 也要考虑性能”。面试常问的“串行还是并行”已经成了基本功。真实项目里那些“点开页面要等三秒”的体验常常就是多个可并行的请求被串行等待了。数据结构设计或接口设计允许的话合并请求、缓存复用、并发控制都是调优的重点。五是“工具函数要沉淀”。并发控制、超时、重试、统一错误格式、竞态取消这些是我在新项目开始时就一定会搭的基础设施。配合 async/await 使用它们能让整个项目的异步代码质量上一个档次。踩过这么多坑之后我对 async/await 最大的感受是它把异步编程从“防不胜防”变成了“可读、可写、可测”。但工具始终只是工具真正决定代码质量的还是你对手底下这套异步机制的理解深度。希望这篇文章能帮你把 Promise 和 async/await 这条链路彻底吃透之后写异步代码时脑子里能有一个清晰的事件循环运转图而不是靠试错和玄学调代码。
返回列表