CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Node.js 后端入门/课时/错误处理
RawBlame
课时目录12 节

Node.js 后端入门

  1. 01Node.js 是什么
  2. 02运行时与 process
  3. 03模块与 npm
  4. 04文件系统与 path
  5. 05http 模块
  6. 06Express 入门
  7. 07事件循环与异步
  8. 08环境变量与配置
  9. 09Streams 基础
  10. 10错误处理
  11. 11测试 node:test
  12. 12综合练习与选型

错误处理

同步/异步错误、未捕获异常、Express 错误中间件。

30 分钟

错误处理

这一课你将能做到

  1. 区分四类错误的处理方式:同步异常、Promise 拒绝、回调错误、全局兜底
  2. 定义业务错误类并携带状态码,把错误一路传到统一的出口
  3. 配置 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:

// 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)

两个铁律:

  1. 兜底 handler 里做「记录日志 + 有序退出」,不要试图恢复继续服务——发生兜底级错误时进程内部状态(缓存、连接池)已不可信。
  2. 兜底不是免死金牌:生产环境靠进程管理器(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 错误自动转发 → 错误中间件按类型分流(业务错误返回细节,未知错误打日志、回模糊信息)→ 兜底事件收最后一道。

讲清楚要点

  1. 四类错误四个入口:同步 throw→try/catch;Promise→catch/await;回调→error-first 首参;EventEmitter→error 事件监听。流和 server 的 error 不监听就是崩溃。
  2. catch 的职责是「分类」不是「吞掉」:业务错误(AppError)带状态码返回;未知错误记日志后继续上抛或触发退出。只 console.log(err) 然后装没事发生,是最恶劣的反模式。
  3. err.code 优于字符串匹配:err.code === 'ENOENT' 判断文件缺失、ECONNREFUSED 判断连不上服务,比解析 message 文案可靠(文案随版本变)。
  4. 兜底事件是「遗言处理器」:uncaughtException/unhandledRejection 里记日志、退出码非 0,重启交给 pm2/容器。别在里面继续跑业务。
  5. 对外泄露要克制: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 直接用 dataENOENT 时 data 是 undefined,下游炸error-first:先 if (err) return ...
流不挂 error 事件读失败 = uncaughtException = 进程崩每条流/每个 server 都挂 error
兜底 handler 里尝试恢复业务进程状态不可信,越修越坏记日志 + process.exit(1) + 外部重启
500 响应返回完整 stack泄露内部结构,给攻击者递刀对外模糊信息,细节进日志
对非幂等操作盲目重试重复下单/扣款重试前确认幂等,或用幂等键

卡点急救

  1. 进程莫名退出且只打了 throw er; // Unhandled 'error' event:某个 EventEmitter(server/流)的 error 事件没人监听。全文搜索 createReadStream、net.createServer 等创建点,补 error 监听。
  2. Cannot read properties of undefined:程序员错误,看 stack 第一行定位文件与行列;常见根因是上游函数「出错时返回 undefined」被下游直接使用——修上游的返回约定。
  3. 错误日志里只有 message 没有 stack:console.error(err) 要传整个错误对象而不是 err.message;自定义 logger 也要序列化 stack。
  4. unhandledRejection 警告刷屏:逐个补 .catch 或 await;临时止血可在兜底监听里打日志找出是哪条 Promise(Node 会打印 reason 的 stack)。
  5. 重启风暴:进程秒崩、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。

自测三问

  1. 流的 error 事件没监听会发生什么?和 try/catch 是什么关系?
  2. 自定义 AppError 要携带哪些信息?catch 里如何区分业务错误和未知错误?
  3. uncaughtException 的监听器里写「重启数据库连接并继续服务」对吗?为什么?

小结一句话

错误处理 = 四个入口(try/catch、Promise、error-first、error 事件)+ 一个分类纪律(业务错误带码返回、未知错误上抛)+ 一个遗言机制(兜底日志后退出,重启交给进程管理器)。

延伸

  • Node.js 官方指南:错误处理
  • Express 错误处理文档
  • 下一课:测试 node:test——Node 20 内置测试框架,不装 Jest 也能把质量关立起来。
加载本节练习…
← 上一课Streams 基础下一课 →测试 node:test