错误处理与调试

让程序在出错时不崩,在调试时一目了然

🎯 学习目标

学完本章,你将能够:

  • 能够使用 try/catch/finally 捕获和处理同步代码中的错误
  • 能够区分 JavaScript 内置错误类型(TypeError、SyntaxError、RangeError 等)
  • 能够创建自定义错误类,携带额外的上下文信息
  • 能够使用浏览器 DevTools 设置断点、单步执行、查看调用栈
  • 能够运用防御性编程策略,编写健壮的错误处理代码

你有没有想过

为什么有些网站出错了还能正常用,有些网站一个接口挂了整个页面白屏?

差别就在错误处理。好的程序不是不报错,而是报错了还能优雅降级、给用户友好提示、记录日志方便排查。

这一章你将学会:用 try/catch 捕获错误、自定义错误类型、异步错误处理策略、以及浏览器和 Node.js 的调试技巧。

Part 1: try/catch

最基本的错误处理结构:把可能出错的代码包起来,出了错能接住

一个接口返回了格式错误的数据,如果不处理,整个页面直接白屏。但如果用 try/catch 包起来,最起码能给用户一个"数据加载失败"的友好提示。这是最基础也最有效的防线。finally 块不管出不出错都会执行,适合做清理工作。先把这个三板斧练熟,后面的高级技巧才有根基。

try/catch/finally

try 里出错了不会炸,catch 接住它,finally 始终执行

try {
    let data = JSON.parse("invalid json"); // 这里会抛 SyntaxError
} catch (error) {
    console.log(error.name);    // "SyntaxError"
    console.log(error.message); // 具体错误信息
    console.log(error.stack);   // 完整调用栈(排查利器)
} finally {
    console.log("始终执行,不管有没有错");
}

用比喻来说:try 是蹦极,catch 是安全网,finally 是着地后无论安不安全都要做的收尾。

嵌套与重新抛出

内层处理不了的错误,throw 给外层

try {
    try {
        throw new Error("内部错误");
    } catch (e) {
        console.log("内部捕获:", e.message);
        throw e; // 重新抛出,交给外层处理
    }
} catch (e) {
    console.log("外部捕获:", e.message);
}
永远不要写空的 catch 块。 空 catch 会吞掉错误,让你完全不知道出了什么问题。至少要 console.error(e)。

Part 2: 自定义错误

内置的 Error 不够用时,继承它创建自己的错误类型

catch 里拿到一个 Error,怎么知道是"用户名太短"还是"网络超时"?如果全靠 message 字符串判断,很快就会乱。更好的做法是创建 ValidationError、AuthError 这样的自定义错误类,用 instanceof 精确区分。再加上 ES2022 的 Error.cause 保留错误链,排查问题时能一路追溯到最底层的原因。

内置错误类型

不同场景抛不同类型的错误

错误类型触发场景
SyntaxError语法错误,如 JSON.parse("bad")
ReferenceError访问未声明的变量
TypeError对 null 调方法、类型不匹配
RangeError数值超出范围
URIErrorencodeURI/decodeURI 参数非法

所有错误都继承自 Error,都有 name、message、stack 三个属性。

自定义错误类

继承 Error,加上自己的字段

class ValidationError extends Error {
    constructor(field, message) {
        super(message);
        this.name = "ValidationError";
        this.field = field; // 自定义字段
    }
}

function validateAge(age) {
    if (typeof age !== "number") {
        throw new ValidationError("age", "年龄必须是数字");
    }
    if (age < 0 || age > 150) {
        throw new ValidationError("age", "年龄超出范围");
    }
}

// 使用 instanceof 区分错误类型
try {
    validateAge("abc");
} catch (e) {
    if (e instanceof ValidationError) {
        console.log(`字段 ${e.field} 验证失败: ${e.message}`);
    } else {
        throw e; // 不认识的错误重新抛出
    }
}

Part 3: 异步错误处理

异步代码的错误处理和同步不同,callback、Promise、async/await 各有各的写法

前面学的 try/catch 只能捕获同步错误。setTimeout 里面抛的错、fetch 请求失败,它根本接不住。异步错误要靠回调的 error-first 约定、Promise 的 .catch()、或者 async/await 的 try/catch 来处理。再加上全局错误兜底,即使有漏网之鱼也不会导致应用崩溃。

三种风格对比

callback 靠约定,Promise 靠 catch,async/await 靠 try/catch

// 1. callback 风格:error-first(Node.js 约定)
fs.readFile(path, (err, data) => {
    if (err) return callback(err);
    callback(null, data);
});

// 2. Promise 风格:链式 .catch()
fetch("/api/data")
    .then(res => res.json())
    .catch(err => console.error(err))
    .finally(() => hideLoading());

// 3. async/await 风格(推荐)
async function fetchData() {
    try {
        let res = await fetch("/api/data");
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return await res.json();
    } catch (err) {
        console.error(err);
        throw err;
    }
}

实际开发中 async/await + try/catch 是主流写法,可读性最好。

全局错误兜底

漏网的错误,用全局监听接住

