错误处理与异常模式

What — 是什么

错误处理是 JavaScript 运行时对异常状况的捕获、传递与恢复机制。它包含内置 Error 对象体系、try/catch/finally 同步捕获、Promise 异步错误传播、全局错误兜底以及函数式 Result 模式等策略。

Error 对象体系:

错误类型触发时机示例
Error通用错误基类throw new Error('something')
SyntaxError代码解析阶段语法错误JSON.parse('{')
TypeError值的类型不符合预期undefined.foo
ReferenceError引用未声明的变量console.log(notExist)
RangeError值超出允许范围new Array(-1)
URIErrorURI 编解码参数非法decodeURI('%')

Error 实例核心属性:

const err = new Error('出错了');
console.log(err.name);    // 'Error'
console.log(err.message); // '出错了'
console.log(err.stack);   // 错误栈字符串,包含调用链和行号

自定义错误类:

class BusinessError extends Error {
  constructor(code, message, extra = {}) {
    super(message);
    this.name = 'BusinessError';
    this.code = code;       // 业务错误码,如 'AUTH_EXPIRED'
    this.extra = extra;     // 附加信息
    this.timestamp = Date.now();
    // 修复原型链(ES6 继承 Error 的已知问题)
    Object.setPrototypeOf(this, BusinessError.prototype);
  }
}

throw new BusinessError('AUTH_EXPIRED', '登录已过期', { userId: 123 });

try/catch/finally 基本结构:

try {
  // 可能抛出错误的代码
  riskyOperation();
} catch (error) {
  // 捕获并处理错误
  console.error(error.message);
} finally {
  // 无论是否出错都会执行(清理资源等)
  cleanup();
}

关键特性:

  • JavaScript 错误是”异常”(exception)机制:throw 抛出,catch 捕获,未捕获则沿调用栈向上冒泡
  • Error.stack 非标准但所有主流引擎都支持,V8/SpiderMonkey/JavaScriptCore 格式各异
  • try/catch 只能捕获同步错误,异步错误需用 Promise.catch 或全局事件捕获
  • ES2019 起 catch 绑定可省略:try { ... } catch { ... }

Why — 为什么

适用场景:

  • 网络请求失败、接口异常返回
  • 用户输入校验不通过
  • 第三方库抛出未知异常
  • 资源加载失败(图片、脚本、样式)
  • 异步任务超时或取消

不处理错误的后果:

  1. 用户体验差:白屏、功能无响应、无错误提示
  2. 调试困难:错误被静默吞掉,线上问题无法复现
  3. 数据丢失:未清理的状态导致后续逻辑混乱
  4. 安全隐患:错误信息泄露内部实现细节

核心价值:

  • 健壮性:防御性编程,保证应用在异常情况下仍能优雅降级
  • 可调试性:保留完整的错误栈和上下文信息
  • 可观测性:错误上报让线上问题可追踪、可量化
  • 可恢复性:重试、熔断、降级策略让系统自愈

对比替代方案:

方案优点缺点
try/catch同步代码直观、标准语法不能捕获异步错误、嵌套深
Promise.catch异步链式错误处理容易遗忘、链中间可能吞错
Result 模式无异常开销、类型安全需要额外封装、不标准
全局兜底兜住一切遗漏无法恢复上下文、信息有限

How — 怎么用

快速上手

// 1. 同步错误:try/catch
try {
  JSON.parse(badJson);
} catch (e) {
  console.error('JSON 解析失败:', e.message);
}

// 2. 异步错误:async/await + try/catch
async function fetchData(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (e) {
    console.error('请求失败:', e.message);
    return null; // 降级返回
  }
}

// 3. 全局兜底
window.addEventListener('unhandledrejection', (e) => {
  e.preventDefault();
  console.error('未处理的 Promise 异常:', e.reason);
});

代码示例

1. try/catch/finally 详解

// ===== 基本用法 =====
function divide(a, b) {
  try {
    if (b === 0) throw new RangeError('除数不能为零');
    return a / b;
  } catch (error) {
    if (error instanceof RangeError) {
      console.warn('范围错误:', error.message);
      return Infinity;
    }
    throw error; // 重新抛出不认识的错误
  } finally {
    console.log('计算结束'); // 始终执行
  }
}

// ===== finally 中的 return 陷阱 =====
function trickyReturn() {
  try {
    throw new Error('原始错误');
  } catch (e) {
    return '来自 catch';
  } finally {
    return '来自 finally'; // 会覆盖 catch 的 return!
  }
}
console.log(trickyReturn()); // '来自 finally'

// ===== finally 中 throw 也会覆盖 =====
function trickyThrow() {
  try {
    throw new Error('原始错误');
  } finally {
    throw new Error('finally 的错误'); // 覆盖原始错误
  }
}
try {
  trickyThrow();
} catch (e) {
  console.log(e.message); // 'finally 的错误',原始错误丢失!
}

// ===== ES2019 省略 catch 绑定 =====
try {
  dangerousOp();
} catch {
  // 不需要 error 对象时可以省略参数
  console.log('出错了,但不关心具体信息');
}

// ===== 嵌套 try/catch =====
function nestedError() {
  try {
    try {
      throw new Error('内层错误');
    } catch (inner) {
      console.log('内层捕获:', inner.message);
      throw inner; // 重新抛出,外层可继续捕获
    }
  } catch (outer) {
    console.log('外层捕获:', outer.message);
  }
}

2. 内置错误类型及触发场景

