写代码的时间里有相当一部分并不是在“写”,而是在“找”——找那个让页面白屏的变量、找那个偶发失败的请求、找那个只在 iOS Safari 上出现的诡异偏移。很多人把调试理解成“出问题了才做的事”,但真正拉开工程师差距的,恰恰是把错误当作一等公民来设计:在写第一行代码的时候就想清楚它会怎么失败、失败之后谁知道、知道之后怎么办。本篇 30 分钟长文,从错误对象的解剖开始,依次走过同步错误、异步错误、全局兜底、监控上报、调试工具箱,最后落到一次完整的线上白屏排查复盘。文中嵌入了五个可以真手上手的互动实验台——错误类型探测、执行顺序追踪、异步陷阱模拟、错误指纹聚合、控制台沙盒——建议边读边点,把“知道”变成“见过”。
一、先改变观念:错误不是“异常情况”
“异常”这个词翻译得有点误导。它让人以为错误是偏离常态的少数派,只要代码写得足够好就不会出现。但在前端这个运行环境里,事实恰好相反:
- 用户的网络会断,会慢,会在请求发出去的瞬间切到地铁隧道;
- 后端会返回你文档里没写的字段,会返回
null,会在凌晨发版; - 浏览器有五个主流内核、上百个版本,还有各种 App 内置 WebView 和“极速模式”;
- 用户会双击按钮、会在输入框里粘贴一张图片、会把手机系统时间改成 1970 年;
- 第三方 SDK 会在你不知情的时候往
window上挂东西。
换句话说,错误是常态,不是意外。一段从未被错误路径走过的代码,和一段没有写过的代码,可靠性上是等价的。
三种处理错误的态度
catch {}、catch (e) { return null },把错误吞掉当作没发生
console.error(e),知道出错了但没人看见,线上依旧无从下手
错误的四种来源
不同类型的问题,处理方式完全不同。点击下面的卡片展开:
TypeError、ReferenceError、RangeError 都属于这一类。它是前端监控里最常见的错误类型,也是本文后续重点讨论的对象。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 无法告诉你“是哪个用户、在哪一步、点了什么”。自定义错误类解决的正是这个问题:
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 这个字段之后,重试逻辑就变得非常干净:
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 完全看不到了
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 只在花括号内可见。这本来是为了兼容旧引擎的设计,但今天它反而成了一个优点——你可以在外层用同样的名字:
try {
throw new Error('内层的错误');
} catch (err) {
console.log(err.message); // "内层的错误"
}
console.log(err); // "外层的变量"
规则二:不需要绑定参数时可以省略
如果你确实不关心错误对象(比如只是想在解析失败时走一个降级分支),ES2019 起可以完全省略括号:
try {
config = JSON.parse(localStorage.getItem('config'));
} catch {
config = {}; // 本地缓存坏了,用默认值兜底
}
// 注意:省略绑定时你无法上报错误,
// 所以只适合"确实不需要知道原因"的场景
规则三:finally 一定会执行
这是 finally 存在的全部意义:无论 try 里是正常返回、抛出错误,还是被 return 提前结束,finally 都会执行。
动手:执行顺序追踪
点击下面的按钮,逐步观察一次“try 中抛错”的执行轨迹。注意第四步——try 中错误之后的代码会被整体跳过:
规则四:finally 里的 return 会吃掉一切
这是 finally 最危险的行为,也是代码评审里最容易被忽略的一类 bug:
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 {
const user = await fetchUser(id);
const orders = await fetchOrders(user.id);
const total = orders.reduce(calcTotal, 0);
render(total);
} catch (e) {
// 是接口挂了?还是 calcTotal 写错了?
// 还是 render 里 DOM 不存在?完全不知道
}
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); // 纯函数与渲染,不该失败
规则七:用断言把逻辑错误变成运行时错误
逻辑错误最难查,因为它不报错。一个有效的技巧是主动把不可能的情况变成异常,让它在开发阶段就暴露出来:
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。这个约定的价值是把错误变成显式的、无法忽略的参数:
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,没人知道
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:
fetchUser(id).then((user) => {
fetchOrders(user.id).then((orders) => {
render(orders);
});
}).catch((e) => {
// fetchOrders 失败时不会走到这里!
});
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 函数:
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:
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”:
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 事件,它走的是另一个事件:
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 直接抛出,逼你在本地就发现它;生产环境才走上报流程。
if (import.meta.env.DEV) {
// 本地开发:直接在控制台里炸出来,附上醒目的标记
console.error('⚠️ 未处理的 Promise rejection:', event.reason);
throw event.reason;
}
reportError(normalize(event.reason), { type: 'unhandledrejection' });
});
5.3 框架级兜底
现代框架在全局兜底之上,又加了一层组件级的隔离:
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;
}
}
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 专为这个场景设计——它由浏览器接管发送,不阻塞页面卸载:
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:让压缩后的堆栈变回人话
线上代码经过压缩混淆后,堆栈长这样:
有了 Source Map 之后,同样的堆栈可以被还原成:
做法上有两种流派:
- 上传到监控平台:构建时把
.map文件传给监控服务,线上不部署 map 文件(避免源码泄露),由平台负责还原。 - 自己还原:用
source-map库在服务端消费.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 不存在 会被当成两个错误:
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,表单错误里可能带着身份证号,接口返回的错误里可能带着手机号。几条硬性规则:
七、调试工具箱:把 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 面板支持这些断点类型:
debugger
console.log 但不用改代码,也不会有代码污染
click、所有 setTimeout
7.3 堆栈怎么读
暂停在断点上时,Call Stack 面板展示的是“从当前行一路回溯到最外层”的调用链。从上往下读是“我怎么到这儿的”,从下往上读是“谁调用的”。把鼠标悬停在任意一帧上,可以查看该帧作用域内的所有变量。
读堆栈的一个实用技巧:先忽略框架的帧,找到第一个属于你业务代码的帧。大多数情况下,答案就在那里。
7.4 debugger 语句与代码注释
在代码里临时插入 debugger,效果等同于在那里打了个断点,但不需要在 DevTools 里找文件:
// 只在本地调试时启用,提交前记得删掉
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 第一步:确认影响面
打开监控看板,按时间筛选最近一小时:
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 还原后:
定位到 UserCard.vue 第 38 行:
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 第五步:加固防御
止血之后,要做的是让这类问题下次在发版前就暴露出来:
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 复盘清单
每次线上事故之后,问自己这五个问题:
- 它为什么能上线?——哪一道防线失效了?测试、评审、还是灰度?
- 它为什么没被更早发现?——监控阈值是不是太松了?
- 它为什么影响这么大?——是不是缺少降级方案,导致一个字段就让整页白屏?
- 它为什么没被快速定位?——堆栈够不够清晰?Source Map 有没有上传?
- 同类问题还有多少?——同样的模式在别的页面存在吗?要不要全局搜一遍?
这五个问题问完,通常能挖出十几个改进项。比起“下次注意点”,这才是真正能把事故变成资产的做法。
九、速查表与清单
最后把全文浓缩成两份可以直接贴在手边的参考。
错误处理速查
| 场景 | 推荐做法 | 避免 |
|---|---|---|
| 解析外部数据 | 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 都写明白,把每一个断点都用熟练,把每一次排查都记录下来。慢慢地你会发现,那些曾经让人头秃的问题,开始变得有迹可循了。