错误处理与调试

张玥 2026年9月20日 阅读时间 30分钟
JavaScript 错误处理 调试 DevTools 监控
前端开发技巧之错误处理与调试

写代码的时间里有相当一部分并不是在“写”,而是在“找”——找那个让页面白屏的变量、找那个偶发失败的请求、找那个只在 iOS Safari 上出现的诡异偏移。很多人把调试理解成“出问题了才做的事”,但真正拉开工程师差距的,恰恰是把错误当作一等公民来设计:在写第一行代码的时候就想清楚它会怎么失败、失败之后谁知道、知道之后怎么办。本篇 30 分钟长文,从错误对象的解剖开始,依次走过同步错误、异步错误、全局兜底、监控上报、调试工具箱,最后落到一次完整的线上白屏排查复盘。文中嵌入了五个可以真手上手的互动实验台——错误类型探测、执行顺序追踪、异步陷阱模拟、错误指纹聚合、控制台沙盒——建议边读边点,把“知道”变成“见过”。

一、先改变观念:错误不是“异常情况”

“异常”这个词翻译得有点误导。它让人以为错误是偏离常态的少数派,只要代码写得足够好就不会出现。但在前端这个运行环境里,事实恰好相反:

  • 用户的网络会断,会慢,会在请求发出去的瞬间切到地铁隧道;
  • 后端会返回你文档里没写的字段,会返回 null,会在凌晨发版;
  • 浏览器有五个主流内核、上百个版本,还有各种 App 内置 WebView 和“极速模式”;
  • 用户会双击按钮、会在输入框里粘贴一张图片、会把手机系统时间改成 1970 年;
  • 第三方 SDK 会在你不知情的时候往 window 上挂东西。

换句话说,错误是常态,不是意外。一段从未被错误路径走过的代码,和一段没有写过的代码,可靠性上是等价的。

三种处理错误的态度

最差 掩盖:空的 catch {}、catch (e) { return null },把错误吞掉当作没发生
及格 记录:console.error(e),知道出错了但没人看见,线上依旧无从下手
优秀 管理:分类、降级、上报、告警、复盘,错误成为可观测系统的一部分

错误的四种来源

不同类型的问题,处理方式完全不同。点击下面的卡片展开:

1
语法错误
SyntaxError
代码根本没跑起来。整段脚本被解析器拒绝,一个字符的错误就能让整个文件失效。特点是:容易发现、影响面大、只能在开发阶段解决,线上出现基本意味着构建流程有问题。
2
运行时错误
Runtime
代码跑起来了,但执行到某一行炸了。TypeError、ReferenceError、RangeError 都属于这一类。它是前端监控里最常见的错误类型,也是本文后续重点讨论的对象。
3
逻辑错误
Logic
代码没报错,但结果不对。金额算错了、排序反了、分页少了一条。这类错误没有任何堆栈信息,只能靠单元测试、断言和数据比对来发现,是最难排查的一类。
4
资源 / 网络
Network
脚本加载 404、图片加载失败、接口超时、跨域被拦。注意:fetch 只在网络层失败时才 reject,HTTP 4xx / 5xx 是不会 reject 的,必须手动检查 res.ok,这是新手最容易踩的坑之一。

错误处理的目标

把“处理错误”拆解开,其实是四件不同的事,很多人把它们混在一起,导致每件事都没做好:

目标 要回答的问题 典型手段
不崩 出错后界面还能用吗? try/catch、错误边界、降级 UI
可查 出错了我怎么知道在哪? 保留堆栈、带上业务上下文
可知 线上出错了谁第一时间知道? 监控上报、聚合、告警
可复现 我怎么在本地把它重现出来? Source Map、用户行为轨迹、环境快照
值得坚持的习惯
  • 每个 catch 都要么处理、要么重新抛出、要么上报
  • 抛错误时带上足够的上下文(哪条数据、哪个接口、哪个状态)
  • 对外部数据永远保持怀疑:接口返回、localStorage、URL 参数
  • 失败路径和成功路径一样要有测试
常见的错误处理反模式
  • try { ... } catch (e) {}——静默吞掉一切
  • catch (e) { console.log(e) }——只打印 message,丢掉堆栈
  • catch (e) { throw new Error('操作失败') }——新错误吃掉了原始堆栈
  • 用 return false 表示失败——调用方根本不知道发生了什么

二、认识错误对象:Error 的解剖

在能处理错误之前,得先知道错误对象里有什么。Error 实例上有四个值得关注的成员:

成员 含义 使用建议
name 错误类型名,如 TypeError 用于分类统计与告警规则
message 人类可读的描述 用于展示与检索,不要用它做逻辑判断
stack 调用栈快照 排查的第一手资料,绝不能丢
cause 造成本次错误的上游错误 错误链式传递的最佳实践(ES2022)

动手:错误类型探测台

下面这个探测台会真正执行会出错的代码,然后把捕获到的错误对象展开给你看。点击不同的按钮,对比它们的 name、message 和堆栈第一帧有什么不同:

内置错误类型速查

