异步错误捕获的六大陷阱:await 裹着 try-catch 就一定稳了吗?

场景一:try-catch 捕获不到 Promise.reject?

js 复制代码
// ❌ 线上出现 Unhandled Rejection,但明明写了 try-catch
async function loadConfig() {
  try {
    const config = await fetchConfig();
    return config;
  } catch (e) {
    console.error('配置加载失败', e);
    return fallbackConfig;
  }
}

loadConfig();

结果:生产环境依然上报 UnhandledPromiseRejection

原因分析

问题不在 loadConfig 内部,而在于调用方没有 awaitasync 函数返回的 Promise 如果被"悬空"调用,内部 catch 虽然执行了,但外部引用这个 Promise 时若再次 reject,就会产生未捕获的 rejection。

实际场景中 fetchConfig 抛出了一个非 Error 类型的值 (比如数字 403),catch 捕获到后 console.error 打印了它,但紧接着又执行了 return fallbackConfig,整个过程 Promise 是 resolved 的。但如果 fetchConfig 在 Promise 中抛出的值被异步事件循环忽略了呢?------ 这是另一个陷阱。

解决方案

始终在顶层调用处处理 Promise

js 复制代码
// ✅ 在调用链末端加 catch 兜底
loadConfig().catch(err => {
  console.error('未捕获的配置加载异常', err);
});

或者给 Node.js/浏览器挂载全局兜底:

js 复制代码
// 浏览器
window.addEventListener('unhandledrejection', event => {
  console.error('未捕获的 Promise 拒绝:', event.reason);
  event.preventDefault(); // 阻止默认打印
});

// Node.js
process.on('unhandledRejection', (reason, promise) => {
  console.error('未捕获的 Promise 拒绝:', reason);
});

场景二:forEach 中的 await 吞掉了所有错误

js 复制代码
// ❌ 只会捕获到第一个错误,后面的请求全都"静默失败"
async function batchFetch(urls) {
  try {
    urls.forEach(async (url) => {
      const data = await fetch(url);
      processData(data);
    });
  } catch (e) {
    console.error('批量请求失败', e);
  }
}

原因分析

forEach 的回调是独立的 async 函数 ,每个回调返回的 Promise 没有人 await。外部 try-catch 只能捕获同步代码抛出的异常,而对那些返回的 Promise 完全"视而不见"。

假设 5 个 URL 中第 2 个请求 401 了:

  • 第 1 个正常执行 ✅
  • 第 2 个走到 catch 分支
  • 第 3、4、5 个继续执行(独立 Promise 互不影响)
  • 但外层 try-catch 根本没有捕获到任何异常

这就是线上经常出现的"部分请求失败但监控不到"的经典原因。

解决方案

js 复制代码
// ✅ 方案一:for...of + await(串行)
async function batchFetchSequential(urls) {
  for (const url of urls) {
    try {
      const data = await fetch(url);
      processData(data);
    } catch (e) {
      console.error(`请求失败: ${url}`, e);
    }
  }
}

// ✅ 方案二:Promise.allSettled(并行,不中断)
async function batchFetchAllSettled(urls) {
  const results = await Promise.allSettled(
    urls.map(url => fetch(url).then(data => processData(data)))
  );

  const errors = results.filter(r => r.status === 'rejected');
  if (errors.length > 0) {
    console.error(`${errors.length} 个请求失败`, errors.map(e => e.reason));
  }
}

场景三:全局错误拦截被 async 函数绕过

js 复制代码
// ❌ window.onerror 在 async await 下失效
window.onerror = function(msg, url, line, col, error) {
  console.log('全局错误:', msg);
  return true;
};

async function main() {
  throw new Error('async error');
}
main();

原因分析

window.onerror 只能捕获同步代码 中的运行时错误。async 函数返回的 Promise 被 reject 时,并不会触发 onerror,而是触发 unhandledrejection 事件。

解决方案

