深入解析 async/await 异常处理:从 try-catch 原理到工程实践

发布时间:2026/11/4 18:43:23
深入解析 async/await 异常处理:从 try-catch 原理到工程实践

写业务代码写到一定年头,你会发现一个挺反直觉的现象:真正让人半夜爬起来查日志的 bug,往往不是那些一眼就能看出来的语法错误,而是异常被悄悄吞掉、Promise 静默 reject、或者 catch 块里又抛了个新错导致原始堆栈丢失。try-catch这个语法本身简单到几乎不需要学,但把它和async/await放在一起用的时候,坑就密集出现了——同步代码里的 try-catch 直觉,套到异步流程上经常失效。这篇内容就是把我这些年在这块踩过的、见过的、帮别人排查过的问题系统梳理一遍,从同步异常的基本机制讲到 async/await 下的异常传播路径,再到工程里怎么组织错误处理才不至于让线上问题变成"玄学"。不管你是刚接触 Promise 的新手,还是已经写了几年前端、想把这套机制彻底理清的老手,下面这些内容应该都能对上你的某些实际场景。

1. 先搞清楚 try-catch 到底能接住什么

很多人对 try-catch 的理解停留在"把可能出错的代码包起来",但"可能出错"这四个字太模糊了。要真正用好它,得先明确它的捕获边界——哪些异常它接得住,哪些它根本管不了。这个边界搞不清楚,后面所有关于 async/await 的讨论都是空中楼阁。

1.1 同步执行栈内的异常捕获原理

JavaScript 的 try-catch 本质上依赖的是执行栈。当一段代码在 try 块内同步执行时,引擎会在这个执行上下文上挂一个异常处理的"锚点"。一旦某行代码抛出异常(无论是throw主动抛的,还是运行时错误如访问 undefined 的属性),引擎会立刻中断当前执行流,沿着调用栈向上查找最近的 catch 锚点。找到就跳进去执行 catch 块,找不到就继续往上冒,直到全局,触发uncaughtException(Node)或控制台报错(浏览器)。

关键在于"同步执行"这四个字。看这段代码:

try { setTimeout(() => { throw new Error('定时器里的错误'); }, 0); } catch (e) { console.log('能接住吗?', e.message); }

答案是接不住。因为setTimeout的回调是在未来的某个事件循环 tick 里执行的,那时候 try 块早就执行完退出了,执行栈上根本没有对应的 catch 锚点。回调抛错时,引擎只能一路冒到全局。这是最经典的"try-catch 失效"场景,也是理解异步异常处理的起点。

我见过不少同学在这里栽跟头,尤其是从同步思维过渡过来的时候,觉得"我明明包在 try 里了怎么还崩"。原因就是异常发生的时间点和 try 块的生命周期不重叠。记住一句话:try-catch 只对"在 try 块执行期间、同步抛出"的异常有效。

1.2 那些 try-catch 注定接不住的场景

除了定时器回调,还有几类场景是 try-catch 的天然盲区,值得单独列出来,因为它们在真实项目里出现频率极高:

  • 事件回调:element.addEventListener('click', () => { throw new Error() }),点击时抛的错跟注册时的 try 块毫无关系。
  • Promise 的 reject:Promise.reject(new Error())如果不接.catch,会变成 unhandled rejection,try-catch 包住创建 Promise 的那行代码也没用。
  • 异步函数返回的 Promise:这个稍微绕一点,后面单独讲,但结论是——不 await 的话,try-catch 接不住。
  • 跨微任务/宏任务的延迟抛错:任何在 try 块退出后才执行的代码,抛错都不归它管。

这里有个容易混淆的点:Promise构造函数内部的同步抛错,其实是能被接住的。因为new Promise((resolve, reject) => { throw new Error() })里,执行器函数是同步调用的,抛错会被 Promise 内部捕获并转成 reject。但这属于 Promise 自己的机制,不是 try-catch 的功劳。这个区别在排查问题时很重要,因为它决定了错误最终以什么形态出现——是同步异常还是 rejected promise。

1.3 一个判断口诀:异常发生的那一刻,try 块还活着吗

我总结了一个特别实用的判断方法,遇到不确定能不能接住的情况,就问自己一句:异常抛出的那个瞬间,对应的 try 块是否还在执行栈上?