类型 触发场景 典型例子
TypeError 值不是预期的类型 读 null 的属性、把非函数当函数调用
ReferenceError 引用了不存在的变量 拼错变量名、TDZ 中访问 let
RangeError 数值超出允许范围 new Array(-1)、递归爆栈
SyntaxError 代码无法被解析 JSON.parse 收到非法字符串
URIError URI 编解码参数非法 decodeURIComponent('%')
AggregateError 多个错误打包 Promise.any 全部失败

自定义错误:让错误会说人话

原生错误的信息量往往不够。一个 TypeError: Cannot read properties of undefined 无法告诉你“是哪个用户、在哪一步、点了什么”。自定义错误类解决的正是这个问题:

class AppError extends Error {
  constructor(message, { code, meta, cause } = {}) {
    super(message, { cause }); // cause 会挂到 this.cause 上
    this.name = 'AppError';
    this.code = code ?? 'UNKNOWN';
    this.meta = meta ?? {};

    // 让堆栈从"抛出点"开始,而不是从 constructor 开始
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, AppError);
    }
  }

  toJSON() {
    return {
      name: this.name,
      code: this.code,
      message: this.message,
      meta: this.meta,
    };
  }
}

// 领域化的子类,一眼看懂是哪一层出的问题
class NetworkError extends AppError {
  constructor(message, options) {
    super(message, { ...options, code: options?.code ?? 'NETWORK' });
    this.name = 'NetworkError';
    this.retryable = true; // 告诉上层:这个错可以重试
  }
}

class BusinessError extends AppError {
  constructor(message, options) {
    super(message, { ...options, code: options?.code ?? 'BUSINESS' });
    this.name = 'BusinessError';
    this.retryable = false; // 业务规则不允许,重试也没用
  }
}

加上 retryable 这个字段之后,重试逻辑就变得非常干净:

async function withRetry(fn, times = 3) {
  for (let i = 0; i < times; i++) {
    try {
      return await fn();
    } catch (err) {
      // 不可重试的错误直接抛出,不做无谓的等待
      if (err.retryable === false || i === times - 1) throw err;
      await sleep(300 * 2 ** i); // 指数退避
    }
  }
}

用 cause 保留错误链

重构时最常见的破坏性写法,就是把底层错误包装成一个新错误却丢掉原始信息:

// ✗ 原始堆栈被吃掉了
try {
  await saveOrder(data);
} catch (e) {
  throw new Error('下单失败');
}

// 控制台只会显示 "下单失败"
// 真正出问题的 saveOrder 完全看不到了
// ✓ 用 cause 串起错误链
try {
  await saveOrder(data);
} catch (e) {
  throw new BusinessError('下单失败', {
    code: 'ORDER_SAVE',
    meta: { orderId: data.id },
    cause: e,
  });
}

// 控制台会展开 [cause]: TypeError ...
// 完整链路一目了然

三、同步错误处理:try / catch / finally 的真实规则

try/catch 看起来简单,但它的行为细节比多数人以为的要多。这些细节决定了你写出来的是“错误处理”还是“错误掩盖”。

规则一:catch 的绑定是块级作用域

catch (err) 里的 err 只在花括号内可见。这本来是为了兼容旧引擎的设计,但今天它反而成了一个优点——你可以在外层用同样的名字:

const err = '外层的变量';
try {
  throw new Error('内层的错误');
} catch (err) {
  console.log(err.message); // "内层的错误"
}
console.log(err); // "外层的变量"

规则二:不需要绑定参数时可以省略

如果你确实不关心错误对象(比如只是想在解析失败时走一个降级分支),ES2019 起可以完全省略括号:

let config = {};
try {
  config = JSON.parse(localStorage.getItem('config'));
} catch {
  config = {}; // 本地缓存坏了,用默认值兜底
}

// 注意:省略绑定时你无法上报错误,
// 所以只适合"确实不需要知道原因"的场景

规则三:finally 一定会执行

这是 finally 存在的全部意义:无论 try 里是正常返回、抛出错误,还是被 return 提前结束,finally 都会执行。

动手:执行顺序追踪

点击下面的按钮,逐步观察一次“try 中抛错”的执行轨迹。注意第四步——try 中错误之后的代码会被整体跳过:

规则四:finally 里的 return 会吃掉一切

这是 finally 最危险的行为,也是代码评审里最容易被忽略的一类 bug:

function getValue() {
  try {
    return '来自 try';
  } finally {
    return '来自 finally'; // ✗ try 的返回值被静默丢弃
  }
}
getValue(); // "来自 finally"

function throwError() {
  try {
    throw new Error('真正的错误');
  } finally {
    return '正常值'; // ✗ 错误被彻底吞掉,调用方毫不知情
  }
}
throwError(); // "正常值",没有抛错

结论:finally 里永远不要写 return。它几乎总是 bug。清理逻辑放在 finally 里,返回值放在 try 或 catch 里。

规则五:catch 里要不要重新抛出

一个朴素的判断标准是:如果你不能真正处理这个错误,就不要捕获它。

值得捕获
  • 有明确的降级方案(缓存坏了就用默认配置)
  • 需要补充上下文后再抛出(cause 链)
  • 需要清理资源(关闭连接、移除 loading 状态)
  • 需要把错误转换为业务可识别的类型