// ===== SyntaxError:解析阶段 =====
try {
  eval('const x ='); // 语法不完整
} catch (e) {
  console.log(e instanceof SyntaxError); // true
}

try {
  JSON.parse('{"name": "test",}'); // 尾逗号不合法
} catch (e) {
  console.log(e.name); // 'SyntaxError'
}

// ===== TypeError:类型操作错误 =====
try {
  undefined.foo; // 不能读取 undefined 的属性
} catch (e) {
  console.log(e instanceof TypeError); // true
}

try {
  null(); // null 不是函数
} catch (e) {
  console.log(e.name); // 'TypeError'
}

try {
  Object.defineProperty({}, 'x', { writable: false }).x = 1; // 严格模式下
} catch (e) {
  console.log(e.name); // 'TypeError'
}

// ===== ReferenceError:引用未声明变量 =====
try {
  console.log(notDeclaredVar);
} catch (e) {
  console.log(e instanceof ReferenceError); // true
}

// ===== RangeError:值超出范围 =====
try {
  new Array(-1);
} catch (e) {
  console.log(e.name); // 'RangeError'
}

try {
  'abc'.repeat(Infinity);
} catch (e) {
  console.log(e.name); // 'RangeError'
}

// ===== URIError:URI 编解码错误 =====
try {
  decodeURIComponent('%');
} catch (e) {
  console.log(e.name); // 'URIError'
}

// ===== 区分错误类型做不同处理 =====
function safeParse(json) {
  try {
    return JSON.parse(json);
  } catch (e) {
    if (e instanceof SyntaxError) {
      return { error: 'JSON 格式不合法', raw: json };
    }
    throw e; // 非预期错误重新抛出
  }
}

3. 自定义错误类

// ===== 基础自定义错误 =====
class AppError extends Error {
  constructor(message, options = {}) {
    super(message);
    this.name = 'AppError';
    this.code = options.code || 'UNKNOWN';
    this.statusCode = options.statusCode || 500;
    this.isOperational = options.isOperational ?? true; // 是否为可预期的业务错误
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

// ===== 分层错误体系 =====
class AuthError extends AppError {
  constructor(message, options = {}) {
    super(message, { ...options, code: options.code || 'AUTH_ERROR', statusCode: 401 });
    this.name = 'AuthError';
    Object.setPrototypeOf(this, AuthError.prototype);
  }
}

class NetworkError extends AppError {
  constructor(message, options = {}) {
    super(message, { ...options, code: options.code || 'NETWORK_ERROR', statusCode: options.statusCode || 503 });
    this.name = 'NetworkError';
    Object.setPrototypeOf(this, NetworkError.prototype);
  }
}

class ValidationError extends AppError {
  constructor(message, fields = {}, options = {}) {
    super(message, { ...options, code: options.code || 'VALIDATION_ERROR', statusCode: 400 });
    this.name = 'ValidationError';
    this.fields = fields; // { username: '用户名不能为空', email: '邮箱格式不正确' }
    Object.setPrototypeOf(this, ValidationError.prototype);
  }
}

// ===== 使用错误码枚举 =====
const ErrorCode = Object.freeze({
  AUTH_EXPIRED:       { code: 10001, message: '登录已过期' },
  AUTH_FORBIDDEN:     { code: 10002, message: '无访问权限' },
  USER_NOT_FOUND:     { code: 20001, message: '用户不存在' },
  USER_DUPLICATE:     { code: 20002, message: '用户已存在' },
  PARAM_INVALID:      { code: 30001, message: '参数校验失败' },
  NETWORK_TIMEOUT:    { code: 40001, message: '网络请求超时' },
  NETWORK_ERROR:      { code: 40002, message: '网络请求失败' },
  SERVER_ERROR:       { code: 50000, message: '服务器内部错误' },
});

function createError(key, overrides = {}) {
  const def = ErrorCode[key] || ErrorCode.SERVER_ERROR;
  const err = new AppError(overrides.message || def.message, {
    code: key,
    statusCode: overrides.statusCode,
  });
  err.errorCode = def.code;
  return err;
}

// ===== 统一错误处理中间件(类 Express 场景)=====
function errorHandler(err, req, res, next) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      code: err.code,
      message: err.message,
      ...(err instanceof ValidationError && { fields: err.fields }),
    });
  }
  // 未知错误,不暴露内部信息
  console.error('未预期错误:', err);
  res.status(500).json({ code: 'INTERNAL_ERROR', message: '服务器内部错误' });
}

4. 异步错误处理

// ===== Promise .catch() =====
fetch('/api/user')
  .then(res => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })
  .then(data => console.log(data))
  .catch(err => {
    console.error('请求失败:', err.message);
  });