js 复制代码
// ✅ 全局错误监控的正确姿势
window.addEventListener('error', event => {
  // 捕获资源加载错误 + 同步 JS 错误
  console.error('全局错误:', event.error || event.message);
});

window.addEventListener('unhandledrejection', event => {
  // 捕获 async/await 和 Promise 的未处理拒绝
  console.error('未处理的 Promise 拒绝:', event.reason);
});

要点onerror 管同步,unhandledrejection 管异步。线上监控两者都须挂载。


场景四:JSON.parse 异常悄悄被 async 吞掉

js 复制代码
// ❌ 线上排查了很久才发现是 JSON 解析异常被吞了
async function fetchAndParse(url) {
  try {
    const res = await fetch(url);
    const text = await res.text(); // 假设接口返回了 502 HTML 页面
    return JSON.parse(text);       // ❌ 此时 JSON.parse 抛异常
  } catch (e) {
    // 注意:e 是字符串 '"Unexpected token < in JSON at position 0"'
    // 日志里只有 "JSON.parse 失败",没有原始响应内容
    console.error('JSON.parse 失败:', e.message);
    return null;
  }
}

原因分析

这个写法的实际问题是:当 JSON.parse 失败时,catch 确实捕获到了异常。但如果你在 catch 里没有记录上下文 (比如原始响应文本 text),排查时就只能看到"JSON.parse 失败",根本不知道接口返回了什么。

更隐蔽的问题:如果 JSON.parse 抛出的是非 Error 类型 (例如在某些 polyfill 或旧浏览器中),e.message 可能是 undefined,导致日志里连错误信息都没有。

解决方案

js 复制代码
// ✅ 方案一:细分 try-catch 并记录上下文
async function fetchAndParse(url) {
  let res, text;

  try {
    res = await fetch(url);
    text = await res.text();
    return JSON.parse(text);
  } catch (e) {
    console.error('请求/解析失败:', {
      url,
      status: res?.status,
      text: text?.slice(0, 500),   // 保存原始响应的前 500 字符
      error: e instanceof Error ? e.message : String(e)
    });
    return null;
  }
}

// ✅ 方案二:用 async/await 包裹 JSON.parse 单独 try
async function safeJsonParse(str) {
  try {
    return { ok: true, data: JSON.parse(str) };
  } catch (e) {
    return { ok: false, error: e, raw: str };
  }
}

场景五:catch 分支误以为一定会收到 Error 对象

js 复制代码
// ❌ 线上日志出现大量 "[object Object]" 堆栈缺失
async function risky() {
  // 模拟:某个 SDK 抛出的不是 Error
  throw { code: 403, message: 'Forbidden', details: { userId: 123 } };
}

try {
  await risky();
} catch (e) {
  // e 不是 Error 对象
  console.error('错误详情:', e);         // { code: 403, message: 'Forbidden', ... }
  console.error(e.stack);                // ❌ undefined
  console.error(e.message);              // ❌ undefined(注意不是 'Forbidden')
  sendToErrorMonitor(e);                 // ❌ 监控平台收不到有效 stack
}

原因分析

很多开发者的潜意识和 TypeScript 类型注解都暗示 catch 的形参一定是 Error 类型。但 JavaScript 中 throw 可以抛出任意类型 的值:对象、字符串、数字、甚至 undefinede.stacke.message 在这些情况下全部是 undefined

线上场景中:

  • 部分第三方 SDK 用 reject({ code, message }) 抛出对象
  • WebSocket 错误回调传的是 (event),不是 Error
  • 某些浏览器跨域脚本错误会抛 Script error.(字符串)

解决方案

js 复制代码
// ✅ 统一错误格式化
function normalizeError(err) {
  if (err instanceof Error) return err;

  // 对象类型:提取关键信息
  if (typeof err === 'object' && err !== null) {
    const msg = err.message || err.msg || err.code || '';
    const normalized = new Error(String(msg));
    normalized.original = err;
    return normalized;
  }

  // 字符串/数字/其他类型
  return new Error(String(err));
}