不该捕获
  • 只是为了打印一行日志
  • 只是为了“让代码不报错”
  • 捕获之后什么也不做
  • 用来做流程控制(用 try 判断变量是否存在)

规则六:try 块要尽可能小

把几十行代码全塞进一个 try 里,会导致两个后果:你无法知道错误具体来自哪一行;你也不知道哪些错误是“预期的”。更好的写法是只包住真正可能失败的那一句:

// ✗ 大 try 块:错误来源模糊
try {
  const user = await fetchUser(id);
  const orders = await fetchOrders(user.id);
  const total = orders.reduce(calcTotal, 0);
  render(total);
} catch (e) {
  // 是接口挂了?还是 calcTotal 写错了?
  // 还是 render 里 DOM 不存在?完全不知道
}
// ✓ 小 try 块:每处失败都有交代
const user = await fetchUser(id).catch((e) => {
  throw new NetworkError('获取用户失败', {
    meta: { id }, cause: e,
  });
});

const orders = await fetchOrders(user.id).catch((e) => {
  reportError(e, { step: 'fetchOrders' });
  return []; // 订单拿不到不算致命,降级为空列表
});

const total = orders.reduce(calcTotal, 0);
render(total); // 纯函数与渲染,不该失败

规则七:用断言把逻辑错误变成运行时错误

逻辑错误最难查,因为它不报错。一个有效的技巧是主动把不可能的情况变成异常,让它在开发阶段就暴露出来:

function assert(condition, message) {
  if (!condition) {
    throw new Error(`断言失败:${message}`);
  }
}

function calcDiscount(price, rate) {
  assert(typeof price === 'number' && !Number.isNaN(price), 'price 必须是数字');
  assert(rate >= 0 && rate <= 1, `折扣率越界:${rate}`);

  return price * (1 - rate);
}

calcDiscount(100, 1.5);
// Error: 断言失败:折扣率越界:1.5
// 如果不加断言,这里会静默返回 -50,一路带到结算页

生产环境里可以把 assert 变成空函数,用构建工具在打包时剔除,做到零成本。但开发环境一定要开着——让 bug 在你面前爆炸,好过让它在用户面前爆炸。

四、异步世界的错误:三个时代的三种写法

异步是前端错误处理的主战场。同步代码里 try/catch 是万能的,但到了异步世界,它的能力边界会突然收缩——很多新手困惑的“为什么我 catch 不到”,根源都在这里。

4.1 回调时代:error-first 约定

Node.js 确立了“错误优先回调”的约定:回调的第一个参数是错误对象,没出错时传 null。这个约定的价值是把错误变成显式的、无法忽略的参数:

function readConfig(path, callback) {
  fs.readFile(path, 'utf8', (err, data) => {
    if (err) {
      callback(err); // 第一个参数传错误
      return; // 别忘了 return,否则会继续往下执行
    }
    try {
      const config = JSON.parse(data);
      callback(null, config);
    } catch (e) {
      callback(e); // 解析错误也要通过第一个参数传出
    }
  });
}

回调的问题也很明显:嵌套一深,错误处理就变成了灾难,每一层都要写一遍 if (err) return callback(err),任何一层漏掉,错误就会凭空消失。

4.2 Promise 时代:rejection 的传播规则

Promise 把错误处理从“每个回调都要检查”变成了“一条链上的统一捕获”。理解它只需要记住三条规则:

规则一 链上任何位置抛出的错误,都会跳到最近的一个 catch
规则二 catch 之后如果返回值,链条会恢复正常,后续 then 照常执行
规则三 catch 里再次抛出,错误会继续往后传给下一个 catch
致命 链尾没有 catch,就会变成 unhandledrejection,没人知道
// 三个 catch 的分工
fetchUser(id)
  .then((user) => fetchOrders(user.id))
  .then((orders) => render(orders))
  .catch((err) => {
    // 兜底:任何一环出错都会到这里
    reportError(err);
    renderErrorState();
  })
  .finally(() => {
    hideLoading(); // 无论成功失败都要收掉 loading
  });

// 需要分别处理时,可以在中间插 catch,但要记得重新抛
fetchUser(id)
  .catch((err) => {
    // 只处理"用户不存在",其他错误继续往上抛
    if (err.code === 'NOT_FOUND') return null;
    throw err; // 别忘了这一步
  })
  .then((user) => { ... });

有一个特别隐蔽的坑:在 then 里嵌套了一个新的 Promise,却没有 return 它。这时候外层链条完全不知道内层存在,内层的失败会变成无人处理的 rejection:

// ✗ 忘记 return,错误逃逸
fetchUser(id).then((user) => {
  fetchOrders(user.id).then((orders) => {
    render(orders);
  });
}).catch((e) => {
  // fetchOrders 失败时不会走到这里!
});
// ✓ return 出去,错误回到主链
fetchUser(id).then((user) => {
  return fetchOrders(user.id).then((orders) => {
    render(orders);
  });
}).catch((e) => {
  // 现在两处失败都会到这里
});

4.3 async / await:回到 try/catch,但有新坑

async/await 让异步错误重新可以用 try/catch 处理,这是它最大的价值之一。但有几个陷阱必须知道。

动手:异步错误陷阱实验室

