超过 70% 的线上 Bug 与错误处理不当有关——不是没 catch,而是 catch 之后的处理逻辑有问题。据 Sentry 2025 年度报告,生产环境中最常见的 JavaScript 错误类型是 TypeError: Cannot read properties of undefined,占比高达 34%,而其中绝大多数都可以通过合理的错误处理架构避免。错误处理不是写个 try-catch 就完事的「边角料」,它是系统可靠性的基石。本文将从工程视角出发,拆解 JavaScript/TypeScript 中错误处理的常见反模式,介绍自定义错误体系设计、Result 模式、异步错误处理策略,以及生产环境中的全局兜底机制——所有代码均可直接运行。
🔐 一、错误处理的三大反模式与正确姿势
大多数开发者的错误处理停留在「能跑就行」的阶段。以下三个反模式几乎存在于每个项目中。
1.1 反模式一:空 catch 吞掉错误
这是最危险的错误处理方式——catch 块里什么都没有,错误被悄悄吞掉,程序继续以不确定的状态运行。
❌ 错误写法:
// 错误被吞掉,调试时完全无迹可寻
try {
const data = JSON.parse(userInput);
processOrder(data);
} catch (e) {
// 什么都没做,程序继续运行
}
✅ 正确写法:
// 至少要记录错误,最好给用户反馈
try {
const data = JSON.parse(userInput);
processOrder(data);
} catch (error) {
console.error('[OrderService] Failed to parse input:', {
input: userInput.slice(0, 100), // 截断避免日志过大
error: error.message,
stack: error.stack,
});
throw new AppError('INVALID_ORDER_INPUT', '订单数据格式错误,请检查后重试');
}
⚠️ **警告:**永远不要写空的 catch 块。如果你确实需要忽略某个错误,至少加一行注释说明原因,并记录到日志中。
1.2 反模式二:捕获太宽泛
用 catch (e) 捕获所有异常,然后用同一个逻辑处理——这会导致本该让程序崩溃的编程错误(如 ReferenceError)也被静默处理。
❌ 错误写法:
// 太宽泛,连编程错误都被捕获了
try {
riskyOperation();
} catch (e) {
// 把所有错误都当成"可恢复"的错误处理
showNotification('操作失败,请重试');
}
✅ 正确写法:
// 精确捕获,区分可恢复错误和编程错误
try {
riskyOperation();
} catch (error) {
if (error instanceof ValidationError) {
// 可恢复:用户输入错误,提示修改
showNotification(error.userMessage);
} else if (error instanceof NetworkError) {
// 可恢复:网络问题,可以重试
showRetryDialog(() => riskyOperation());
} else {
// 不可恢复:编程错误,上报并崩溃
reportToSentry(error);
showNotification('系统异常,请联系管理员');
}
}
1.3 反模式三:在 catch 中重新抛出原始错误
catch 之后又 throw error,丢失了上下文信息,导致调试时只能看到一个模糊的错误堆栈。
❌ 错误写法:
async function loadUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data;
} catch (error) {
throw error; // 丢失了 "加载用户档案" 这个上下文
}
}
✅ 正确写法:
async function loadUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new NetworkError(`HTTP ${response.status}`, {
url: response.url,
status: response.status,
});
}
return await response.json();
} catch (error) {
// 包装错误,保留上下文
throw new AppError('PROFILE_LOAD_FAILED', `加载用户档案失败 (userId: ${userId})`, {
cause: error,
userId,
});
}
}
💡 提示: ES2022 引入了
Error.cause属性(所有现代浏览器和 Node.js 16.9+ 都支持),用于在包装错误时保留原始错误。这是构建错误链的标准方式。
🚀 二、构建自定义错误体系
一个生产级项目需要一套结构化的错误体系,而不是到处 throw new Error('xxx')。
2.1 基础错误类层次设计
// 基础应用错误类 - 所有业务错误的父类
class AppError extends Error {
constructor(code, message, options = {}) {
super(message, { cause: options.cause });
this.name = 'AppError';
this.code = code; // 机器可读的错误码
this.userMessage = options.userMessage || message; // 用户友好的消息
this.context = options.context || {}; // 附加上下文
this.retryable = options.retryable ?? false; // 是否可重试
this.statusCode = options.statusCode || 500; // HTTP 状态码
}
toJSON() {
return {
name: this.name,
code: this.code,
message: this.message,
userMessage: this.userMessage,
retryable: this.retryable,
statusCode: this.statusCode,
context: this.context,
stack: this.stack,
cause: this.cause?.message,
};
}
}
// 验证错误 - 用户输入问题,通常可恢复
class ValidationError extends AppError {
constructor(message, fields = {}) {
super('VALIDATION_ERROR', message, {
userMessage: message,
statusCode: 400,
retryable: false,
context: { fields },
});
this.name = 'ValidationError';
this.fields = fields; // { fieldName: errorMessage }
}
}
// 网络错误 - 通常可重试
class NetworkError extends AppError {
constructor(message, details = {}) {
super('NETWORK_ERROR', message, {
userMessage: '网络连接异常,请检查网络后重试',
statusCode: details.status || 502,
retryable: true,
context: details,
});
this.name = 'NetworkError';
}
}
// 认证错误 - 需要重新登录
class AuthError extends AppError {
constructor(message = '认证已过期,请重新登录') {
super('AUTH_ERROR', message, {
userMessage: message,
statusCode: 401,
retryable: false,
});
this.name = 'AuthError';
}
}
// 限流错误 - 可重试但需要等待
class RateLimitError extends AppError {
constructor(retryAfter = 60) {
super('RATE_LIMIT', `请求过于频繁,请 ${retryAfter} 秒后重试`, {
userMessage: `操作太频繁了,请等待 ${retryAfter} 秒后再试`,
statusCode: 429,
retryable: true,
context: { retryAfter },
});
this.name = 'RateLimitError';
this.retryAfter = retryAfter;
}
}
2.2 错误码体系设计
一个好的错误码体系能让前端、后端、运维和客服用同一套语言沟通问题。
| 错误码前缀 | 含义 | 示例 | 可重试 |
|---|---|---|---|
VALIDATION_ |
输入校验 | VALIDATION_EMAIL_FORMAT |
❌ |
AUTH_ |
认证授权 | AUTH_TOKEN_EXPIRED |
❌ |
NETWORK_ |
网络通信 | NETWORK_TIMEOUT |
✅ |
RATE_ |
限流 | RATE_LIMIT_EXCEEDED |
✅ |
RESOURCE_ |
资源问题 | RESOURCE_NOT_FOUND |
❌ |
BUSINESS_ |
业务逻辑 | BUSINESS_INSUFFICIENT_BALANCE |
❌ |
SYSTEM_ |
系统内部 | SYSTEM_DB_CONNECTION |
✅ |
📌 **记住:**错误码是给机器用的(用于监控告警和自动化处理),错误消息是给开发者看的(用于调试),用户友好的消息是给终端用户看的(用于展示)。三者不要混用。
2.3 工厂函数简化错误创建
// 错误工厂 - 统一创建错误的入口
const Errors = {
validation(message, fields) {
return new ValidationError(message, fields);
},
network(message, details) {
return new NetworkError(message, details);
},
auth(message) {
return new AuthError(message);
},
rateLimit(retryAfter) {
return new RateLimitError(retryAfter);
},
notFound(resource, id) {
return new AppError('RESOURCE_NOT_FOUND', `${resource} 不存在 (id: ${id})`, {
userMessage: `请求的${resource}不存在`,
statusCode: 404,
context: { resource, id },
});
},
business(code, message, userMessage) {
return new AppError(code, message, {
userMessage: userMessage || message,
statusCode: 422,
context: {},
});
},
};
// 使用示例
function withdraw(amount, balance) {
if (amount <= 0) {
throw Errors.validation('提现金额必须大于 0', { amount: '金额必须为正数' });
}
if (amount > balance) {
throw Errors.business(
'BUSINESS_INSUFFICIENT_BALANCE',
`余额不足: 需要 ${amount}, 当前 ${balance}`,
'账户余额不足,请充值后再试'
);
}
// ...
}
💡 三、Result 模式:用类型代替异常
在函数式编程中,有一种更优雅的错误处理方式——Result 模式(也叫 Either 模式)。核心思想是:函数不抛出异常,而是返回一个包含成功或失败结果的值,让调用方强制处理错误情况。
3.1 基础 Result 实现
// Result 类型 - 成功或失败,二选一
class Result {
constructor(ok, value, error) {
this.ok = ok;
this._value = value;
this._error = error;
}
// 创建成功结果
static ok(value) {
return new Result(true, value, null);
}
// 创建失败结果
static err(error) {
return new Result(false, null, error);
}
// 从 Promise 创建 Result
static async from(promise) {
try {
const value = await promise;
return Result.ok(value);
} catch (error) {
return Result.err(error);
}
}
// 是否成功
isOk() {
return this.ok;
}
// 是否失败
isErr() {
return !this.ok;
}
// 获取值(失败时抛出错误)
unwrap() {
if (this.ok) return this._value;
throw this._error;
}
// 获取值(失败时返回默认值)
unwrapOr(defaultValue) {
return this.ok ? this._value : defaultValue;
}
// 获取错误
unwrapErr() {
if (!this.ok) return this._error;
throw new Error('Called unwrapErr on an Ok value');
}
// 链式转换(成功时)
map(fn) {
if (this.ok) return Result.ok(fn(this._value));
return this;
}
// 链式转换(失败时)
mapErr(fn) {
if (!this.ok) return Result.err(fn(this._error));
return this;
}
// 链式操作(返回 Result 的函数)
flatMap(fn) {
if (this.ok) return fn(this._value);
return this;
}
// 匹配模式
match({ ok, err }) {
return this.ok ? ok(this._value) : err(this._error);
}
}
3.2 Result 模式实战:API 调用链
// 使用 Result 封装 API 调用
async function fetchUser(userId) {
const result = await Result.from(
fetch(`/api/users/${userId}`).then(res => {
if (!res.ok) throw new NetworkError(`HTTP ${res.status}`);
return res.json();
})
);
return result.mapErr(error =>
new AppError('USER_FETCH_FAILED', `获取用户失败: ${error.message}`, {
cause: error,
userId,
})
);
}
// 使用 Result 封装数据解析
function parseUserData(raw) {
try {
if (!raw.name || typeof raw.name !== 'string') {
return Result.err(new ValidationError('用户名格式错误', { name: '必填且为字符串' }));
}
if (!raw.email || !raw.email.includes('@')) {
return Result.err(new ValidationError('邮箱格式错误', { email: '请输入有效邮箱' }));
}
return Result.ok({
name: raw.name.trim(),
email: raw.email.toLowerCase(),
createdAt: new Date(raw.createdAt || Date.now()),
});
} catch (error) {
return Result.err(error);
}
}
// 链式调用:获取用户 -> 解析数据 -> 更新状态
async function loadAndProcessUser(userId) {
const userResult = await fetchUser(userId);
const processed = userResult
.flatMap(user => parseUserData(user))
.map(user => ({ ...user, displayName: user.name.slice(0, 20) }));
processed.match({
ok: (user) => {
console.log('✅ 用户加载成功:', user.displayName);
return user;
},
err: (error) => {
console.error('❌ 处理失败:', error.userMessage || error.message);
if (error.retryable) {
console.log('🔄 该错误可重试');
}
},
});
return processed;
}
⚡ 关键结论:Result 模式的核心优势是让调用方无法忽略错误。与 try-catch 不同,如果你不处理 Result 的 err 分支,TypeScript 类型检查会在编译时提醒你。这对于构建可靠的系统至关重要。
3.3 TypeScript 类型安全的 Result
在 TypeScript 中,Result 模式的优势更加明显——你可以用联合类型(Union Type)让编译器帮你检查错误处理:
// TypeScript 版 Result - 类型安全
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// 类型安全的 API 调用
async function fetchUserTS(userId: string): Promise<Result<User, AppError>> {
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
return err(new NetworkError(`HTTP ${res.status}`));
}
const data: User = await res.json();
return ok(data);
} catch (e) {
return err(new AppError('FETCH_FAILED', String(e)));
}
}
// 调用方必须处理两种情况,否则 TypeScript 报错
async function displayUser(userId: string) {
const result = await fetchUserTS(userId);
if (result.ok) {
// TypeScript 知道这里 result.value 是 User 类型
console.log(result.value.name);
} else {
// TypeScript 知道这里 result.error 是 AppError 类型
console.error(result.error.userMessage);
}
}
⚠️ 四、异步错误处理:Promise.allSettled 与全局兜底
异步代码的错误处理比同步代码复杂得多。一个未捕获的 Promise rejection 可能导致整个 Node.js 进程崩溃。
4.1 批量异步操作的错误处理
// 场景:同时加载多个资源,部分失败不应影响其他
async function loadDashboardData(userId) {
// ❌ 错误做法:一个失败全部失败
// const [user, orders, notifications] = await Promise.all([
// fetchUser(userId),
// fetchOrders(userId),
// fetchNotifications(userId),
// ]);
// ✅ 正确做法:独立处理每个请求的结果
const results = await Promise.allSettled([
fetchUser(userId),
fetchOrders(userId),
fetchNotifications(userId),
]);
const dashboard = {
user: null,
orders: [],
notifications: [],
errors: [],
};
// 逐个处理结果
const [userResult, ordersResult, notificationsResult] = results;
if (userResult.status === 'fulfilled') {
dashboard.user = userResult.value;
} else {
dashboard.errors.push({ source: 'user', error: userResult.reason });
console.error('[Dashboard] Failed to load user:', userResult.reason);
}
if (ordersResult.status === 'fulfilled') {
dashboard.orders = ordersResult.value;
} else {
dashboard.errors.push({ source: 'orders', error: ordersResult.reason });
console.error('[Dashboard] Failed to load orders:', ordersResult.reason);
}
if (notificationsResult.status === 'fulfilled') {
dashboard.notifications = notificationsResult.value;
} else {
dashboard.errors.push({ source: 'notifications', error: notificationsResult.reason });
console.error('[Dashboard] Failed to load notifications:', notificationsResult.reason);
}
return dashboard;
}
4.2 带重试的异步错误处理
// 通用重试函数 - 支持指数退避
async function withRetry(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000, // 基础延迟 1 秒
maxDelay = 10000, // 最大延迟 10 秒
retryOn = () => true, // 默认所有错误都重试
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn(attempt);
} catch (error) {
lastError = error;
// 判断是否应该重试
if (attempt >= maxRetries || !retryOn(error)) {
throw error;
}
// 指数退避 + 随机抖动
const delay = Math.min(
baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
maxDelay
);
console.warn(
`[Retry] Attempt ${attempt + 1}/${maxRetries} failed, ` +
`retrying in ${Math.round(delay)}ms: ${error.message}`
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
// 使用示例:只对网络错误和 5xx 错误重试
async function fetchWithRetry(url) {
return withRetry(
async () => {
const response = await fetch(url);
if (!response.ok) {
const error = new NetworkError(`HTTP ${response.status}`, { status: response.status });
error.retryable = response.status >= 500;
throw error;
}
return response.json();
},
{
maxRetries: 3,
retryOn: (error) => error.retryable === true,
}
);
}
4.3 Node.js 全局错误兜底
在 Node.js 生产环境中,必须设置全局错误处理器,防止未捕获的异常导致进程静默退出。
// Node.js 全局错误处理 - 放在应用入口文件的最顶部
import { captureException } from './monitoring.js'; // 你的监控 SDK
// 1. 未捕获的同步异常
process.on('uncaughtException', (error, origin) => {
console.error(`[FATAL] Uncaught Exception (${origin}):`, error);
captureException(error, { origin, type: 'uncaughtException' });
// 给监控系统 5 秒上报时间,然后退出
setTimeout(() => {
process.exit(1);
}, 5000);
});
// 2. 未处理的 Promise rejection(Node.js 15+ 默认会导致进程退出)
process.on('unhandledRejection', (reason, promise) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
console.error('[ERROR] Unhandled Rejection:', error);
captureException(error, { type: 'unhandledRejection' });
// 不要 exit,让进程继续运行,但记录到监控
});
// 3. 前端(浏览器)全局错误兜底
if (typeof window !== 'undefined') {
// 同步错误
window.addEventListener('error', (event) => {
console.error('[Global Error]', event.error);
captureException(event.error, {
type: 'window.onerror',
filename: event.filename,
lineno: event.lineno,
});
});
// 未处理的 Promise rejection
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
console.error('[Unhandled Rejection]', error);
captureException(error, { type: 'unhandledrejection' });
});
}
⚠️ 警告:在浏览器中,
unhandledrejection事件的event.preventDefault()可以阻止控制台报错,但不推荐这样做——你应该修复 Promise 链,而不是隐藏问题。
📊 五、错误处理方案对比
| 方案 | 类型安全 | 强制处理 | 调试友好 | 学习成本 | 推荐场景 |
|---|---|---|---|---|---|
| try-catch | ❌ | ❌ | ✅ | 低 | 简单脚本、一次性代码 |
| 自定义错误类 + try-catch | ⚠️ | ❌ | ✅ | 中 | 大多数 Web 应用 |
| Result 模式 | ✅ | ✅ | ⚠️ | 高 | 关键业务逻辑、库/框架 |
| Effect-TS (函数式) | ✅ | ✅ | ⚠️ | 很高 | 大型函数式项目 |
| 错误码 + 全局处理器 | ⚠️ | ❌ | ✅ | 低 | 后端 API 服务 |
⚡ 关键结论:对于大多数 Web 项目,推荐自定义错误类 + try-catch + 全局兜底的组合。Result 模式适合对可靠性要求极高的核心业务逻辑(如支付、数据迁移),但不建议在整个项目中全面使用——它的学习曲线和代码量会让团队效率下降。
🔧 六、生产环境错误监控集成
错误处理的最后一步是将错误上报到监控系统。以下是与 Sentry 集成的最佳实践:
// 错误上报工具 - 支持上下文和面包屑
class ErrorReporter {
constructor(options = {}) {
this.dsn = options.dsn;
this.environment = options.environment || 'production';
this.breadcrumbs = [];
this.maxBreadcrumbs = options.maxBreadcrumbs || 50;
}
// 添加面包屑(记录用户操作路径,帮助复现错误)
addBreadcrumb(category, message, data = {}) {
this.breadcrumbs.push({
category,
message,
data,
timestamp: Date.now(),
});
// 限制面包屑数量
if (this.breadcrumbs.length > this.maxBreadcrumbs) {
this.breadcrumbs.shift();
}
}
// 上报错误
captureException(error, extra = {}) {
const errorEvent = {
message: error.message,
name: error.name,
code: error.code,
stack: error.stack,
environment: this.environment,
breadcrumbs: this.breadcrumbs.slice(),
extra: {
...extra,
...error.context,
},
tags: {
retryable: String(error.retryable || false),
statusCode: String(error.statusCode || 'unknown'),
},
timestamp: new Date().toISOString(),
};
// 过滤掉不应上报的错误
if (this.shouldIgnore(error)) return;
// 发送到监控系统
this.send(errorEvent);
}
shouldIgnore(error) {
// 忽略浏览器扩展导致的错误
if (error.stack?.includes('chrome-extension://')) return true;
// 忽略网络取消
if (error.name === 'AbortError') return true;
// 忽略用户脚本注入的错误
if (error.message?.includes('Script error')) return true;
return false;
}
async send(event) {
try {
// 实际项目中替换为 Sentry SDK 或自己的上报接口
console.log('[ErrorReporter] Sending:', event.message);
// await fetch(this.dsn, {
// method: 'POST',
// body: JSON.stringify(event),
// headers: { 'Content-Type': 'application/json' },
// });
} catch (e) {
// 上报本身不能抛错
console.error('[ErrorReporter] Failed to send:', e);
}
}
}
// 全局初始化
const reporter = new ErrorReporter({
environment: process.env.NODE_ENV,
dsn: 'https://your-sentry-dsn.com/api/report',
});
// 在关键操作前添加面包屑
reporter.addBreadcrumb('navigation', 'User navigated to checkout');
reporter.addBreadcrumb('api', 'POST /api/orders', { itemCount: 3 });
✅ 总结与最佳实践清单
错误处理是软件工程中最容易被忽视、却影响最深远的环节。以下是核心建议:
- ✅ 建立统一的错误类体系 — 用
AppError作为基类,按业务域派生子类 - ✅ 错误码、错误消息、用户消息三者分离 — 给机器、开发者、用户各看各的
- ✅ 用
Error.cause构建错误链 — 保留完整的错误上下文 - ✅ 批量异步操作用
Promise.allSettled— 部分失败不应影响全局 - ✅ 关键操作加重试机制 — 指数退避 + 可配置的重试策略
- ✅ 设置全局错误兜底 —
uncaughtException/unhandledrejection - ✅ 上报监控系统 — 包含面包屑、上下文、错误码
- ❌ 不要写空 catch 块 — 至少记录日志
- ❌ 不要捕获太宽泛 — 区分可恢复错误和编程错误
- ❌ 不要在 catch 中丢失上下文 — 包装错误而不是直接 rethrow
💡 **提示:**如果你在用 TypeScript,开启
strict模式和useUnknownInCatchVariables选项,编译器会强制你检查 catch 中的错误类型,避免error.message在unknown类型上报错。
相关工具推荐:
- 🔧 Sentry — 生产环境错误监控的行业标准
- 🔧 TypeScript strict 模式 — 编译时错误检查
- 🔧 Effect-TS — 函数式错误处理框架(适合大型项目)
- 🔧 Zod — 运行时数据校验,与 Result 模式配合使用
- 🔧 neverthrow — TypeScript 原生 Result 类型库