// ===== async/await + try/catch =====
async function getUser(id) {
  try {
    const res = await fetch(`/api/user/${id}`);
    if (!res.ok) throw new NetworkError(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    if (err instanceof NetworkError) {
      return { error: true, message: err.message };
    }
    throw err;
  }
}

// ===== 封装安全的 async 包装器 =====
function to(promise) {
  return promise.then(
    data => [null, data],
    err => [err, null]
  );
}

// 使用:无需 try/catch
async function safeFetch() {
  const [err, data] = await to(fetch('/api/data').then(r => r.json()));
  if (err) {
    console.error('请求出错:', err);
    return null;
  }
  return data;
}

// ===== Promise 链中 catch 的位置影响 =====
// 错误场景1:catch 在 then 之前,后续 then 仍会执行
Promise.reject(new Error('fail'))
  .catch(err => {
    console.log('捕获到:', err.message);
    return '恢复值'; // catch 返回的值会被后续 then 接收
  })
  .then(val => {
    console.log('继续执行:', val); // '继续执行: 恢复值'
  });

// 错误场景2:catch 在最后,兜住所有错误
Promise.resolve()
  .then(() => { throw new Error('中间出错'); })
  .then(() => console.log('不会执行'))
  .catch(err => console.log('兜底:', err.message));

// ===== unhandledrejection 事件 =====
window.addEventListener('unhandledrejection', (event) => {
  event.preventDefault(); // 阻止控制台打印默认错误信息
  console.error('未处理的 Promise rejection:', event.reason);
  // 上报到监控系统
  reportError({
    type: 'unhandledrejection',
    reason: event.reason?.message || String(event.reason),
    stack: event.reason?.stack || '',
  });
});

// ===== 多个 await 的错误隔离 =====
async function multiOp() {
  // 方式1:每个操作独立 try/catch
  const [r1, r2, r3] = await Promise.allSettled([
    fetch('/api/a').then(r => r.json()),
    fetch('/api/b').then(r => r.json()),
    fetch('/api/c').then(r => r.json()),
  ]);

  const results = [r1, r2, r3].map((r, i) => {
    if (r.status === 'fulfilled') return r.value;
    console.error(`请求${i}失败:`, r.reason);
    return null; // 降级
  });

  return results;
}

5. 全局错误捕获

// ===== window.onerror(传统方式)=====
// 能捕获:同步错误、异步错误(setTimeout 等)
// 不能捕获:资源加载错误、Promise rejection
window.onerror = (message, source, lineno, colno, error) => {
  console.log('message:', message);   // 错误信息
  console.log('source:', source);     // 出错脚本 URL
  console.log('lineno:', lineno);     // 行号
  console.log('colno:', colno);       // 列号
  console.log('error:', error);       // Error 对象(可能为 null)

  reportError({ message, source, lineno, colno, stack: error?.stack });
  return true; // 返回 true 阻止浏览器默认错误处理
};

// ===== window.addEventListener('error')(推荐)=====
// 比 onerror 更灵活,可添加多个监听器
// 默认在冒泡阶段,无法捕获资源加载错误
window.addEventListener('error', (event) => {
  // event 包含完整信息
  console.log('全局错误:', event.message);
  console.log('错误对象:', event.error);
}, false);

// ===== 捕获阶段监听 — 可捕获资源加载错误 =====
window.addEventListener('error', (event) => {
  const target = event.target || event.srcElement;
  // 区分 JS 运行时错误 vs 资源加载错误
  if (target instanceof HTMLElement) {
    // 资源加载错误
    const tagName = target.tagName.toLowerCase();
    const src = target.src || target.href;
    console.error(`资源加载失败: <${tagName}> ${src}`);
    reportError({
      type: 'resource_error',
      tagName,
      src,
    });
  } else {
    // JS 运行时错误
    console.error('JS 运行时错误:', event.message);
    reportError({
      type: 'js_error',
      message: event.message,
      stack: event.error?.stack,
    });
  }
}, true); // 注意:捕获阶段 = true

// ===== window.addEventListener('unhandledrejection')=====
window.addEventListener('unhandledrejection', (event) => {
  console.error('未处理的 Promise 异常:', event.reason);
  event.preventDefault();
});

// ===== 完整的全局错误监控示例 =====
class ErrorMonitor {
  constructor(options = {}) {
    this.reportUrl = options.reportUrl || '/api/error';
    this.maxQueue = options.maxQueue || 10;
    this.queue = [];
    this._origOnError = window.onerror;
    this._origOnUnhandledRejection = window.onunhandledrejection;
  }

  install() {
    // JS 运行时错误
    window.addEventListener('error', (e) => this._handleJsError(e), false);

    // 资源加载错误(捕获阶段)
    window.addEventListener('error', (e) => this._handleResourceError(e), true);

    // Promise 未处理 rejection
    window.addEventListener('unhandledrejection', (e) => this._handleRejection(e));

    // 框架错误(如 Vue)
    if (window.Vue) {
      window.Vue.config.errorHandler = (err, vm, info) => {
        this._report({
          type: 'vue_error',
          message: err.message,
          stack: err.stack,
          componentName: vm?.$options?.name,
          info,
        });
      };
    }
  }

  _handleJsError(event) {
    if (event.target && event.target instanceof HTMLElement) return; // 跳过资源错误
    this._report({
      type: 'js_error',
      message: event.message,
      filename: event.filename,
      lineno: event.lineno,
      colno: event.colno,
      stack: event.error?.stack,
    });
  }

  _handleResourceError(event) {
    const target = event.target;
    if (!(target instanceof HTMLElement)) return;
    this._report({
      type: 'resource_error',
      tagName: target.tagName,
      src: target.src || target.href,
    });
  }

  _handleRejection(event) {
    event.preventDefault();
    const reason = event.reason;
    this._report({
      type: 'promise_rejection',
      message: reason?.message || String(reason),
      stack: reason?.stack,
    });
  }

  _report(data) {
    data.timestamp = Date.now();
    data.userAgent = navigator.userAgent;
    data.url = location.href;
    this.queue.push(data);
    if (this.queue.length >= this.maxQueue) {
      this._flush();
    }
  }

  _flush() {
    if (this.queue.length === 0) return;
    const batch = this.queue.splice(0);
    // 使用 sendBeacon 确保页面卸载时也能上报
    navigator.sendBeacon?.(this.reportUrl, JSON.stringify(batch))
      || fetch(this.reportUrl, { method: 'POST', body: JSON.stringify(batch), keepalive: true });
  }
}

// 使用
const monitor = new ErrorMonitor({ reportUrl: '/api/errors' });
monitor.install();

6. 错误边界模式

// ===== React ErrorBoundary 类组件 =====
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null, errorInfo: null };
  }

  static getDerivedStateFromError(error) {
    // 渲染阶段调用,不能有副作用
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // 提交阶段调用,可以执行副作用
    console.error('ErrorBoundary 捕获:', error, errorInfo);
    this.setState({ errorInfo });
    reportError({
      type: 'react_error_boundary',
      message: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack,
    });
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="error-fallback">
          <h2>组件渲染出错</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            重试
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// 使用:隔离风险组件
<ErrorBoundary fallback={<FallbackUI />}>
  <RiskyComponent />
</ErrorBoundary>

// ===== 安全区域隔离 — 非框架场景 =====
class SafeZone {
  constructor(name, onError) {
    this.name = name;
    this.onError = onError;
  }

  run(fn) {
    try {
      const result = fn();
      // 如果返回 Promise,自动追加 .catch
      if (result && typeof result.catch === 'function') {
        return result.catch(err => {
          this.onError(err, this.name);
          return null;
        });
      }
      return result;
    } catch (err) {
      this.onError(err, this.name);
      return null;
    }
  }

  async runAsync(fn) {
    try {
      return await fn();
    } catch (err) {
      this.onError(err, this.name);
      return null;
    }
  }
}

// 使用
const widgetZone = new SafeZone('widget', (err, zone) => {
  console.error(`[${zone}] 出错:`, err.message);
  // 降级:隐藏 widget,不影响主页面
  document.getElementById('widget-container')?.classList.add('hidden');
});

widgetZone.run(() => renderWidget()); // 同步
await widgetZone.runAsync(() => fetchWidgetData()); // 异步

// ===== 多层错误边界 =====
function App() {
  return (
    <ErrorBoundary fallback={<AppCrashPage />}>
      <Header />
      <ErrorBoundary fallback={<SidebarError />}>
        <Sidebar />
      </ErrorBoundary>
      <ErrorBoundary fallback={<MainContentError />}>
        <MainContent>
          <ErrorBoundary fallback={<WidgetError name="推荐" />}>
            <RecommendWidget />
          </ErrorBoundary>
          <ErrorBoundary fallback={<WidgetError name="评论" />}>
            <CommentWidget />
          </ErrorBoundary>
        </MainContent>
      </ErrorBoundary>
      <Footer />
    </ErrorBoundary>
  );
}

7. Result 模式(函数式错误处理)

// ===== Result 类型定义 =====
class Result {
  constructor(val, err, isSuccess) {
    this._val = val;
    this._err = err;
    this._isSuccess = isSuccess;
  }

  static ok(value) {
    return new Result(value, null, true);
  }

  static fail(error) {
    return new Result(null, error, false);
  }

  get isOk() { return this._isSuccess; }
  get isFail() { return !this._isSuccess; }

  get value() {
    if (!this._isSuccess) throw new Error('Cannot get value from Fail result');
    return this._val;
  }

  get error() {
    if (this._isSuccess) throw new Error('Cannot get error from Ok result');
    return this._err;
  }

  // map:转换成功值
  map(fn) {
    return this._isSuccess
      ? Result.ok(fn(this._val))
      : this;
  }

  // mapErr:转换错误
  mapErr(fn) {
    return this._isSuccess
      ? this
      : Result.fail(fn(this._err));
  }

  // flatMap / andThen:链式操作,可短路
  flatMap(fn) {
    return this._isSuccess
      ? fn(this._val)
      : this;
  }

  // match:模式匹配
  match(onOk, onFail) {
    return this._isSuccess
      ? onOk(this._val)
      : onFail(this._err);
  }

  // getOrElse:获取值或默认值
  getOrElse(defaultVal) {
    return this._isSuccess ? this._val : defaultVal;
  }

  // orElse:失败时提供备选 Result
  orElse(fn) {
    return this._isSuccess ? this : fn(this._err);
  }

  // unwrap:获取值,失败时抛出
  unwrap() {
    if (this._isSuccess) return this._val;
    throw this._err;
  }
}

// ===== 使用 Result 替代 throw =====
function divide(a, b) {
  if (b === 0) return Result.fail(new RangeError('除数不能为零'));
  return Result.ok(a / b);
}

const result = divide(10, 2)
  .map(v => v * 100)
  .flatMap(v => divide(v, 50));

result.match(
  value => console.log('结果:', value),   // 结果: 10
  error => console.error('出错:', error.message)
);

// ===== 封装可能会抛错的函数 =====
function safeExec(fn) {
  try {
    return Result.ok(fn());
  } catch (e) {
    return Result.fail(e);
  }
}

async function safeAsync(fn) {
  try {
    return Result.ok(await fn());
  } catch (e) {
    return Result.fail(e);
  }
}

// 使用
const parsed = safeExec(() => JSON.parse('{"name":"test"}'));
parsed.match(
  data => console.log('解析成功:', data),
  err => console.error('解析失败:', err.message)
);

// ===== 业务校验链 =====
function validateUser(input) {
  return Result.ok(input)
    .flatMap(data =>
      data.name ? Result.ok(data) : Result.fail(new ValidationError('用户名不能为空'))
    )
    .flatMap(data =>
      data.email?.includes('@') ? Result.ok(data) : Result.fail(new ValidationError('邮箱格式不正确'))
    )
    .flatMap(data =>
      data.age >= 18 ? Result.ok(data) : Result.fail(new ValidationError('年龄必须满18岁'))
    );
}

const v1 = validateUser({ name: 'Tom', email: 'tom@test.com', age: 20 });
console.log(v1.isOk); // true

const v2 = validateUser({ name: '', email: 'bad', age: 15 });
console.log(v2.isFail); // true
console.log(v2.error.message); // '用户名不能为空'

8. 重试与熔断模式

// ===== 指数退避重试 =====
async function retry(fn, options = {}) {
  const {
    maxRetries = 3,
    baseDelay = 1000,
    maxDelay = 30000,
    factor = 2,
    jitter = true,          // 抖动:防止所有客户端同时重试
    retryableCheck = null,  // 判断错误是否可重试
    onRetry = null,         // 每次重试的回调
  } = options;

  let lastError;
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;

      // 最后一次不重试
      if (attempt >= maxRetries) break;

      // 检查错误是否可重试
      if (retryableCheck && !retryableCheck(err)) break;

      // 计算退避延迟
      let delay = Math.min(baseDelay * Math.pow(factor, attempt), maxDelay);
      if (jitter) {
        delay = delay * (0.5 + Math.random() * 0.5); // 0.5x ~ 1x 随机抖动
      }

      onRetry?.({ attempt: attempt + 1, delay, error: err });
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  throw lastError;
}

// 使用
const data = await retry(
  () => fetch('/api/data').then(r => {
    if (r.status >= 500) throw new NetworkError(`服务器错误: ${r.status}`);
    if (!r.ok) throw new AppError(`请求失败: ${r.status}`);
    return r.json();
  }),
  {
    maxRetries: 3,
    baseDelay: 500,
    retryableCheck: (err) => err instanceof NetworkError, // 仅网络错误重试
    onRetry: ({ attempt, delay }) => console.log(`第${attempt}次重试,${delay}ms 后执行`),
  }
);

// ===== 熔断器模式 =====
class CircuitBreaker {
  static STATE = { CLOSED: 'CLOSED', OPEN: 'OPEN', HALF_OPEN: 'HALF_OPEN' };

  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;     // 连续失败次数阈值
    this.resetTimeout = options.resetTimeout || 30000;         // 熔断后等待时间(ms)
    this.halfOpenMaxCalls = options.halfOpenMaxCalls || 1;     // 半开状态最大试探次数

    this.state = CircuitBreaker.STATE.CLOSED;
    this.failureCount = 0;
    this.successCount = 0;
    this.lastFailureTime = null;
    this.halfOpenCalls = 0;
  }

  async exec(fn) {
    // OPEN 状态:直接拒绝
    if (this.state === CircuitBreaker.STATE.OPEN) {
      if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
        this.state = CircuitBreaker.STATE.HALF_OPEN;
        this.halfOpenCalls = 0;
      } else {
        throw new AppError('熔断器开启,请求被拒绝', { code: 'CIRCUIT_OPEN' });
      }
    }

    // HALF_OPEN 状态:限制试探次数
    if (this.state === CircuitBreaker.STATE.HALF_OPEN) {
      if (this.halfOpenCalls >= this.halfOpenMaxCalls) {
        throw new AppError('熔断器半开,试探请求已满', { code: 'CIRCUIT_HALF_OPEN' });
      }
      this.halfOpenCalls++;
    }

    try {
      const result = await fn();
      this._onSuccess();
      return result;
    } catch (err) {
      this._onFailure();
      throw err;
    }
  }

  _onSuccess() {
    this.failureCount = 0;
    if (this.state === CircuitBreaker.STATE.HALF_OPEN) {
      this.state = CircuitBreaker.STATE.CLOSED; // 试探成功,恢复
    }
  }

  _onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    if (this.state === CircuitBreaker.STATE.HALF_OPEN) {
      this.state = CircuitBreaker.STATE.OPEN; // 试探失败,继续熔断
    } else if (this.failureCount >= this.failureThreshold) {
      this.state = CircuitBreaker.STATE.OPEN; // 连续失败达到阈值,熔断
    }
  }

  getStats() {
    return {
      state: this.state,
      failureCount: this.failureCount,
      lastFailureTime: this.lastFailureTime,
    };
  }
}