点击下面五个场景,看看每个写法的实际结果。这些输出是根据 JavaScript 的真实行为模拟的,请仔细对比“你以为的”和“实际发生的”:

4.4 forEach 为什么不能用 await

这是最高频的异步陷阱。Array.prototype.forEach 接收的同步回调会被立即执行完,它不会等待你的 async 函数:

// ✗ forEach 不会等待,错误也没人接
async function saveAll(items) {
  items.forEach(async (item) => {
    await save(item); // 主流程不会等它
  });
  console.log('全部完成'); // ✗ 会先打印这一句
}

// ✓ 方案一:顺序执行(有依赖关系时用)
async function saveAll(items) {
  for (const item of items) {
    await save(item);
  }
  console.log('全部完成');
}

// ✓ 方案二:并行执行(无依赖时用,更快)
async function saveAll(items) {
  await Promise.all(items.map((item) => save(item)));
  console.log('全部完成');
}

// ✓ 方案三:并行但要全部结果,不因单个失败中断
const results = await Promise.allSettled(
  items.map((item) => save(item))
);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length) reportError(failed);

4.5 Promise 家族的失败语义

API 失败时行为 适用场景
Promise.all 任一失败立即 reject,其他结果丢弃 所有数据都必需,缺一不可
Promise.allSettled 永不 reject,返回每个的成功/失败状态 批量操作,允许部分失败
Promise.race 第一个 settled 的结果(可能是失败) 超时控制
Promise.any 第一个成功即成功,全失败才 reject 多源容灾、CDN 竞速

一个常见的误区是拿 Promise.all 去做“批量上传”这类任务。只要有一张图上传失败,整个批次就全部作废,而前面已经成功的文件其实白传了。这种场景应该用 allSettled:

async function uploadAll(files) {
  const results = await Promise.allSettled(
    files.map((file) => upload(file).then(
      (url) => ({ ok: true, file, url }),
      (err) => ({ ok: false, file, err })
    ))
  );

const succeeded = results.filter((r) => r.value.ok);
const failed = results.filter((r) => !r.value.ok);

if (failed.length) {
  toast(`${succeeded.length} 个成功,${failed.length} 个失败`);
  reportError(failed.map((f) => f.value.err));
}
return succeeded;
}

4.6 超时与取消:AbortController

“请求永远不返回”是前端最常见的问题之一。给异步操作加超时,可以让错误从“未知”变成“明确的 TimeoutError”:

function withTimeout(promise, ms, label = '操作') {
  let timer;

  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => {
      reject(new NetworkError(`${label}超时(${ms}ms)`, {
        code: 'TIMEOUT',
      }));
    }, ms);
  });

  return Promise.race([promise, timeout])
    .finally(() => clearTimeout(timer)); // 无论谁先完成都要清掉定时器
}

// 用 AbortController 真正做到"取消请求",而不只是忽略结果
async function search(keyword, signal) {
  const res = await fetch(`/api/search?q=${keyword}`, { signal });
  return res.json();
}

let controller;
function onInput(e) {
  controller?.abort(); // 取消上一次请求
  controller = new AbortController();
  search(e.target.value, controller.signal).catch((err) => {
    if (err.name === 'AbortError') return; // 主动取消,不算错误
    showError(err);
  });
}

注意最后那个 AbortError 的判断。“用户主动取消”不应该被上报成错误,否则你的错误看板会被搜索框的每一次输入刷屏。同理,fetch 在页面卸载时被中止产生的错误,也应该被过滤掉。

五、全局兜底:最后一道防线

局部处理能覆盖你预料到的错误,全局兜底负责接住你没想到的那些。它们不是替代品,而是最后一道防线。

5.1 window.onerror 与 error 事件

window.onerror 是最经典的方式,但它有几个重要限制:

// 传统写法:注意参数顺序很容易记混
window.onerror = function (message, source, lineno, colno, error) {
  reportError(error ?? new Error(message), {
    source, lineno, colno,
  });
  return true; // 返回 true 可以阻止默认的控制台输出
};

// 现代写法:可以在捕获阶段拿到资源加载错误
window.addEventListener('error', (event) => {
  // 资源加载失败(img / script / link)走的是这里
  if (event.target && event.target !== window) {
    const tag = event.target.tagName;
    reportError(new Error(`${tag} 加载失败`), {
      src: event.target.src ?? event.target.href,
      type: 'resource',
    });
    return;
  }

  reportError(event.error ?? new Error(event.message), {
    type: 'runtime',
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
  });
}, true); // ← 第三个参数 true 是关键

这里有一个非常容易被忽略的细节:资源加载错误不会冒泡,所以必须用捕获阶段(第三个参数 true)才能监听到。而运行时错误是在 window 上触发的,冒泡和捕获都能收到。

5.2 unhandledrejection:Promise 的兜底网

没有 catch 的 Promise rejection 不会触发 error 事件,它走的是另一个事件:

window.addEventListener('unhandledrejection', (event) => {
  const reason = event.reason;

  // reason 不一定是 Error,可能是字符串、对象甚至 undefined
  const error = reason instanceof Error
    ? reason
    : new Error(`Unhandled rejection: ${JSON.stringify(reason)}`);

  reportError(error, { type: 'unhandledrejection' });

  // 阻止控制台打印(谨慎使用,开发环境建议保留)
  // event.preventDefault();
});