// 浏览器环境
window.addEventListener("error", (e) => {
    console.log("全局错误:", e.message, e.filename, e.lineno);
});
window.addEventListener("unhandledrejection", (e) => {
    console.log("未捕获的 Promise 错误:", e.reason);
});

// Node.js 环境
process.on("uncaughtException", (err) => {
    console.error("未捕获异常:", err);
});
process.on("unhandledRejection", (reason) => {
    console.error("未处理的 Promise 拒绝:", reason);
});
全局错误处理是最后一道防线,不是偷懒的工具。 能用 try/catch 处理的地方就应该用,全局处理只兜底漏网之鱼。

Part 4: 调试技巧

写代码一半时间在写,另一半时间在找 bug,掌握调试工具效率翻倍

与其到处加 console.log 再删掉,不如学会断点调试。在 Sources 面板点一下行号,程序执行到那里就暂停——你能看到所有变量的值、调用栈、甚至手动执行表达式。Node.js 也一样,--inspect 启动后用 Chrome 或 VS Code 接入。再加上 console.table、console.trace 这些高级技巧,定位 bug 的速度能快好几倍。

浏览器 DevTools

Sources 面板设断点,比 console.log 高效十倍

// 方式一:在 Sources 面板点击行号设断点
// 方式二:代码中写 debugger(推荐调试时用)
function complexCalc(data) {
    debugger; // 执行到这里会暂停
    return data.reduce((a, b) => a + b, 0);
}
快捷键功能
F8继续执行
F10步过(不进入函数内部)
F11步入(进入函数内部)
Shift+F11步出(跳出当前函数)

Console 技巧

不只是 console.log,还有很多好用的方法

// 样式化输出
console.log("%cStyled", "color: red; font-size: 20px");

// 表格输出——看数组和对象特别清晰
console.table([{name: "A"}, {name: "B"}]);

// 打印调用栈
console.trace("调用栈");

// 计时
console.time("操作");
// ... 一段代码
console.timeEnd("操作"); // 输出耗时

// 分组
console.group("用户信息");
console.log("姓名: 张三");
console.log("年龄: 25");
console.groupEnd();

Node.js 调试

--inspect 打开调试端口,VS Code 或 Chrome 接入

# 启动调试模式
$ node --inspect app.js
# 然后打开 Chrome: chrome://inspect

# 在首行就暂停
$ node --inspect-brk app.js

# 内置命令行调试器
$ node inspect app.js
# n (下一步) c (继续) s (步入) o (步出) repl (交互)

推荐用 VS Code 的调试功能。在项目根目录创建 .vscode/launch.json,然后按 F5 一键调试。

Part 5: 防御性编程

与其出错了再调试,不如写代码时就防止错误发生

错误处理和调试都是"事后补救"。更高级的做法是在写代码时就堵住错误的源头——这就是防御性编程。函数入口校验参数、严格模式让隐式错误显式化、TypeScript 加类型保护、ESLint 做静态检查。多写几行防御代码,调试时能省下十倍的时间。

防御性编程清单

写代码时多想一步,调试时少花十倍时间

输入校验

始终检查函数参数:if (!name) throw new Error(...)

严格模式

"use strict" 让隐式错误变成显式报错。

类型安全

使用 TypeScript 或 JSDoc 增强类型检查。

错误日志

记录错误日志,不吞掉错误,避免空 catch。

全局兜底

添加 uncaughtException 和 unhandledRejection 监听。

静态检查

ESLint 能检测出很多潜在问题,一定要用。

常见错误排查

看到错误先看调用栈。 调用栈从下往上看,最下面一行是出错的位置,上面是调用链。

TypeError: Cannot read property of undefined

最常见错误,没有之一。 说明你在 undefined 上访问了属性。检查链式调用中哪一步返回了 undefined。

UnhandledPromiseRejection

Promise 被 reject 了但没有 .catch()。 给所有 Promise 加上 .catch() 或用 try/catch 包裹 async 函数。

Maximum call stack size exceeded

无限递归。 检查递归函数是否有正确的终止条件。

链式错误

ES2022 的 Error.cause 属性,让错误可以追溯根因

// 在 catch 中包装原始错误,保留根因
function readConfig(path) {
    try {
        return JSON.parse(fs.readFileSync(path, "utf-8"));
    } catch (err) {
        throw new Error(`读取配置失败: ${path}`, { cause: err });
    }
}

try {
    readConfig("./config.json");
} catch (e) {
    console.error(e.message);           // "读取配置失败: ./config.json"
    console.error(e.cause.message);     // 原始错误信息
}

// 遍历错误链找到根因
function getRootCause(err) {
    let current = err;
    while (current.cause) {
        current = current.cause;
    }
    return current;
}
console.log(getRootCause(e).message); // 最底层的错误
不要丢失原始错误。 如果 catch 后直接 throw new Error("出错了"),原始错误的调用栈就丢了。始终用 { cause: err } 保留错误链。

错误处理模式

一个完整的 API 错误处理层:自动处理 HTTP 错误、网络错误、超时