// 使用
const breaker = new CircuitBreaker({ failureThreshold: 3, resetTimeout: 10000 });

async function fetchWithBreaker(url) {
  try {
    return await breaker.exec(() => fetch(url).then(r => {
      if (!r.ok) throw new NetworkError(`HTTP ${r.status}`);
      return r.json();
    }));
  } catch (err) {
    if (err.code === 'CIRCUIT_OPEN') {
      // 降级:返回缓存数据
      return getCachedData(url);
    }
    throw err;
  }
}

9. 错误上报与 Source Map 反解

// ===== 错误栈解析 =====
function parseStack(error) {
  if (!error?.stack) return [];
  return error.stack
    .split('\n')
    .slice(1) // 跳过第一行(错误信息)
    .map(line => {
      // Chrome 格式: "    at functionName (http://example.com/bundle.js:1:100)"
      // Firefox 格式: "functionName@http://example.com/bundle.js:1:100"
      const chromeMatch = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/);
      const firefoxMatch = line.match(/(.+?)@(.+?):(\d+):(\d+)/);

      const match = chromeMatch || firefoxMatch;
      if (!match) return null;

      const fnName = match[1];
      const file = match[2];
      const line = parseInt(match[3], 10);
      const col = parseInt(match[4], 10);

      return { fnName, file, line, col };
    })
    .filter(Boolean);
}