// 还有一个"迟到的"事件:rejection 在事件循环后才被处理
window.addEventListener('rejectionhandled', (event) => {
  // 可以用来修正统计:这条错误其实已经被处理了
});

一个实用的组合是:开发环境让 unhandledrejection 直接抛出,逼你在本地就发现它;生产环境才走上报流程。

window.addEventListener('unhandledrejection', (event) => {
  if (import.meta.env.DEV) {
    // 本地开发:直接在控制台里炸出来,附上醒目的标记
    console.error('⚠️ 未处理的 Promise rejection:', event.reason);
    throw event.reason;
  }
  reportError(normalize(event.reason), { type: 'unhandledrejection' });
});

5.3 框架级兜底

现代框架在全局兜底之上,又加了一层组件级的隔离:

// React:错误边界(只能捕获渲染期错误)
class ErrorBoundary extends React.Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    reportError(error, { componentStack: info.componentStack });
  }

  render() {
    if (this.state.error) {
      return <FallbackUI error={this.state.error} />;
    }
    return this.props.children;
  }
}
// Vue:全局 + 组件级
app.config.errorHandler = (err, instance, info) => {
  reportError(err, {
    componentName: instance?.$options.name,
    info, // 'render' | 'setup' | 'event handler' ...
  });
};

// 警告处理(开发环境有用)
app.config.warnHandler = (msg, instance, trace) => {
  console.warn(msg, trace);
};

关于 React 的错误边界,有个必须知道的事实:它捕获不了事件处理器、异步代码、服务端渲染和错误边界自身抛出的错误。也就是说,onClick 里抛的错不会被 ErrorBoundary 接住。这也是为什么全局的 window.onerror 依然不可省略。

5.4 兜底不是万能药

全局兜底适合做的
  • 记录那些你完全没想到的错误
  • 在崩溃时展示一个友好的降级页面
  • 统计整体错误率,作为质量指标
不该指望它做的
  • 恢复业务状态——兜底时上下文已经丢了
  • 替代局部处理——它只知道“炸了”,不知道“为什么”
  • 让代码“看起来不会崩”——崩了就是崩了

六、错误监控与上报:让线上错误看得见

本地开发时你能看到控制台,线上你什么都看不到。错误监控做的事情,就是把用户的控制台搬到你的屏幕上。

6.1 上报什么

维度 字段 用途
错误本体 name / message / stack / code 定位问题代码
环境 UA、屏幕尺寸、网络类型、设备内存 判断是否为特定环境问题
版本 应用版本号、构建哈希 对应 Source Map,判断是否新引入
路径 页面 URL、来源页、路由栈 复现操作路径
用户 匿名 ID(非用户名) 统计影响用户数
上下文 错误前的接口调用、点击行为 还原现场

6.2 上报方式:为什么用 sendBeacon

普通的 fetch 上报有一个致命问题:如果错误发生在页面即将关闭时,请求很可能还没发出去页面就没了。navigator.sendBeacon 专为这个场景设计——它由浏览器接管发送,不阻塞页面卸载:

const endpoint = 'https://monitor.example.com/collect';

function send(payload) {
  const body = JSON.stringify(payload);

  // 优先使用 sendBeacon:不阻塞、页面卸载也能发出
  if (navigator.sendBeacon) {
    const ok = navigator.sendBeacon(
      endpoint,
      new Blob([body], { type: 'application/json' })
    );
    if (ok) return;
  }

  // 降级:用 keepalive 让 fetch 在页面卸载后仍能完成
  fetch(endpoint, {
    method: 'POST',
    body,
    keepalive: true,
    headers: { 'Content-Type': 'application/json' },
  }).catch(() => { /* 上报失败就算了,绝不能因此再抛错 */ });
}

上报本身绝对不能出错。监控代码抛出的错误会引发递归上报,把用户的浏览器拖垮。所以所有的上报逻辑都要包在 try/catch 里,且失败时静默。

6.3 Source Map:让压缩后的堆栈变回人话

线上代码经过压缩混淆后,堆栈长这样:

TypeError: Cannot read properties of undefined (reading 'id')     at t (main.a3f9c2.js:1:48213)     at e (main.a3f9c2.js:1:49120)     at main.a3f9c2.js:1:50233

有了 Source Map 之后,同样的堆栈可以被还原成:

TypeError: Cannot read properties of undefined (reading 'id')     at renderRow (src/components/UserList.vue:42:18)     at renderList (src/components/UserList.vue:31:5)     at src/pages/Users.vue:88:9

做法上有两种流派:

  • 上传到监控平台:构建时把 .map 文件传给监控服务,线上不部署 map 文件(避免源码泄露),由平台负责还原。
  • 自己还原:用 source-map 库在服务端消费 .map 文件。适合自建监控的场景。
// 服务端用 source-map 库还原堆栈
import { SourceMapConsumer } from 'source-map';