// 使用方式
try {
  await risky();
} catch (e) {
  const normalized = normalizeError(e);
  console.error('原始错误:', e);
  console.error('规范化错误:', normalized.message);
  console.error(normalized.stack);  // ✅ 一定有 stack
  sendToErrorMonitor(normalized);
}

场景六:finally 中 return 吞掉了 catch 的异常

js 复制代码
// ❌ 异常被静默吞掉,catch 形同虚设
async function process() {
  try {
    await doRiskyWork();
  } catch (e) {
    console.error('捕获到异常:', e);
    throw e;  // 重新抛出,期望调用方处理
  } finally {
    return 'fallback'; // ❌ finally 中的 return 覆盖了 throw
  }
}

const result = await process();
console.log(result); // 'fallback' --- 异常被悄无声息地吞掉了

原因分析

这是一个鲜为人知的 JavaScript 语言特性:无论在 try 中 return 还是在 catch 中 throw,最终函数的返回值都会被 finally 块中的 return 覆盖。这个行为在同步代码和 async 函数中同样生效。

线上场景:某个资源释放逻辑写在 finally 中,某天有人在 finally 末尾加了个 return 兜底返回值,结果所有异常都被吞了,调用方永远收不到错误信号。

解决方案

js 复制代码
// ✅ 方案一:finally 中不要 return
async function process() {
  try {
    return await doRiskyWork();
  } catch (e) {
    console.error('捕获到异常:', e);
    throw e;
  } finally {
    // ✅ 只做清理,不要 return
    await releaseResources();
    // 没有 return!
  }
}

// ✅ 方案二:如果确实需要 finally 返回值,明确语义
async function process() {
  let result;
  try {
    result = await doRiskyWork();
  } catch (e) {
    console.error('捕获到异常:', e);
    result = 'errorFallback';
  } finally {
    await releaseResources();
  }
  return result; // 在 finally 之外 return
}

要点总结

# 陷阱 一句话修复
1 async 函数外层未 await,catch 形同虚设 调用链末端挂 .catch() 或全局挂 unhandledrejection
2 forEach + await 吞异常 改用 for...ofPromise.allSettled
3 window.onerror 抓不到 async 错误 同时监听 unhandledrejection 事件
4 try-catch 中丢失上下文信息 在 catch 中记录原始响应及相关状态码
5 catch 假设 e 是 Error 对象 统一 normalizeError 函数规范化异常
6 finally 中 return 覆盖异常 finally 块中永远不要 return

核心原则:

  1. 始终在调用链最外层兜底,不要依赖"内部 catch 就能覆盖所有场景"
  2. 所有异常进入监控系统之前必须规范化为 Error 对象
  3. 记录异常时附带上尽可能多的上下文(URL、状态码、原始响应)

这六类问题我曾在不同项目线上排查中全部都遇到过,尤其是第 2 条 forEach 和第 6 条 finally,排查耗时最长,最后发现是语言层面的"反直觉"行为。希望你看完不用再踩同样的坑。

相关推荐
用户059540174461 小时前
向量库静默丢数据踩坑实录:Playwright 端到端测试让我排查了72小时
前端·css
Asize1 小时前
CSS 3D:从布局到立方体
前端
梨子同志1 小时前
React
前端
万少2 小时前
22 点后,我靠这个 AI 工具成了"夜间天才程序员"
前端·后端
狂师2 小时前
比 Playwright 更给力,推荐一个AI Agent的浏览器自动化开源项目!
前端·开源·测试
IT_陈寒2 小时前
React hooks 闭包陷阱把我的状态吃掉了,原来问题出在这里
前端·人工智能·后端
壹方秘境2 小时前
使用ApiCatcher在 iOS 上像修改 hosts 一样自定义域名解析
前端·后端·客户端
柳杉2 小时前
可视化大屏设计器脚手架:从设计到交付的一站式方案
前端·three.js·数据可视化
kyriewen16 小时前
我手写了一个 EventEmitter,面试官追问了 6 个问题——第 4 个我没答上来
前端·javascript·面试