// ===== Source Map 反解 =====
// 注意:source-map 库需要在构建时生成 .map 文件
// npm install source-map

async function resolveOriginalPosition(stackFrame, sourceMapConsumer) {
  const original = sourceMapConsumer.originalPositionFor({
    line: stackFrame.line,
    column: stackFrame.col,
  });

  return {
    source: original.source,    // 原始文件路径
    line: original.line,        // 原始行号
    column: original.column,    // 原始列号
    name: original.name,        // 原始函数名
  };
}

// 完整流程示例
async function resolveErrorStack(error) {
  const frames = parseStack(error);
  // 服务端:读取 source map 文件
  // const sourceMapContent = fs.readFileSync('./dist/bundle.js.map', 'utf-8');
  // const consumer = await new sourceMap.SourceMapConsumer(sourceMapContent);

  // const resolved = await Promise.all(
  //   frames.map(frame => resolveOriginalPosition(frame, consumer))
  // );
  // return resolved;

  // 前端:不上报 source map,只上报行列号,服务端反解
  return frames.map(f => ({
    file: f.file,
    line: f.line,
    col: f.col,
    fnName: f.fnName,
  }));
}

// ===== 上报策略 =====
class ErrorReporter {
  constructor(options = {}) {
    this.endpoint = options.endpoint || '/api/errors';
    this.appVersion = options.appVersion || '1.0.0';
    this.batchSize = options.batchSize || 5;
    this.batchInterval = options.batchInterval || 5000;
    this.queue = [];
    this._timer = null;
  }

