JavaScript/TypeScript 错误处理工程指南:从 try-catch 到 Result 模式的生产级实践

深入解析 JavaScript 和 TypeScript 中的错误处理最佳实践,涵盖自定义错误体系、Result 模式、异步错误处理、全局兜底机制,附完整可运行代码和生产环境避坑指南。

前端开发 2026-06-12 15 分钟

超过 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.messageunknown 类型上报错。


相关工具推荐:

  • 🔧 Sentry — 生产环境错误监控的行业标准
  • 🔧 TypeScript strict 模式 — 编译时错误检查
  • 🔧 Effect-TS — 函数式错误处理框架(适合大型项目)
  • 🔧 Zod — 运行时数据校验,与 Result 模式配合使用
  • 🔧 neverthrow — TypeScript 原生 Result 类型库

📚 相关文章