错误处理与调试
程序不可能不犯错,关键是让错误在正确的位置被发现、被捕获、被记录。本文先讲清楚 JavaScript 的错误类型体系与 try/catch 机制,再对比同步与异步场景下捕获方式的差异,最后给出从 console 到 DevTools 的完整调试工具箱与错误上报思路。
一、错误类型体系
JavaScript 所有错误都继承自 Error 基类,运行时按出错原因实例化不同子类:
// SyntaxError:语法错误,解析阶段就抛出,代码无法运行
// const a = ; // 运行前直接报 SyntaxError
// TypeError:类型不对,最常见的运行时错误
null.name; // TypeError: Cannot read properties of null
const n = 1;
n(); // TypeError: n is not a function
// ReferenceError:引用不存在的变量
console.log(notDefined); // ReferenceError: notDefined is not defined
// RangeError:数值超出有效范围
new Array(-1); // RangeError: Invalid array length
// URIError:URI 编解码参数非法
decodeURIComponent("%"); // URIError: URI malformed
// EvalError:直接使用 eval 且参数非法(规范保留,现代引擎几乎不会抛)
// eval("{"); // 现代引擎抛的是 SyntaxError
// AggregateError:多个错误聚合(Promise.any 全部失败时抛出)
Promise.any([Promise.reject(new Error("A")), Promise.reject(new Error("B"))])
.catch(err => console.log(err.errors.length)); // 2,errors 数组| 错误类型 | 触发场景 | 典型示例 |
|---|---|---|
SyntaxError | 代码语法非法,解析期抛错 | 漏括号、中文标点、非法赋值 |
TypeError | 值类型与操作不匹配 | 对 null/undefined 取属性、非函数调用 |
ReferenceError | 引用未声明变量 | 拼写错误、作用域外访问 |
RangeError | 数值超出合理范围 | 数组长度非法、递归过深栈溢出 |
URIError | 全局 URI 函数收到非法参数 | decodeURIComponent 传 % |
EvalError | 与 eval 相关的异常(现代引擎保留) | 规范兼容场景 |
AggregateError | 批量操作中的多个错误聚合 | Promise.any 全败、Promise.allSettled 聚合 |
二、throw 语句
throw 可以抛出任何值,但最佳实践是抛 Error 实例,因为自带 message 与 stack:
// 可以抛,但不推荐
throw "字符串错误"; // 丢失调用栈
throw { code: 404 }; // 结构随意,难以统一处理
// 推荐:抛 Error 或子类
throw new Error("用户名不能为空");
throw new TypeError("参数类型错误");抛出的 Error 会被最近的 try/catch 捕获,未被捕获时由运行时上报(见第七、八节)。
三、try/catch/finally 执行流程
try 执行可能出错的代码;出错时跳入 catch;finally 无论是否出错都会执行,常用于释放资源:
try {
risky();
console.log("try 正常结束");
} catch (err) {
console.log("捕获:", err.message);
} finally {
console.log("清理资源"); // 总是执行
}执行流程:
try 抛错?──否──→ 跳过 catch → 执行 finally → 继续
│是
↓
执行 catch → 执行 finally → 继续3.1 finally 与 return 的交互
finally 里的 return 会覆盖 try/catch 的返回值:
function demo() {
try {
return "try 的返回值";
} finally {
return "finally 覆盖了"; // 覆盖上面所有 return
}
}
console.log(demo()); // finally 覆盖了
function demo2() {
try {
return "正常返回";
} finally {
console.log("finally 先执行,再返回"); // 先打日志,再返回 try 的值
}
}
console.log(demo2()); // 正常返回要点:finally 中尽量只做清理(关闭连接、清除定时器、恢复状态),不要写 return,否则会吞掉 try 中的返回值和错误。
四、捕获错误对象属性
Error 实例的核心属性:
try {
JSON.parse("{bad json");
} catch (err) {
console.log(err.name); // SyntaxError
console.log(err.message); // Unexpected token 'b'...
console.log(err.stack); // 完整调用栈(name + message + 栈帧)
}| 属性 | 类型 | 含义 |
|---|---|---|
message | string | 人类可读的错误描述 |
name | string | 错误类型名(Error、TypeError…) |
stack | string | 调用栈快照,定位出错位置与调用链 |
cause | any | 原始错误引用(ES2022,见下节) |
注意:stack 的具体格式由引擎决定(V8 与 SpiderMonkey 略有差异),不要解析它做逻辑判断,只用于展示和上报。
五、Error cause 链(ES2022)
ES2022 起 Error 构造函数支持第二个参数 { cause },把底层错误挂到包装错误上,形成原因链:
try {
const data = await fetchData();
} catch (err) {
// 保留底层 err,抛出对上层更有意义的信息
throw new Error("获取用户数据失败", { cause: err });
}
// 上层遍历原因链
try {
await getUserData();
} catch (err) {
console.log(err.message); // 获取用户数据失败
console.log(err.cause?.message); // 底层:网络超时
console.log(err.cause?.cause); // 再往下一层(若有)
}应用场景:底层错误(网络、数据库)包装成业务错误时,绝不丢失原始错误,排查问题时能顺着 cause 一路追到底。
六、自定义错误类
继承 Error 并重写 name,让错误语义清晰,可用 instanceof 精确区分:
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = "ValidationError"; // 默认会是 "Error",必须重写
this.field = field; // 附加业务字段
}
}
class NetworkError extends Error {
constructor(message, status) {
super(message);
this.name = "NetworkError";
this.status = status;
}
}
// 使用
function parseUser(input) {
if (!input.name) throw new ValidationError("缺少 name 字段", "name");
throw new NetworkError("请求失败", 503);
}
try {
parseUser({});
} catch (err) {
if (err instanceof ValidationError) {
console.log("校验失败:", err.field);
} else if (err instanceof NetworkError) {
console.log("网络错误:", err.status);
} else {
console.log("未知错误");
}
}提示:自定义类要正确设置 name,否则所有实例的 name 都显示为 Error,影响日志可读性。
七、同步 vs 异步错误捕获
只有同步代码能被 try/catch 捕获,异步场景的错误不会向上冒泡,必须单独处理:
// 同步:try/catch 有效
try {
const a = null;
a.name;
} catch (err) {
console.log("同步错误可捕获"); // 能走到
}
// 异步:setTimeout 里的错误不会冒泡到外层 try
try {
setTimeout(() => {
throw new Error("定时器里炸了");
}, 0);
} catch (err) {
console.log("捕获不到!"); // 执行不到,错误成为未捕获异常
}
// Promise 链:必须接 catch,否则成为 unhandledrejection
Promise.reject(new Error("没人接住我"));
// async/await:try/catch 有效(await 把 rejection 变成可捕获异常)
async function load() {
try {
const data = await riskyFetch();
} catch (err) {
console.log("async 中可捕获");
}
}| 场景 | 捕获方式 | 未处理时的表现 |
|---|---|---|
| 同步代码 | try/catch | 中断执行,抛出到全局 |
| Promise 链 | .catch() 链尾捕获 | unhandledrejection 事件 |
| async/await | try/catch 包裹 await | 返回 rejected Promise,无人接则 unhandledrejection |
| setTimeout 回调 | 回调内部 try/catch | 全局 error 事件 |
| 事件回调 | 回调内部 try/catch | 全局 error 事件 |
八、window.onerror 与 unhandledrejection(浏览器)
浏览器提供两个全局兜底事件,捕获漏网的同步错误与未处理的 Promise 拒绝:
// 1. 运行时错误(同步 + 事件回调)
window.addEventListener("error", (event) => {
const { message, filename, lineno, colno, error } = event;
report({ type: "runtime", message, filename, lineno, colno, stack: error?.stack });
});
// 2. Promise 未被处理
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
report({ type: "rejection", message: reason?.message, stack: reason?.stack });
event.preventDefault(); // 可选:阻止控制台默认报错
});
// 3. 资源加载失败(img/script 等,不冒泡到 error 事件)
window.addEventListener("error", (event) => {
if (event.target !== window) {
report({ type: "resource", src: event.target.src });
}
}, true);注意:error 事件对"资源加载失败"不会携带 error 对象,需通过 event.target 判断来源;全局兜底用于记录与上报,不要指望用它恢复业务逻辑。
九、Node 中的 uncaughtException 与 unhandledRejection
Node 环境对应的两个进程级事件:
// 未捕获的同步异常
process.on("uncaughtException", (err) => {
console.error("未捕获异常:", err);
// 只记录,然后让进程退出或优雅重启,不要继续运行在脏状态
});
// 未处理的 Promise 拒绝
process.on("unhandledRejection", (reason, promise) => {
console.error("未处理的拒绝:", reason);
});// 演示
setTimeout(() => { throw new Error("同步炸"); }, 100); // uncaughtException
Promise.reject(new Error("拒绝炸")); // unhandledRejection重要认知:Node 中发生 uncaughtException 后进程处于不稳定状态,主流做法是记录错误后让进程退出,由 PM2、Docker 等进程管理器重启,而不是试图在兜底里继续运行。
十、防御性编程
在源头减少错误发生,比事后捕获更省心:
// 1. 参数校验 + 默认值
function createUser({ name, age = 18 } = {}) {
if (typeof name !== "string" || name.trim() === "") {
throw new TypeError("name 必须是非空字符串");
}
if (!Number.isFinite(age) || age < 0) {
throw new RangeError("age 必须是 ≥0 的数字");
}
return { name, age };
}
// 2. 可选链 + 空值合并,避免 "Cannot read properties of undefined"
const city = user?.address?.city ?? "未知城市";
// 3. try 范围最小化:只包可能出错的一行,别把整个函数塞进去
let data;
try {
data = JSON.parse(raw); // 只这行可能抛错
} catch (err) {
data = { fallback: true };
}
// 之后的业务代码保持正常流,不用层层缩进| 手段 | 解决的问题 | 用法 |
|---|---|---|
| 参数校验 | 非法入参引发连锁错误 | 入口处 throw TypeError/RangeError |
可选链 ?. | 深层属性访问崩溃 | obj?.a?.b |
空值合并 ?? | 缺失值给默认 | x ?? "默认" |
| try 范围最小化 | catch 误吞无关错误 | 只包裹风险语句 |
| 早返回 | 减少嵌套与分支错误 | 不满足条件直接 return |
十一、调试技巧
11.1 console 系列方法
console.log("普通日志");
console.info("信息"); // 样式区分
console.warn("警告"); // 黄色
console.error("错误"); // 红色,含调用栈
const users = [
{ name: "张三", age: 25 },
{ name: "李四", age: 30 },
];
console.table(users); // 表格化展示对象数组,极适合查数据
console.group("分组");
console.log("a");
console.log("b");
console.groupEnd();
console.time("耗时测试"); // 计时开始
for (let i = 0; i < 1e6; i++) {}
console.timeEnd("耗时测试"); // 耗时测试: 2.5ms
console.dir(obj); // 以对象树形式展开(尤其适合 DOM 元素)
console.trace("调用路径"); // 打印当前调用栈11.2 debugger 语句与 DevTools 断点
在代码中写 debugger;,浏览器执行到这里会自动暂停(需打开开发者工具):
function calc(a, b) {
const sum = a + b;
debugger; // 执行到这一行时暂停,可检查 a、b、sum
return sum * 2;
}
calc(3, 4);DevTools 的断点调试流程:
- Sources 面板打开文件,点击行号设置断点。
- 条件断点:右键断点设置表达式,如
a === 3,满足才停。 - 暂停后右侧面板查看:调用栈(Call Stack,逐级回溯)、作用域(Scope,局部/闭包/全局变量)、监视(Watch,自定义表达式)。
- 用 Step Over(跳过本行)、Step Into(进入函数)、Step Out(跳出函数)控制执行流。
11.3 性能面板
Performance 面板录制一段操作后,可看到:主线程任务时间线、长任务(Long Task)、函数调用耗时占比、内存变化。先复现慢的问题,再录制分析,配合 performance.now() 在代码中打点定位瓶颈。
| 手段 | 用途 |
|---|---|
console.log/table | 快速观察数据 |
console.time/timeEnd | 粗略耗时对比 |
debugger 语句 | 精确暂停 |
| DevTools 断点/条件断点 | 单步追踪、按条件停 |
| 调用栈 + 作用域面板 | 定位调用链与变量状态 |
| Performance 面板 | 性能瓶颈分析 |
console.trace | 打印当前调用路径 |
十二、错误上报思路
生产环境的错误要能回传到监控平台,一般遵循以下思路:
// 1. 统一收集入口
function reportError(err, context = {}) {
const payload = {
message: err.message,
stack: err.stack,
name: err.name,
url: location.href, // 哪个页面
userAgent: navigator.userAgent, // 什么环境
timestamp: Date.now(),
version: APP_VERSION, // 哪个发布版本
userId: getUserId(), // 哪个用户(脱敏后)
...context, // 附加业务上下文
};
// 2. 抽样 + 去重:错误量巨大时只上报一部分
if (Math.random() > 0.1) return;
// 3. 批量发送:攒一批再上传,避免请求风暴
buffer.push(payload);
if (buffer.length >= 20) flush();
// 4. 失败静默:上报本身失败不能影响业务
}
window.addEventListener("error", (e) => reportError(e.error ?? e.message));
window.addEventListener("unhandledrejection", (e) => reportError(e.reason));关键设计点:
- 全量监听:error + unhandledrejection 双钩子,同步异步全覆盖。
- 上下文补充:页面、版本、用户、操作步骤,便于复现。
- 去重与抽样:同一错误的重复上报按
stack + message聚合,超出阈值降采样。 - 上报自身要安全:用
sendBeacon或图片打点,失败不影响业务。
错误处理的整体思路可以概括为:能捕获的本地捕获、该包装的带着 cause 包装、漏网的靠全局钩子记录、源头靠防御性编程减少,最后所有错误统一流向上报通道,让每次线上事故都能被定位和复盘。