  report(errorData) {
    const payload = {
      ...errorData,
      appVersion: this.appVersion,
      timestamp: Date.now(),
      url: location.href,
      userAgent: navigator.userAgent,
    };

    this.queue.push(payload);

    // 达到批量大小立即上报
    if (this.queue.length >= this.batchSize) {
      this._flush();
    } else if (!this._timer) {
      // 定时批量上报
      this._timer = setTimeout(() => this._flush(), this.batchInterval);
    }
  }

  _flush() {
    clearTimeout(this._timer);
    this._timer = null;

    if (this.queue.length === 0) return;

    const batch = this.queue.splice(0);

    // 优先 sendBeacon(页面卸载时也能发送)
    if (navigator.sendBeacon) {
      const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
      navigator.sendBeacon(this.endpoint, blob);
    } else {
      // 降级 fetch + keepalive
      fetch(this.endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(batch),
        keepalive: true,
      }).catch(() => {
        // 上报本身失败,放回队列下次重试
        this.queue.unshift(...batch);
      });
    }
  }
}

10. 资源加载错误

// ===== 方式1:捕获阶段监听 window error 事件 =====
window.addEventListener('error', (event) => {
  const target = event.target;
  if (!target || !(target instanceof HTMLElement)) return;

  const tagName = target.tagName.toLowerCase();
  if (['img', 'script', 'link', 'video', 'audio', 'source'].includes(tagName)) {
    const src = target.src || target.href;
    console.error(`资源加载失败: <${tagName}> ${src}`);

    reportError({
      type: 'resource_error',
      tagName,
      src,
      id: target.id,
      className: target.className,
    });
  }
}, true); // 必须在捕获阶段!冒泡阶段监听不到

// ===== 方式2:元素 onerror 属性 =====
// <img src="photo.jpg" onerror="this.src='fallback.jpg'; this.onerror=null;">

// 动态创建的图片
function loadImage(url, fallbackUrl) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = () => {
      if (fallbackUrl) {
        img.src = fallbackUrl;
        img.onerror = null; // 防止 fallback 也失败死循环
        resolve(img);
      } else {
        reject(new Error(`图片加载失败: ${url}`));
      }
    };
    img.src = url;
  });
}

// ===== 方式3:动态脚本加载错误 =====
function loadScript(src, options = {}) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = src;
    if (options.crossOrigin) script.crossOrigin = 'anonymous';
    script.onload = () => resolve(script);
    script.onerror = () => reject(new Error(`脚本加载失败: ${src}`));
    document.head.appendChild(script);
  });
}

// 注意:跨域脚本需要设置 crossorigin 属性 + 服务器返回 CORS 头
// 否则 window.onerror 只能得到 'Script error.' 无具体信息
// <script src="https://cdn.example.com/lib.js" crossorigin="anonymous"></script>

// ===== 资源加载重试 =====
async function loadResourceWithRetry(loadFn, src, retries = 2) {
  for (let i = 0; i <= retries; i++) {
    try {
      return await loadFn(src);
    } catch (err) {
      if (i === retries) throw err;
      console.warn(`资源加载失败,第${i + 1}次重试: ${src}`);
      await new Promise(r => setTimeout(r, 1000 * (i + 1)));
    }
  }
}

// 使用
await loadResourceWithRetry(loadScript, 'https://cdn.example.com/analytics.js', 2);
await loadResourceWithRetry(loadImage, 'https://cdn.example.com/banner.png', 1);

常见问题与踩坑

1. async 函数中 catch 丢失

// 错误写法:忘记 await,catch 捕不到异步错误
async function bad() {
  try {
    fetchData(); // 忘记 await!错误不会被 try/catch 捕获
  } catch (e) {
    console.log('捕获不到');
  }
}

// 正确写法
async function good() {
  try {
    await fetchData(); // 加上 await
  } catch (e) {
    console.log('捕获到了:', e.message);
  }
}

2. Promise 链中 catch 位置导致错误被吞

