
写业务代码写到一定年头你会发现一个挺反直觉的现象真正让人半夜爬起来查日志的 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 块找不到就继续往上冒直到全局触发uncaughtExceptionNode或控制台报错浏览器。关键在于同步执行这四个字。看这段代码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 的 rejectPromise.reject(new Error())如果不接.catch会变成 unhandled rejectiontry-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 块已经执行完退出了。所以接不住。但如果加上awaittry { 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/catchreject 时把异常重新抛出到 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 放进 trytry { 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 都会中断后续执行并跳到 catchtry { 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和unhandledrejectionNode 有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; }几个要点只重试可重试的错误网络超时可以重试参数错误重试多少次都没用、用指数退避避免雪崩、设置最大重试次数防止无限循环。我见过没设上限的重试把下游服务打挂的案例教训很深刻。超时和取消则依赖AbortControllerconst 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 的范围——回调里的错误在回调内部 trypromise 补 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错误在哪一层被转换、哪一层被吞、哪一层上报画出来一目了然。我排查疑难问题时经常先画这张图比读代码快。这套机制说到底不复杂难的是在长期迭代里保持一致性。把捕获边界、错误对象、分层策略、恢复手段这几件事想清楚再配合几个固定的编码习惯线上那些玄学异常会少一大半。剩下的就是遇到具体问题时按排查链路一步步走别急着改代码先搞清楚异常到底在哪一刻、哪一层发生的。