在,就能接住;不在,就接不住。这个口诀几乎能覆盖 90% 的疑惑场景。比如:

async function foo() { throw new Error('异步函数里的错'); } try { foo(); // 没有 await } catch (e) { console.log('接不住'); }

foo()调用后立刻返回一个 rejected promise,函数体里的throw是在微任务里执行的,此时 try 块已经执行完退出了。所以接不住。但如果加上await:

try { await foo(); } catch (e) { console.log('接住了', e.message); }

await会把后续代码"挂起",等 promise settle 后再恢复执行,而恢复执行的位置仍然在 try 块的逻辑范围内(引擎内部通过微任务调度维持了这个关联),所以能接住。这个差异是 async/await 异常处理的核心,下一节展开讲。

2. async/await 把异常传播路径彻底改写了

async/await 表面上是让异步代码写起来像同步,但它对异常传播的影响远不止"看起来像"。理解它,得先理解 async 函数返回的到底是什么,以及 await 在异常发生时做了什么。

2.1 async 函数永远返回 Promise,这是所有行为的根源

一个async函数,无论你return什么,最终都会被包装成一个 Promise。return 1变成Promise.resolve(1),throw new Error()变成Promise.reject(new Error())。这个转换是理解一切的前提。

所以当你在 async 函数内部throw时,错误并没有"当场炸掉",而是被转成了一个 rejected promise。这个 promise 如果没人处理,就会变成 unhandled rejection。而 try-catch 能不能接住,取决于你有没有用await去"消费"这个 promise。

async function risky() { throw new Error('出错了'); } // 情况一:不 await try { risky(); } catch (e) { // 进不来 } // 情况二:await try { await risky(); } catch (e) { // 进来了 }

情况一里,risky()返回的 rejected promise 被直接丢弃,try 块正常结束,catch 不触发,同时控制台会报 unhandled rejection。情况二里,await相当于在 promise 上挂了 then/catch,reject 时把异常"重新抛出"到 await 所在的同步位置,而这个位置在 try 块内,所以被接住。

我个人的经验是:只要一个 async 函数可能抛错,调用它的时候要么 await 并包 try-catch,要么显式 .catch,绝不能裸调。裸调是线上 unhandled rejection 的头号来源。

2.2 await 的异常"重抛"机制拆解

await在遇到 rejected promise 时的行为,可以粗略理解为:它把 promise 的 reject 值取出来,然后以同步throw的方式在当前 await 表达式的位置抛出。正因为是"在当前位置抛出",它才能被外层的 try-catch 捕获。

这个机制带来一个很实用的推论:try-catch 的包裹范围要精确覆盖到 await 那一行。看这个反例:

async function process() { const data = await fetchData(); // 如果这里 reject return transform(data); } try { const p = process(); // 只拿到 promise,没 await console.log(p); } catch (e) { // 接不住 fetchData 的错 }

正确写法是把 await 放进 try:

try { const data = await fetchData(); return transform(data); } catch (e) { // 能接住 }

这里还有个细节值得说:transform(data)如果同步抛错,也能被同一个 catch 接住,因为它就在 try 块内同步执行。所以一个 try 块可以同时覆盖"await 的异步错误"和"后续同步代码的错误",这是 async/await 相比 Promise 链式调用在可读性上的巨大优势——Promise 链里你得在.then和.catch之间来回跳,而 async/await 把错误处理收敛到了一个地方。

2.3 串行 await 与并行 await 的错误处理差异

这个点很多人没意识到,但它直接影响错误处理的写法。串行 await 时,任何一个环节 reject 都会中断后续执行并跳到 catch:

try { const a = await stepA(); const b = await stepB(a); const c = await stepC(b); } catch (e) { // 任何一步失败都到这里,后面的步骤不会执行 }

而并行 await 如果用Promise.all,行为是"快速失败"——只要有一个 reject,整个 all 就 reject,其他还在跑的 promise 结果被丢弃:

try { const [a, b, c] = await Promise.all([stepA(), stepB(), stepC()]); } catch (e) { // 任何一个失败就进来,但其他成功的任务其实已经执行了 }

这里有个坑:Promise.all快速失败后,其他 promise 并不会被取消,它们继续在后台跑,如果它们后续也 reject,可能产生额外的 unhandled rejection(取决于实现和时机)。如果业务上需要"全部完成再判断",应该用Promise.allSettled,它永远 resolve,返回每个任务的状态:

const results = await Promise.allSettled([stepA(), stepB(), stepC()]); results.forEach((r, i) => { if (r.status === 'rejected') { console.error(`任务 ${i} 失败:`, r.reason); } });

选 all 还是 allSettled,取决于你的业务语义:是"一个失败就整体放弃",还是"尽量都跑完再汇总"。这个决策要在写代码前想清楚,而不是等线上出了问题再改。

3. 错误对象本身:比捕获更重要的是捕获到什么

很多人把精力全放在"怎么接住异常"上,却忽略了"接住之后拿到的是什么"。一个丢失了堆栈、丢失了上下文的 Error,接住了也等于没接——你只知道出错了,不知道在哪、为什么、影响了谁。

3.1 Error 的 stack 是怎么来的,什么时候会丢

new Error()创建时,引擎会采集当前的调用栈快照存进stack属性。这个快照是"创建那一刻"的栈,不是"抛出那一刻"的。这个区别在跨异步边界时特别要命。

考虑这个场景:

async function inner() { await something(); throw new Error('业务错误'); }

错误在inner里创建并抛出,stack 记录的是inner被恢复执行时的调用栈。由于经过了 await 的挂起和恢复,原始调用链的某些帧可能已经不在栈上了,stack 看起来会比实际调用路径"短"或者"断"。这不是 bug,是异步的本质决定的。

更常见的丢栈场景是重新抛错时没保留原始错误:

try { await doSomething(); } catch (e) { throw new Error('操作失败'); // 原始 e 的信息全丢了 }

这样写,上层拿到的错误只有"操作失败",原始的错误类型、消息、堆栈全没了。正确做法是用cause把原始错误挂上去(现代运行时都支持):

try { await doSomething(); } catch (e) { throw new Error('操作失败', { cause: e }); }

这样上层既能拿到业务语义清晰的错误消息,又能通过error.cause追溯根因。我在排查线上问题时,cause链几乎是第一手线索,比翻日志快得多。

3.2 自定义错误类型:让 catch 能做出精准判断

catch (e)拿到的 e 是unknown类型(TypeScript 里尤其明显),你没法直接假设它有message或code。工程里比较稳妥的做法是定义一套自定义错误类,用instanceof或错误码来区分处理逻辑。

class AppError extends Error { constructor(message, code, options) { super(message, options); this.name = 'AppError'; this.code = code; } } class NetworkError extends AppError { constructor(message, options) { super(message, 'NETWORK_ERROR', options); this.name = 'NetworkError'; } }

然后在 catch 里:

try { await fetchData(); } catch (e) { if (e instanceof NetworkError) { // 网络问题,可以重试 } else if (e instanceof AppError) { // 业务错误,提示用户 } else { // 未知错误,上报 reportUnknown(e); } }

这里有个 TypeScript 的实用技巧:catch 变量默认是unknown,在访问属性前要先做类型收窄。可以写个类型守卫函数isAppError(e): e is AppError,比到处写instanceof更清晰,也方便复用。

我踩过的一个坑是:跨 iframe 或跨模块(不同打包 chunk)时,instanceof可能失效,因为同一个类在不同上下文里是不同的构造函数引用。这种场景下用错误码(e.code)判断更可靠。所以我的习惯是——自定义错误同时带 name、code 和 instanceof 三重标识,判断时优先用 code。

3.3 别在 catch 里做太多事:catch 块的最小职责原则

一个反模式是把 catch 块写成一个小型业务逻辑中心:打日志、弹提示、重试、降级、上报、清理状态全塞进去。这样写短期没问题,长期会变成维护噩梦——你很难说清一个 catch 到底负责什么,测试也难写。

我的做法是让 catch 块只做三件事之一:转换(包装成上层能理解的错误)、记录(打日志/上报)、恢复(重试或降级)。如果三件事都要做,就拆成独立的函数调用,catch 里只负责编排:

try { await syncData(); } catch (e) { logger.error('syncData failed', { error: e }); reporter.capture(e); throw new SyncError('数据同步失败', { cause: e }); }

这样每个职责都清晰,日志格式统一,上报逻辑集中,错误转换也有明确边界。后面要改上报策略,只动 reporter,不用碰业务代码。

4. 工程化视角:把异常处理组织成可维护的结构

单点的 try-catch 写对不难,难的是在一个几十上百个异步调用的项目里,让错误处理保持一致、可观测、不遗漏。这一节讲的是组织层面的经验,都是被真实项目磨出来的。

4.1 统一入口的错误边界设计

前端有全局的window.onerror和unhandledrejection,Node 有uncaughtException和unhandledRejection。这些是最后一道防线,但绝不能当成主要手段——它们只能告诉你"出事了",拿不到业务上下文,也没法做精细的恢复。

合理的分层是:

层级手段职责
调用点try-catch / .catch处理可预期的、局部的错误,做恢复或转换
模块边界统一包装函数兜底未处理的错误,附加模块上下文后上报
全局全局钩子捕获漏网之鱼,记录并防止进程崩溃

模块边界这一层最容易被忽略,但价值最大。比如给所有 API 调用包一层:

async function safeCall(fn, context) { try { return await fn(); } catch (e) { logger.error('API call failed', { context, error: e }); throw e; // 继续往上抛,让调用方决定怎么处理 } }

这样每个 API 调用都自动带上了上下文日志,调用方仍然可以自己 try-catch 做业务处理。关键是"记录后继续抛",而不是"吞掉"——吞掉是万恶之源。

4.2 什么时候该吞、什么时候必须抛

"不要吞异常"是原则,但现实里确实有该吞的场景。判断标准是:这个错误是否影响当前操作的最终结果,以及调用方是否有能力处理它。

该吞的典型场景:

  • 非关键的埋点上报失败——上报失败不该影响主流程。
  • 可选的增强功能失败——比如头像加载失败,用默认头像兜底即可。
  • 清理逻辑里的错误——清理失败通常也没法补救,记录即可。

必须抛的场景:

  • 核心业务数据操作失败——调用方需要知道并决定重试还是提示。
  • 参数校验失败——这是调用方的责任,必须让它知道。
  • 依赖服务不可用——上层可能需要降级或切换。

我见过最坑的写法是"为了不报错而 catch 后什么都不做":

try { await saveOrder(order); } catch (e) { // 空 }

这种代码在测试环境一切正常,上线后订单静默丢失,排查时毫无线索。如果确实要吞,至少留一行日志,并且注释说明为什么可以吞。

4.3 重试、超时与取消:异步错误处理的三件套

真实项目里,光捕获错误不够,还得有恢复策略。重试是最常见的,但重试本身有很多讲究:

async function retry(fn, { retries = 3, delay = 1000, backoff = 2 } = {}) { let lastError; for (let i = 0; i <= retries; i++) { try { return await fn(); } catch (e) { lastError = e; if (i < retries) { await sleep(delay * Math.pow(backoff, i)); } } } throw lastError; }

几个要点:只重试可重试的错误(网络超时可以重试,参数错误重试多少次都没用)、用指数退避避免雪崩、设置最大重试次数防止无限循环。我见过没设上限的重试把下游服务打挂的案例,教训很深刻。

超时和取消则依赖AbortController:

const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); try { const res = await fetch(url, { signal: controller.signal }); } catch (e) { if (e.name === 'AbortError') { // 超时或被主动取消 } } finally { clearTimeout(timer); }

注意AbortError是特殊的错误类型,要单独判断,不能和普通网络错误混为一谈。另外finally里清理定时器很重要,否则会泄漏。

4.4 日志与上报:让 catch 块产出可排查的信息

catch 块里打日志,最容易犯的错是只打e.message。message 往往只有一句话,没有堆栈、没有上下文、没有请求参数,线上根本没法定位。

我习惯的日志结构是:

logger.error('操作失败', { error: { name: e.name, message: e.message, stack: e.stack, code: e.code, cause: e.cause?.message, }, context: { userId, action: 'saveOrder', params: sanitize(params), // 脱敏后再记 }, });

几个细节:堆栈必记、上下文必记、敏感信息必脱敏。脱敏这点经常被忽略,把用户手机号、身份证直接打进日志,合规上是要出问题的。我一般会写个sanitize函数统一处理,白名单保留必要字段,其余打码。

还有一点:同一错误不要重复上报。如果每一层 catch 都上报一次,一个错误会产生 N 条记录,噪音极大。约定是"只在最外层或专门的边界层上报,内层只记录或转换"。

5. 几个高频踩坑场景的完整排查链路

理论讲完,来点实战。下面这几个场景是我在代码 review 和线上排查里反复遇到的,每个都给出从现象到根因到修复的完整链路,方便你对照自己的代码。

5.1 现象:接口偶发失败但日志里什么都没有

排查链路是这样的:先确认接口是否真的失败(看网络面板或服务端日志),确认失败后查前端有没有对应的 catch。常见根因是 Promise 链里某个.then抛错但没接.catch,或者 async 函数裸调没 await。定位方法是全局挂unhandledrejection监听,把 reason 和当时的上下文打出来:

window.addEventListener('unhandledrejection', (event) => { logger.error('Unhandled rejection', { reason: event.reason, stack: event.reason?.stack, }); });

挂上之后往往立刻就能看到漏网的 promise。修复就是把裸调改成 await + try-catch,或者补上 .catch。

5.2 现象:catch 里拿到的错误 message 是空的

这种情况多半是抛的不是 Error 实例,而是字符串或普通对象:throw '出错了'或throw { code: 500 }。catch 到的 e 就是那个原始值,没有 message 属性。修复分两步:一是规范代码里只 throw Error 实例(可以加 ESLint 规则no-throw-literal强制),二是在 catch 入口做归一化:

function normalizeError(e) { if (e instanceof Error) return e; return new Error(typeof e === 'string' ? e : JSON.stringify(e)); }

这个归一化函数我几乎每个项目都会放一个,成本极低,收益很大。

5.3 现象:错误堆栈指向打包后的压缩代码,完全看不懂

生产环境代码压缩后,stack 里的行号列号对不上源码。解决办法是开启 source map 并配置错误上报工具做映射。如果不想暴露 source map 到公网,可以只上传到监控平台,用 sourcemap 文件在服务端还原。这块的配置因构建工具而异,核心是保证"上报的 stack + 对应的 sourcemap"能对上版本。我踩过的坑是 sourcemap 版本和线上代码版本不一致,映射出来的位置全是错的,所以发布流程里要把 sourcemap 和构建产物绑定管理。

5.4 现象:try-catch 包住了但还是崩了

回到最开始的口诀:异常发生的那一刻,try 块还活着吗?如果崩了,说明异常发生在 try 块生命周期之外。常见于:回调里抛错、promise 没 await、定时器里抛错。定位方法是看崩溃堆栈的最顶层帧,判断它属于哪个执行上下文。修复就是把这些"逃逸"的异常重新纳入 try 的范围——回调里的错误在回调内部 try,promise 补 await 或 catch。

6. 一些让代码更稳的个人习惯

写到这里,分享几个我这些年固化下来的习惯,都是小改动但长期收益明显的。

第一,async 函数调用默认加 await,除非明确知道不需要等待结果。裸调 async 函数是 unhandled rejection 的主要来源,养成 await 的习惯能消灭一大类问题。如果确实要"发射后不管",显式写.catch(noop)并注释说明,让意图清晰。

第二,catch 参数永远当作 unknown 处理,访问属性前先判断类型。TypeScript 的useUnknownInCatchVariables建议开启,强制自己做类型收窄,避免运行时e.message报 undefined 的二次错误。

第三,错误信息里带上足够的定位线索。我写 throw 的时候会习惯性想一句:如果这行报错,我看到 message 能不能知道是哪个业务、哪个参数出的问题?如果不能,就补上。比如throw new Error('订单创建失败: userId=' + userId)就比throw new Error('创建失败')有用得多(当然敏感信息要脱敏)。

第四,finally 里别 return。finally块里的 return 会覆盖 try 和 catch 的返回值,是个隐蔽的坑。清理逻辑放 finally 没问题,但别在里面写 return 或 throw,除非你非常清楚后果。

第五,给关键异步流程画一张错误传播图。复杂业务里,一个请求可能经过拦截器、多个 service、多个 await,错误在哪一层被转换、哪一层被吞、哪一层上报,画出来一目了然。我排查疑难问题时经常先画这张图,比读代码快。

这套机制说到底不复杂,难的是在长期迭代里保持一致性。把捕获边界、错误对象、分层策略、恢复手段这几件事想清楚,再配合几个固定的编码习惯,线上那些"玄学"异常会少一大半。剩下的,就是遇到具体问题时按排查链路一步步走,别急着改代码,先搞清楚异常到底在哪一刻、哪一层发生的。