// 问题:中间的 catch 让后续 then 执行了
Promise.reject(new Error('fail'))
  .catch(() => console.log('捕获'))  // 捕获后返回 undefined
  .then(val => console.log('继续:', val)); // 继续: undefined

// 解决:需要区分"已恢复"和"继续报错"
Promise.reject(new Error('fail'))
  .catch(err => {
    console.log('记录错误:', err.message);
    throw err; // 重新抛出,让后续 catch 处理
  })
  .then(() => console.log('不会执行'))
  .catch(err => console.log('最终处理:', err.message));

3. finally 对返回值的影响

// finally 中的 return 会覆盖 try/catch 的返回值
function foo() {
  try {
    return 1;
  } finally {
    return 2; // 覆盖了 try 的 return 1
  }
}
console.log(foo()); // 2

// finally 中没有 return 时,不影响返回值
function bar() {
  try {
    return 1;
  } finally {
    console.log('finally'); // 执行但不影响返回值
  }
}
console.log(bar()); // 1

// finally 中 throw 也会覆盖
function baz() {
  try {
    throw new Error('原始');
  } finally {
    throw new Error('finally的'); // 原始错误丢失!
  }
}

4. window.onerror vs addEventListener(‘error’)

// 区别1:onerror 只能注册一个,后注册覆盖前面的
window.onerror = handler1;
window.onerror = handler2; // handler1 被覆盖

// addEventListener 可以注册多个
window.addEventListener('error', handler1);
window.addEventListener('error', handler2); // 两个都执行

// 区别2:onerror 参数解构,addEventListener 是 ErrorEvent 对象
window.onerror = (msg, source, line, col, error) => { ... };
window.addEventListener('error', (event) => {
  // event.message, event.filename, event.lineno, event.colno, event.error
});

// 区别3:资源加载错误
// onerror 无法捕获资源加载错误
// addEventListener 在捕获阶段可以捕获

5. 跨域脚本的 ‘Script error.’

// 问题:跨域脚本出错,window.onerror 只收到 'Script error.'
// 无文件名、行号等详细信息

// 解决方案:
// 1. 脚本标签加 crossorigin 属性
// <script src="https://other.com/lib.js" crossorigin="anonymous"></script>
// 2. 服务器返回 Access-Control-Allow-Origin 响应头
// 3. 同域部署(最可靠)

// 如果无法修改脚本标签,可用 try/catch 包裹调用
try {
  thirdPartyLib.method();
} catch (e) {
  // 此处可获取完整错误信息
  console.error(e.stack);
}

6. forEach 中的错误不会被外部 try/catch 捕获

// forEach 回调中的 throw 会穿透到外部
try {
  [1, 2, 3].forEach(item => {
    if (item === 2) throw new Error('找到了');
  });
} catch (e) {
  console.log('能捕获:', e.message); // '找到了'
}

// 但异步回调中的错误捕获不到
try {
  [1, 2, 3].forEach(async item => {
    await new Promise(r => setTimeout(r, 100));
    if (item === 2) throw new Error('异步错误');
  });
} catch (e) {
  console.log('捕获不到');
}
// 需要用 for...of + await

最佳实践

  1. 永远处理 Promise 错误:每个 Promise 链末尾必须有 .catch(),每个 async 函数调用必须有 try/catch 或上层捕获
  2. 自定义错误分层:业务错误(AppError)与系统错误(TypeError/ReferenceError)分开处理,业务错误可展示给用户,系统错误上报即可
  3. 不要吞掉错误:catch 块里至少要 console.error 或上报,空 catch 是隐患
  4. finally 中避免 return/throw:除非明确要覆盖,否则只在 finally 中做清理操作
  5. 重试需限定条件:仅对幂等且可能临时失败的操作重试(网络错误、5xx),不要对 4xx 或业务逻辑错误重试
  6. 全局兜底不可或缺:window.error + unhandledrejection 双重保险,捕获遗漏的异常
  7. 错误上报用 sendBeacon:确保页面卸载时错误也能上报
  8. Source Map 不上线:构建产物部署时剥离 .map 文件,服务端保存用于反解,防止源码泄露
  9. 资源加载加 onerror:关键资源(图片、脚本)加上 onerror 回退或降级逻辑
  10. 区分可恢复与不可恢复错误:可恢复的用重试/降级,不可恢复的展示错误页面并上报

面试题(8题)

1. try/catch 能否捕获异步错误?为什么?

不能。try/catch 是同步的,执行到 setTimeout/Promise 时,异步回调被放入任务队列,try/catch 块已经执行完毕退出,异步回调抛出的错误不在当前 try/catch 的作用域内。要捕获异步错误,需在回调内部 try/catch,或使用 Promise 的 .catch(),或 async/await + try/catch

// 捕获不到
try {
  setTimeout(() => { throw new Error('异步错误'); }, 0);
} catch (e) {
  console.log('捕获不到');
}

// 正确方式1:回调内 try/catch
setTimeout(() => {
  try { throw new Error('异步错误'); }
  catch (e) { console.log('捕获:', e.message); }
}, 0);

// 正确方式2:async/await
async function foo() {
  try {
    await delay(0);
    throw new Error('异步错误');
  } catch (e) {
    console.log('捕获:', e.message);
  }
}

2. Promise 错误传播机制是怎样的?

Promise 链中,任何一环抛出错误(throw 或 reject),都会跳过后续的 .then(),直接到达最近的 .catch().catch() 处理后返回的值会被后续 .then() 当作正常值接收。如果没有任何 .catch() 处理,错误会变成 unhandled rejection。