const ERROR_MESSAGES = {
    NETWORK: "网络连接失败,请检查网络后重试",
    TIMEOUT: "请求超时,服务器响应过慢",
    400: "请求参数有误,请检查输入",
    401: "登录已过期,请重新登录",
    403: "没有权限执行此操作",
    404: "请求的资源不存在",
    500: "服务器开小差了,请稍后再试",
};

class HttpClient {
    constructor(baseURL, timeout = 5000) {
        this.baseURL = baseURL;
        this.timeout = timeout;
    }

    async request(path, options = {}) {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), this.timeout);
        try {
            const res = await fetch(this.baseURL + path, {
                ...options,
                signal: controller.signal,
            });
            if (!res.ok) {
                throw new Error(ERROR_MESSAGES[res.status] || `HTTP ${res.status}`, {
                    cause: { status: res.status, url: res.url },
                });
            }
            return await res.json();
        } catch (err) {
            if (err.name === "AbortError") {
                throw new Error(ERROR_MESSAGES.TIMEOUT);
            }
            if (err instanceof TypeError) {
                throw new Error(ERROR_MESSAGES.NETWORK);
            }
            throw err;
        } finally {
            clearTimeout(timer);
        }
    }
}

// 使用
const api = new HttpClient("https://api.example.com");
try {
    const data = await api.request("/users/1");
} catch (e) {
    showToast(e.message); // 用户友好的中文提示
    console.error(e);     // 开发者查看完整错误
}

试一试

练习一

任务:写一个 safeParse 函数,安全解析 JSON 字符串。解析成功返回数据,失败返回 null 并打印错误。

// 提示:用 try/catch 包裹 JSON.parse
// 你的代码:

参考答案

function safeParse(str) {
    try {
        return JSON.parse(str);
    } catch (e) {
        console.error("JSON 解析失败:", e.message);
        return null;
    }
}

console.log(safeParse('{"name":"张三"}')); // {name: "张三"}
console.log(safeParse("invalid"));         // null

练习二

任务:创建一个 AppError 类,包含 code 字段。然后写一个函数模拟用户注册,对用户名长度做验证(3-20 字符)。

// 提示:继承 Error,添加 code 字段
// 你的代码:

参考答案

class AppError extends Error {
    constructor(message, code) {
        super(message);
        this.name = "AppError";
        this.code = code;
    }
}

function register(username) {
    if (!username || username.length < 3) {
        throw new AppError("用户名至少3个字符", "NAME_TOO_SHORT");
    }
    if (username.length > 20) {
        throw new AppError("用户名不能超过20字符", "NAME_TOO_LONG");
    }
    return { success: true, username };
}

try {
    register("ab");
} catch (e) {
    if (e instanceof AppError) {
        console.log(`[${e.code}] ${e.message}`);
    }
}

练习三

任务:实现一个 RetryPolicy 类,支持三种重试策略:fixed(固定间隔)、exponential(指数退避)、自定义函数。该类有一个 execute(fn) 方法,接受一个异步函数,失败时按策略重试,最多重试 3 次。

// 提示:用策略模式,sleep 函数辅助延迟
// const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// 你的代码:

参考答案

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

class RetryPolicy {
    constructor(strategy = "fixed", baseDelay = 1000, maxRetries = 3) {
        this.strategy = strategy;
        this.baseDelay = baseDelay;
        this.maxRetries = maxRetries;
    }

    getDelay(attempt) {
        if (typeof this.strategy === "function") {
            return this.strategy(attempt);
        }
        if (this.strategy === "exponential") {
            return this.baseDelay * Math.pow(2, attempt);
        }
        return this.baseDelay; // fixed
    }

    async execute(fn) {
        let lastError;
        for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
            try {
                return await fn();
            } catch (err) {
                lastError = err;
                if (attempt < this.maxRetries) {
                    const delay = this.getDelay(attempt);
                    console.log(`第 ${attempt + 1} 次失败,${delay}ms 后重试...`);
                    await sleep(delay);
                }
            }
        }
        throw lastError;
    }
}

// 测试:模拟一个前两次失败、第三次成功的函数
let callCount = 0;
async function unstableApi() {
    callCount++;
    if (callCount < 3) throw new Error("服务暂时不可用");
    return { data: "成功" };
}

// 固定间隔重试
const fixedRetry = new RetryPolicy("fixed", 500);
const result = await fixedRetry.execute(unstableApi);
console.log(result); // { data: "成功" }

// 指数退避重试(500ms -> 1000ms -> 2000ms)
const expRetry = new RetryPolicy("exponential", 500);

// 自定义策略
const customRetry = new RetryPolicy(
    (attempt) => [200, 500, 1000][attempt] || 1000
);

📝 要点总结

本章核心知识点

try/catch/finally

捕获异常,确保清理逻辑执行

内置错误

TypeError、SyntaxError、RangeError 等

自定义错误

继承 Error 类,添加业务上下文

调试工具

DevTools 断点、调用栈、变量监视

防御性编程

输入校验、边界检查、优雅降级

下一章

错误处理和调试技能就绪。下一章学习正则表达式基础——用简洁的模式匹配和操作文本。

1 / X