async function resolveStack(rawStack, version) {
  const rawMap = await loadMap(version);
  const consumer = await new SourceMapConsumer(rawMap);

  const resolved = rawStack.split('\n').map((line) => {
    const match = line.match(/\((.+):(\d+):(\d+)\)/);
    if (!match) return line;

    const [, , lineNo, colNo] = match;
    const pos = consumer.originalPositionFor({
      line: Number(lineNo),
      column: Number(colNo),
    });

    return pos.source
      ? ` at ${pos.name ?? '<anonymous>'} (${pos.source}:${pos.line}:${pos.column})`
      : line;
  });

  consumer.destroy();
  return resolved.join('\n');
}

6.4 采样、去重与指纹

假设你的应用每秒有几千个请求,某个循环里写错的代码可能在一分钟内产生十万条相同的错误。如果原样上报,你的监控服务会被打爆,网络流量也会被白白浪费。解决办法是去重 + 采样。

动手:错误指纹聚合实验

下面是一次真实场景中的原始上报记录。点击按钮看看它们会被聚合成几组:

指纹(fingerprint)通常由三部分拼成:错误类型 + 归一化后的 message + 堆栈第一帧。message 需要先做归一化,否则 用户 123 不存在 和 用户 456 不存在 会被当成两个错误:

function fingerprint(err) {
  const normalized = (err.message ?? '')
    // 数字 → 占位符
    .replace(/\d+/g, '<num>')
    // 引号里的内容 → 占位符
    .replace(/'[^']*'/g, '<str>')
    .replace(/"[^"]*"/g, '<str>')
    // URL → 占位符
    .replace(/https?:\/\/\S+/g, '<url>');

  const topFrame = (err.stack ?? '')
    .split('\n')[1]
    ?.replace(/:\d+:\d+/, '') // 去掉行列号,保留文件
    ?.trim() ?? '<unknown>';

  return [err.name, normalized, topFrame].join(' | ');
}

// 采样:同一个指纹每 100 次只上报 1 次
const counters = new Map();
function shouldReport(err) {
  const key = fingerprint(err);
  const n = (counters.get(key) ?? 0) + 1;
  counters.set(key, n);

  // 前 5 次全报(保证能发现问题),之后按 1% 采样
  return n <= 5 || n % 100 === 0;
}

6.5 别把用户隐私一起上报

错误上报是数据泄露的高发区。URL 里可能带着 token,表单错误里可能带着身份证号,接口返回的错误里可能带着手机号。几条硬性规则:

禁止 上报完整的请求体、响应体、表单内容
脱敏 URL 去掉 query、手机号/邮箱/身份证做掩码处理
匿名 用户标识使用随机 ID,不要用手机号或邮箱
合规 在隐私政策里说明错误采集的范围与用途

七、调试工具箱:把 DevTools 用到位

错误处理解决的是“出错之后怎么办”,调试解决的是“怎么找到错在哪”。DevTools 里的功能远比大多数人用的多。

7.1 console 家族:不只是 log

很多人只用 console.log,其实还有一整套工具:

方法 用途 示例
console.table 数组/对象以表格展示 console.table(users)
console.group 分组折叠输出 console.group('请求详情')
console.time 计时 console.time('render')
console.trace 打印调用栈 想知道"谁调用了这个函数"
console.count 统计调用次数 找出重复渲染
console.assert 条件不成立时才输出 替代手写 if + log
console.dir 以对象视图展示 DOM 节点 查看元素上的所有属性

动手:console.log 的“延迟求值”陷阱

点击下面的按钮,观察一个几乎所有人都踩过的坑:

// 点击上方按钮开始

原因是:console.log 打印对象时保存的是引用。你在控制台里展开它看到的是“展开那一刻”的值,而不是“打印那一刻”的值。想看到快照,要么打印 JSON.stringify(obj),要么用 structuredClone(obj) 复制一份。

7.2 断点的五种形态

“打断点”远不止点一下行号那么简单。Sources 面板支持这些断点类型:

行断点 最基础的一种。在 Sources 面板点击行号,或者代码里写 debugger
条件断点 右键行号 → Add conditional breakpoint,只有满足条件才暂停。排查循环里的第 999 次异常时极其有用
日志点 Logpoint:不暂停,只往控制台打印。等价于 console.log 但不用改代码,也不会有代码污染
DOM 断点 Elements 面板右键节点 → Break on subtree modifications。找出“到底是谁改了我的 DOM”
XHR 断点 Sources → XHR/Fetch Breakpoints,按 URL 关键字拦截请求。排查“这个接口什么时候被调的”
事件断点 Sources → Event Listener Breakpoints,按事件类型拦截,比如所有 click、所有 setTimeout

7.3 堆栈怎么读

暂停在断点上时,Call Stack 面板展示的是“从当前行一路回溯到最外层”的调用链。从上往下读是“我怎么到这儿的”,从下往上读是“谁调用的”。把鼠标悬停在任意一帧上,可以查看该帧作用域内的所有变量。

▼ 栈顶:当前暂停的位置   parseOrder (order.js:88:22) ← 出错的那一行   handleSubmit (Checkout.vue:142:9) ← 事件处理器   invokeWithErrorHandling (vue.runtime.js:3:4120) ← 框架调度   ▼ 栈底:最外层入口

读堆栈的一个实用技巧:先忽略框架的帧,找到第一个属于你业务代码的帧。大多数情况下,答案就在那里。