Promise.resolve()
  .then(() => { throw new Error('step1 error'); })
  .then(() => console.log('跳过'))        // 被跳过
  .catch(e => { console.log(e.message); return 'recovered'; })
  .then(v => console.log('继续:', v));    // 继续: recovered

3. 前端全局错误捕获有哪些方式?各自能捕获什么?

方式JS 运行时错误资源加载错误Promise rejection
window.onerror不能不能
addEventListener('error', fn, false)能(冒泡)不能不能
addEventListener('error', fn, true)能(捕获)不能
addEventListener('unhandledrejection')不能不能

资源加载错误只在捕获阶段能监听到,因为资源元素不会向上冒泡 error 事件。要完整覆盖所有错误类型,需要同时注册 addEventListener('error', handler, true)addEventListener('unhandledrejection', handler)

4. 自定义错误类的关键要点有哪些?

三个要点:

① 继承 Error 并修复原型链:ES6 的 class extends Error 在某些引擎中 instanceof 可能失效,需手动 Object.setPrototypeOf(this, CustomError.prototype)

② 设置 name 属性:Error 的默认 name 是 'Error',自定义类需要在构造函数中设置 this.name = 'CustomError'

③ 添加业务属性:如 errorCode、statusCode、isOperational 等,便于上层做分类处理。

class BizError extends Error {
  constructor(code, message) {
    super(message);
    this.name = 'BizError';
    this.code = code;
    Object.setPrototypeOf(this, BizError.prototype); // 修复原型链
  }
}

const e = new BizError('NOT_FOUND', '资源不存在');
console.log(e instanceof BizError); // true(没有 setPrototypeOf 可能是 false)
console.log(e.name);  // 'BizError'
console.log(e.code);  // 'NOT_FOUND'

5. unhandledrejection 事件的用途和注意事项?

unhandledrejection 在 Promise 被 reject 但没有 .catch() 处理时触发。用途:全局兜底捕获遗漏的 Promise 错误,防止错误静默丢失。

注意事项:

  • 事件对象的 event.reason 是 reject 的值(通常是 Error 对象)
  • 调用 event.preventDefault() 可阻止控制台打印默认错误
  • 注册时机要早,否则可能错过早期 Promise 的 rejection
  • 某些环境下 event.reason 可能不是 Error(如 Promise.reject('string')),需要类型判断
window.addEventListener('unhandledrejection', (event) => {
  event.preventDefault();
  const reason = event.reason;
  const message = reason instanceof Error ? reason.message : String(reason);
  const stack = reason instanceof Error ? reason.stack : '';
  reportError({ type: 'unhandledrejection', message, stack });
});

6. finally 中 throw 会怎样?会覆盖原错误吗?

会。finally 中的 throw 会覆盖 try 或 catch 中抛出的原始错误,原始错误信息将丢失。同理,finally 中的 return 也会覆盖 try 或 catch 的返回值。这是 JavaScript 的语言规范行为。

function example() {
  try {
    throw new Error('原始错误A');
  } catch (e) {
    throw new Error('catch中的错误B');
  } finally {
    throw new Error('finally的错误C'); // 覆盖错误B!
  }
}

try { example(); } catch (e) {
  console.log(e.message); // 'finally的错误C',错误B丢失
}

// 最佳实践:finally 中只做清理,不要 return 或 throw
function good() {
  let resource;
  try {
    resource = acquire();
    return process(resource);
  } finally {
    resource?.close(); // 仅清理
  }
}

7. 如何捕获图片、脚本等资源加载错误?

三种方式:

捕获阶段监听 window error 事件(推荐,可统一捕获所有资源):

window.addEventListener('error', (e) => {
  const target = e.target;
  if (target instanceof HTMLImageElement || target instanceof HTMLScriptElement) {
    console.error(`资源加载失败: ${target.src || target.href}`);
  }
}, true); // 必须是捕获阶段

元素 onerror 属性(适合少量关键资源):

<img src="photo.jpg" onerror="this.onerror=null;this.src='fallback.jpg'">

动态创建元素时绑定 onerror(适合 JS 动态加载):

const img = new Image();
img.onerror = () => { /* 处理 */ };
img.src = url;

注意:跨域脚本需要加 crossorigin="anonymous" 属性且服务器需返回 CORS 头,否则 error 事件中获取不到详细信息。

8. 错误上报时如何进行 Source Map 反解?

流程:

  1. 构建时生成 Source Map:Webpack/Vite 配置 devtool: 'source-map',产出 .js.map 文件
  2. 部署时剥离 .map 文件:生产环境不上传 .map 到 CDN,保存在构建服务器
  3. 前端上报原始错误栈:只上报压缩后的行列号和文件名
  4. 服务端反解:根据文件名找到对应 .map 文件,使用 source-map 库还原原始位置
// 服务端反解(Node.js)
const { SourceMapConsumer } = require('source-map');
const fs = require('fs');

async function resolveError(filename, line, column) {
  const mapContent = fs.readFileSync(`./maps/${filename}.map`, 'utf-8');
  const consumer = await new SourceMapConsumer(mapContent);
  const original = consumer.originalPositionFor({ line, column });
  // original = { source: 'src/utils/format.js', line: 42, column: 15, name: 'formatDate' }
  return original;
}

关键点:

  • .map 文件绝不部署到线上,防止源码泄露
  • 使用 sendBeacon 上报,确保页面关闭时也能发送
  • 批量上报减少请求次数
  • 上报信息包含 appVersion,用于匹配对应版本的 .map 文件

相关链接: