错误处理
这一课你将能做到
- 区分四类错误的处理方式:同步异常、Promise 拒绝、回调错误、全局兜底
- 定义业务错误类并携带状态码,把错误一路传到统一的出口
- 配置
uncaughtException / unhandledRejection 兜底,并知道它们救不了什么
为什么要学
代码只写「正常路径」等于只盖了半栋楼。Node 的错误处理格外要命的一点是:一个没接住的异步错误,默认行为是整个进程崩掉——所有正在服务的请求全部陪葬。生产上「凌晨三点进程消失」的事故,多半就是某处异步错误没人接。
人话:错误处理就是把「失控的崩溃」变成「可控的失败」——该重试的重试、该返回 400 的返回 400、实在救不了的优雅退出并留下日志。这课是前面所有课程的地基加固:fs、http、Express、流,每一层的错误都各有脾气。
先建立图像
错误的四种来源与对应处理:
┌───────────────────┬────────────────────────────┐
│ 同步代码 throw │ → try/catch │
│ Promise reject │ → .catch / await+try/catch │
│ 回调 error-first │ → 判断第一个参数 err │
│ EventEmitter error │ → 监听 'error' 事件 │
└───────────────────┴────────────────────────────┘
四层都失守 → uncaughtException / unhandledRejection → 进程兜底
错误的两大类型:
运行期错误(Operational):文件不存在、网络超时、参数非法
→ 预期内,捕获、重试或返回给调用方
程序员错误(Programmer):调错 API、null 解引用、逻辑漏洞
→ 预期外,修 bug;兜底日志 + 重启进程
先分清「哪类错误」,再决定「怎么处理」——很多新手把程序员错误也用重试硬扛,越扛越糊涂。
跟着做
步骤 1:同步异常与 try/catch
新建 node-errors 目录,npm init -y,新建 sync.js:
// sync.js
function parseConfig(raw) {
const obj = JSON.parse(raw); // 非法 JSON 会同步 throw
if (!obj.port) {
throw new RangeError('配置缺少 port 字段'); // 主动抛
}
return obj;
}
// 错误的 JSON:触发 JSON.parse 的 SyntaxError
try {
parseConfig('{oops');
} catch (err) {
console.log('捕获 1:', err.name, '-', err.message);
}
// 合法 JSON 但缺字段:触发自定义 RangeError
try {
parseConfig('{"host":"localhost"}');
} catch (err) {
console.log('捕获 2:', err.name, '-', err.message);
} finally {
console.log('finally 无论成败都执行(适合清理)');
}
node sync.js
# 捕获 1: SyntaxError - Unexpected token 'o'...
# 捕获 2: RangeError - 配置缺少 port 字段
# finally 无论成败都执行(适合清理)
Error 三件套:message(人读的描述)、name(错误类型名)、stack(调用栈,排障金线索)。
步骤 2:Promise 与 async/await 的捕获
// async-errors.js
const fsp = require('node:fs/promises');
// 写法一:.then / .catch 链式
function byThen() {
fsp.readFile('不存在的文件.txt', 'utf8')
.then((data) => console.log(data))
.catch((err) => console.log('then/catch 捕获:', err.code)); // ENOENT
}
// 写法二:await + try/catch(推荐)
async function byAwait() {
try {
const data = await fsp.readFile('不存在的文件.txt', 'utf8');
console.log(data);
} catch (err) {
console.log('await 捕获:', err.code); // ENOENT
}
}
async function main() {
byThen();
await byAwait();
}
main();
// then/catch 捕获: ENOENT
// await 捕获: ENOENT
两种写法等价,团队统一一种即可。关键纪律:每个 Promise 必须有人负责——不 await 也写 .catch() 的「发射后不管」是 unhandledRejection 的头号来源。
步骤 3:回调错误与 error-first 约定
老式 Node API(及很多老库)用回调传错误,约定第一个参数永远是 err:
// callback-errors.js
const fs = require('node:fs');
fs.readFile('不存在的文件.txt', 'utf8', (err, data) => {
if (err) { // error-first:先判错再取值
console.log('回调捕获:', err.code);
return;
}
console.log(data);
});
node callback-errors.js
# 回调捕获: ENOENT
EventEmitter 风格的错误(流的 error 事件、server 的 error 事件)不走 try/catch,必须挂监听:
// emitter-errors.js —— 不监听 error 事件的流 = 崩溃
const fs = require('node:fs');
const stream = fs.createReadStream('不存在的文件.txt');
stream.on('error', (err) => { // 没有这行:uncaughtException,进程崩
console.log('事件捕获:', err.code);
});
两种「看不见 try/catch」的错误处理,是 Node 与其他语言差异最大的地方。
步骤 4:自定义业务错误类
裸 Error 表达不了「这是 404 还是 500」,封装一层:
// app-error.js
class AppError extends Error {
/**
* @param {string} message 错误描述
* @param {number} status HTTP 状态码
* @param {string} [code] 机器可读错误码
*/
constructor(message, status = 500, code = 'INTERNAL_ERROR') {
super(message);
this.name = 'AppError';
this.status = status;
this.code = code;
Error.captureStackTrace?.(this, AppError); // 栈从抛出点开始,干净
}
}
module.exports = { AppError };
// use-error.js
const { AppError } = require('./app-error');
function findUser(id) {
if (!Number.isInteger(id)) {
throw new AppError('用户 ID 必须是整数', 400, 'INVALID_ID');
}
throw new AppError(`用户 ${id} 不存在`, 404, 'USER_NOT_FOUND');
}
try {
findUser('abc');
} catch (err) {
if (err instanceof AppError) {
console.log(`业务错误: [${err.code}] ${err.message} → HTTP ${err.status}`);
} else {
console.log('未知错误,按 500 处理:', err.message);
throw err; // 不认识的错误继续往上抛,别吞
}
}
// 业务错误: [INVALID_ID] 用户 ID 必须是整数 → HTTP 400
instanceof 分支的价值:业务错误(预期内)返回给调用方;未知错误(预期外)继续上抛交给全局兜底。catch 里分不清这两种,就会把 bug 当业务处理,掩耳盗铃。
步骤 5:全局兜底——最后的安全网
// global-guard.js
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err);
// 给一点时间把日志刷出去,然后退出——进程状态已不可信
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('[unhandledRejection]', reason);
process.exit(1);
});
// 主动复现:没接住的同步异常
setTimeout(() => {
throw new Error('boom:没人接住的同步异常');
}, 100);
// 主动复现:没接住的 Promise 拒绝(取消注释试试)
// Promise.reject(new Error('没人 catch 的拒绝'));
node global-guard.js
# [uncaughtException] Error: boom:没人接住的同步异常
# (退出码 1)
- 兜底 handler 里做「记录日志 + 有序退出」,不要试图恢复继续服务——发生兜底级错误时进程内部状态(缓存、连接池)已不可信。
- 兜底不是免死金牌:生产环境靠进程管理器(pm2、Docker 的 restart 策略、K8s)拉起重启,兜底只负责「死前留下遗言」。
步骤 6:完整链路——Express 里的错误旅程
把前面所有知识串成一个真实形态(对照第 6 课):
// express-errors.js
const express = require('express');
const { AppError } = require('./app-error');
const app = express();
app.use(express.json());
// 业务路由:只管抛 AppError,不写 try/catch(Express 5 自动转发 async 错误)
app.get('/api/users/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
throw new AppError('用户 ID 必须是整数', 400, 'INVALID_ID');
}
// ...查库,查不到就:
throw new AppError(`用户 ${id} 不存在`, 404, 'USER_NOT_FOUND');
});
// 404 兜底
app.use((req, res) => {
res.status(404).json({ error: 'NOT_FOUND' });
});
// 全站错误出口:唯一的 res.status(500) 所在地
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.status).json({ error: err.code, message: err.message });
}
// 程序员错误:详细进日志,对外只给模糊信息
console.error('[unexpected]', err);
res.status(500).json({ error: 'INTERNAL_ERROR' });
});
app.listen(3000, () => console.log('http://localhost:3000'));
// 进程兜底(生产必备)
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('[unhandledRejection]', reason);
process.exit(1);
});
错误旅程:业务代码 throw AppError → async 错误自动转发 → 错误中间件按类型分流(业务错误返回细节,未知错误打日志、回模糊信息)→ 兜底事件收最后一道。
讲清楚要点
- 四类错误四个入口:同步 throw→try/catch;Promise→catch/await;回调→error-first 首参;EventEmitter→
error 事件监听。流和 server 的 error 不监听就是崩溃。
- catch 的职责是「分类」不是「吞掉」:业务错误(AppError)带状态码返回;未知错误记日志后继续上抛或触发退出。只
console.log(err) 然后装没事发生,是最恶劣的反模式。
err.code 优于字符串匹配:err.code === 'ENOENT' 判断文件缺失、ECONNREFUSED 判断连不上服务,比解析 message 文案可靠(文案随版本变)。
- 兜底事件是「遗言处理器」:uncaughtException/unhandledRejection 里记日志、退出码非 0,重启交给 pm2/容器。别在里面继续跑业务。
- 对外泄露要克制:500 响应里只给
INTERNAL_ERROR 这类模糊信息,stack 和原始 message 进服务端日志——否则把内部路径、SQL 片段直接暴露给调用方。
你可能会懵的地方
-
Error、TypeError、RangeError 什么区别?
都是 Error 的子类:TypeError 是类型不对(null.foo)、RangeError 是数值越界(new Array(-1))、SyntaxError 是语法/解析错误(非法 JSON)。自定义业务错误继承 Error 加字段,比往 message 里塞约定字符串干净。
-
catch 里到底该 return 还是 throw?
看职责:当前函数能处理(转默认值、重试后成功)就 return 正常值;处理不了就记录后 throw 原错误(或 throw new AppError(..., { cause: err }) 保留原因链)。绝不能捕获后什么都不做。
-
uncaughtException 之后进程还能继续跑吗?
技术上监听器执行完进程不会立刻死,但官方明确不建议继续服务:状态可能已损坏(写入了一半的数据、断掉的连接)。正确姿势:记日志 → 退出 → 外部重启。
-
err.stack 能信吗?
大体可信,但 async 链会截断调用栈(await 之前的帧不在里面);Error.captureStackTrace(this, fn) 可以裁掉内部实现帧。日志里 stack 通常是定位异步 bug 的唯一线索,别省。
-
什么时候重试?
只重试「运行期瞬时错误」:网络超时、连接被重置、429。指数退避(1s → 2s → 4s)加最大次数。重试必须幂等(重复执行不产生副作用),否则可能重复下单、重复扣款。
错法对照
| 错法 | 问题 | 正解 |
|---|
try { ... } catch (e) { console.log(e) } 继续跑 | 错误被吞,坏数据扩散 | 分类处理或重新抛出 |
回调里不判 err 直接用 data | ENOENT 时 data 是 undefined,下游炸 | error-first:先 if (err) return ... |
流不挂 error 事件 | 读失败 = uncaughtException = 进程崩 | 每条流/每个 server 都挂 error |
| 兜底 handler 里尝试恢复业务 | 进程状态不可信,越修越坏 | 记日志 + process.exit(1) + 外部重启 |
| 500 响应返回完整 stack | 泄露内部结构,给攻击者递刀 | 对外模糊信息,细节进日志 |
| 对非幂等操作盲目重试 | 重复下单/扣款 | 重试前确认幂等,或用幂等键 |
卡点急救
- 进程莫名退出且只打了
throw er; // Unhandled 'error' event:某个 EventEmitter(server/流)的 error 事件没人监听。全文搜索 createReadStream、net.createServer 等创建点,补 error 监听。
Cannot read properties of undefined:程序员错误,看 stack 第一行定位文件与行列;常见根因是上游函数「出错时返回 undefined」被下游直接使用——修上游的返回约定。
- 错误日志里只有 message 没有 stack:
console.error(err) 要传整个错误对象而不是 err.message;自定义 logger 也要序列化 stack。
- unhandledRejection 警告刷屏:逐个补
.catch 或 await;临时止血可在兜底监听里打日志找出是哪条 Promise(Node 会打印 reason 的 stack)。
- 重启风暴:进程秒崩、pm2 秒拉起循环打转。先看启动日志的崩溃原因(多半是配置缺失/端口占用),兜底之外要给进程管理器配 backoff。
面试常问
-
Node 中错误处理的几种方式?
同步代码 try/catch;Promise 用 .catch 或 await+try/catch;回调遵循 error-first(首参 err);EventEmitter 类(流、server)监听 error 事件。最后由 uncaughtException / unhandledRejection 进程级兜底,记录后退出,由进程管理器重启。
-
uncaughtException 和 unhandledRejection 的区别?捕获后该做什么?
前者是没接住的同步异常/EventEmitter 错误,后者是没人处理的 Promise 拒绝。捕获后应记录日志并以非 0 退出码退出——进程状态已不可信,不要尝试继续服务;恢复交给 pm2/Docker/K8s 的重启策略。
-
Operational 错误和 Programmer 错误怎么区分处理?
Operational 是运行期可预期错误(文件不存在、超时、参数非法),应捕获、重试或返回给调用方;Programmer 是代码缺陷(空指针、逻辑错),不应掩盖,记录完整 stack、快速失败并重启,修复根因。
-
Express 中错误如何统一处理?
业务层抛携带状态码的错误对象,async 路由的 rejection 由框架转发(Express 5 原生支持),最终汇入四参数错误中间件:AppError 按其 status 返回,未知错误记日志后统一 500,对外不泄露 stack。
自测三问
- 流的
error 事件没监听会发生什么?和 try/catch 是什么关系?
- 自定义 AppError 要携带哪些信息?catch 里如何区分业务错误和未知错误?
- uncaughtException 的监听器里写「重启数据库连接并继续服务」对吗?为什么?
小结一句话
错误处理 = 四个入口(try/catch、Promise、error-first、error 事件)+ 一个分类纪律(业务错误带码返回、未知错误上抛)+ 一个遗言机制(兜底日志后退出,重启交给进程管理器)。
延伸