7.4 debugger 语句与代码注释

在代码里临时插入 debugger,效果等同于在那里打了个断点,但不需要在 DevTools 里找文件:

function calculateTotal(items) {
  // 只在本地调试时启用,提交前记得删掉
  if (process.env.NODE_ENV !== 'production' && items.length > 100) {
    debugger;
  }

  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

// 更优雅的方式:用一个可被构建工具剔除的函数
function devOnly(fn) {
  if (process.env.NODE_ENV !== 'production') fn();
}

devOnly(() => {
  console.group('结算详情');
  console.table(items);
  console.timeEnd('calc');
  console.groupEnd();
});

7.5 网络问题的排查思路

Network 面板里,看一眼请求的这几个字段往往就够了:

现象 看哪里 常见原因
请求根本没发出 Network 里找不到该请求 代码走了错误分支、被拦截器提前 return
请求被取消 Status 显示 (canceled) AbortController 触发、页面跳转
跨域失败 Console 里的 CORS 报错 后端没配 Access-Control-Allow-Origin
预检失败 多出一个 OPTIONS 请求,返回 4xx 自定义头或 Content-Type 触发预检,服务端未处理
响应被缓存 Size 显示 (memory cache) GET 请求未加时间戳或 Cache-Control 设置不当
接口慢但状态 200 Timing 面板的 TTFB 后端慢,不是前端问题

7.6 移动端调试

移动端没有控制台,这是最让人头疼的地方。常用方案有三类:

  • 远程调试:Android 用 Chrome 的 chrome://inspect,iOS 用 Safari 的“开发”菜单。功能最完整,但需要 USB 连接。
  • 页面内控制台:vConsole、eruda 这类库会在页面里注入一个浮层控制台,方便测试同学自助排查。
  • 代理抓包:Charles、Whistle 把手机流量代理到电脑上,可以看到完整的请求响应。

还有一种低成本但很有效的方式:把关键信息渲染到页面上。比如一个只在测试环境显示的调试面板,展示当前的接口状态、路由、错误计数。测试同学截图给你,比口头描述精确得多。

八、实战:一次线上白屏的排查复盘

理论讲完了,来看一个完整的真实场景。这个案例里的每一个环节,都会用到前面提到的工具。

8.1 现象

周一早上 9:12,客服群里开始出现反馈:“用户中心打不开,一片白”。你打开自己的页面,一切正常。这是最麻烦的一种情况——无法在本地复现。

8.2 第一步:确认影响面

打开监控看板,按时间筛选最近一小时:

// 错误看板 · 最近 60 分钟
TypeError Cannot read properties of undefined (reading 'avatar')
指纹:a3f9c2b1
影响用户:1,284 · 发生次数:8,932 · 首次出现:08:58
路径分布:/user/profile(96%)、/user/settings(4%)
版本分布:v2.14.0(100%)

三个关键信息立刻浮现:

  • 版本高度集中:只有 v2.14.0 出问题,说明是这次发版引入的。
  • 首次出现时间:08:58,正好对应发版时间。
  • 路径集中:都在用户中心相关页面,说明问题在用户数据的渲染上。

8.3 第二步:还原堆栈

点开一条错误的详情,看到的是压缩后的堆栈。经过 Source Map 还原后:

TypeError: Cannot read properties of undefined (reading 'avatar')     at renderAvatar (src/components/UserCard.vue:38:24)     at render (src/components/UserCard.vue:22:9)     at setup (src/pages/Profile.vue:64:5)

定位到 UserCard.vue 第 38 行:

// src/components/UserCard.vue
const avatarUrl = computed(() => {
  return props.user.profile.avatar; // ← 第 38 行
});

问题很清楚了:props.user.profile 是 undefined。但为什么会这样?这个字段以前一直是有的。

8.4 第三步:找到根因

对比 v2.14.0 和上一个版本的 git diff,发现后端接口做了一次调整:用户信息接口把 profile 字段拆成了两个接口,主接口不再返回 profile。前端拿到新版数据后,user.profile 自然就是 undefined。

为什么本地没发现?因为本地连的是 mock 数据,没有跟着后端一起改。这是非常典型的一类事故:前后端接口约定变更,但双方的测试环境不同步。

8.5 第四步:紧急止血

线上问题优先考虑止损,而不是完美修复。三分钟内可以做的:

// 热修复:加可选链 + 兜底头像
const avatarUrl = computed(() => {
  return props.user?.profile?.avatar ?? DEFAULT_AVATAR;
});

// 但更好的做法是:在数据入口处做一次结构归一化,
// 让下游组件永远拿到"完整"的对象
function normalizeUser(raw) {
  if (!raw) throw new BusinessError('用户数据为空', { code: 'USER_EMPTY' });

  return {
    ...raw,
    profile: raw.profile ?? {
      avatar: DEFAULT_AVATAR,
      bio: '',
      level: 0,
    },
  };
}

为什么推荐第二种?因为可选链是“到处打补丁”,归一化是“一处收口”。如果 user.profile 在十几个组件里都被访问,加可选链意味着你要改十几个地方,而且下次再来一个新字段,你还得再来一遍。

8.6 第五步:加固防御

止血之后,要做的是让这类问题下次在发版前就暴露出来:

契约测试 用接口 Schema 校验真实响应,字段缺失直接在 CI 阶段失败
数据归一化层 所有外部数据(接口、本地缓存、URL 参数)进入应用前先过一层转换
灰度发布 新版本先放 5% 流量,观察错误率再全量
错误率告警 某指纹的错误率超过阈值时自动通知,而不是等客服反馈
Mock 同步 接口变更时,mock 数据必须一起更新,纳入 code review 检查项
// 契约校验:在请求层就拦住不符合约定的响应
import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  profile: z.object({
    avatar: z.string().url(),
    bio: z.string(),
    level: z.number().int().min(0),
  }),
});

async function fetchUser(id) {
  const raw = await request(`/api/users/${id}`);
  const result = UserSchema.safeParse(raw);

  if (!result.success) {
    // 契约被破坏,这是一个"应该被修复的 bug",而不是"意外"
    throw new AppError('用户数据格式不符合约定', {
      code: 'SCHEMA_MISMATCH',
      meta: { issues: result.error.issues },
    });
  }

  return result.data;
}

8.7 复盘清单

每次线上事故之后,问自己这五个问题:

  1. 它为什么能上线?——哪一道防线失效了?测试、评审、还是灰度?
  2. 它为什么没被更早发现?——监控阈值是不是太松了?
  3. 它为什么影响这么大?——是不是缺少降级方案,导致一个字段就让整页白屏?
  4. 它为什么没被快速定位?——堆栈够不够清晰?Source Map 有没有上传?
  5. 同类问题还有多少?——同样的模式在别的页面存在吗?要不要全局搜一遍?

这五个问题问完,通常能挖出十几个改进项。比起“下次注意点”,这才是真正能把事故变成资产的做法。

九、速查表与清单

最后把全文浓缩成两份可以直接贴在手边的参考。

错误处理速查

场景 推荐做法 避免
解析外部数据 try/catch + 降级默认值 直接信任返回结构
调用接口 检查 res.ok + 超时 + 重试 以为 fetch 会自动 reject
多个并行请求 按需选 all / allSettled 无脑用 Promise.all
循环里发请求 for...of 或 map + Promise.all forEach + async
包装底层错误 带 cause 重新抛出 丢掉原始堆栈
组件渲染崩溃 错误边界 / 降级 UI 让整页白屏
用户主动取消 过滤 AbortError,不上报 当成错误上报刷屏
线上未知错误 全局兜底 + 上报 + 告警 只写 console.error

调试清单

  • 先复现,再修改。不能在本地复现的 bug,修好了你也不知道是不是真的修好了。
  • 一次只改一个变量。同时改三处然后问题消失了,你其实什么也没学到。
  • 相信数据,不相信直觉。“我觉得是这里”通常都是错的,让断点告诉你答案。
  • 读堆栈要从栈顶往下,忽略框架帧。第一个业务代码的帧,往往就是答案。
  • 能用条件断点,就不要加 console.log。尤其不要提交带 log 的代码。
  • 二分法定位。注释掉一半代码看看还复现吗,比逐行读快得多。
  • 把 bug 变成测试。每个修好的 bug 都值得配一个回归测试。
  • 检查边界值。空数组、null、undefined、0、空字符串、超长字符串。
  • 看网络面板。一半的“前端 bug”其实是接口问题。
  • 记录排查过程。下次遇到同类问题,你的笔记会救你一次。
// 一份可以常备的"错误处理工具箱"骨架

// 1. 领域错误 —— 让错误携带上下文
class AppError extends Error {
  constructor(msg, { code, meta, cause } = {}) {
    super(msg, { cause });
    this.name = 'AppError';
    this.code = code ?? 'UNKNOWN';
    this.meta = meta ?? {};
  }
}

// 2. 断言 —— 让逻辑错误提前爆炸
function assert(cond, msg) {
  if (!cond) throw new AppError(msg, { code: 'ASSERT' });
}

// 3. 超时 —— 让"永远不返回"变成明确错误
function withTimeout(p, ms, label) { ... }

// 4. 重试 —— 只重试可重试的
async function withRetry(fn, times = 3) { ... }

// 5. 上报 —— 去重 + 采样 + 脱敏
function reportError(err, context) { ... }

// 6. 全局兜底 —— 最后一道防线
window.addEventListener('error', onGlobalError, true);
window.addEventListener('unhandledrejection', onUnhandled);

关于错误处理,最反直觉的一点是:它的大部分价值体现在那些从未发生的错误上。你永远不知道有多少次白屏被降级 UI 挡了下来,有多少次数据错乱被断言拦在了开发阶段,有多少次线上故障在上报系统里被提前发现。这些“什么都没发生”的时刻,就是错误处理工作的全部回报。

而调试能力则相反,它总是在最需要的时候才被检验。深夜的线上告警、测试同学的截图、用户的一句“打不开”——那一刻你能多快定位、多准判断,取决于你平时有没有认真对待过每一个小小的 bug。

把每一行 catch 都写明白,把每一个断点都用熟练,把每一次排查都记录下来。慢慢地你会发现,那些曾经让人头秃的问题,开始变得有迹可循了。