Node.js 回调与回调地狱
上篇 Node.js buffer 模块 讲完二进制数据处理,紧接着就要接触读写文件这类异步 I/O,你会发现代码风格突然变了。读个文件要传一个函数进去,结果在函数里拿,执行顺序还和书写顺序对不上。这篇讲清楚回调从哪来、错误优先回调是什么约定,以及为什么嵌套多了会变成回调地狱。
为什么 Node.js 到处都是回调
读文件很慢,不能干等
磁盘读取、网络请求这类 I/O 操作,耗时是内存运算的上千倍。用同步方式读文件,主线程就卡在那里等磁盘转完,期间什么都干不了。服务器场景里,一个请求读文件等 5 毫秒,一百个请求依次排队,后面的用户响应就慢得离谱。
非阻塞 I/O 的代价
Node.js 选择不等待。发起 I/O 后立刻返回,主线程继续处理其他请求,等 I/O 完成再回来拿结果。可结果什么时候到说不准,没法像普通函数那样写在调用语句的下一行,只能把”接下来要做什么”打包成一个函数传进去,让 Node.js 干完活来调用它。这个传进去的函数就是回调 (callback)。
回调的本质
回调就是函数作为参数,被另一个函数在合适的时机调用。它分两类。
同步回调,在函数内部直接调用,比如数组的 forEach
[1, 2, 3].forEach((n) => console.log(n));
异步回调,在 I/O 完成之后才被调用,调用时机由事件循环决定,本篇讲的都是这一类。
两者共同点是调用时机都由对方决定,不同点是同步回调立即执行,异步回调要等 I/O 结束,期间主线程已经跑去做别的事了。
定时器是异步回调最简单的例子
setTimeout(() => {
console.log("一秒后执行");
}, 1000);
console.log("先执行这行");
输出顺序是先打印”先执行这行”,一秒后才打印”一秒后执行”。回调传进去之后,调用方不立刻执行,而是等到条件满足再调用。事件监听、定时器、I/O 完成通知,底层都是同一个模式。
错误优先回调
Node.js 给异步回调定了一个约定,叫错误优先 (Error-First Callback)。回调的第一个参数必须是 err,没有错误时为 null,后面才是真正的数据。fs 模块就是这种风格
const fs = require("node:fs");
fs.readFile("a.txt", "utf8", (err, data) => {
if (err) {
console.error("读取失败", err.message);
return;
}
console.log(data);
});
先检查 err 再使用 data,这个顺序不能颠倒。err 放在第一位是有原因的,异步错误没法像同步代码那样用 try/catch 捕获,只能靠回调参数把错误传回来,参数位置固定,调用方才不会漏接。
回调地狱长什么样
需求是依次读 a.txt、b.txt、c.txt,三个文件都读完再合并输出。回调写法必须把下一次读取嵌进上一次的回调里
const fs = require("node:fs");
fs.readFile("a.txt", "utf8", (err, dataA) => {
if (err) {
console.error("读 a.txt 失败", err.message);
return;
}
fs.readFile("b.txt", "utf8", (err, dataB) => {
if (err) {
console.error("读 b.txt 失败", err.message);
return;
}
fs.readFile("c.txt", "utf8", (err, dataC) => {
if (err) {
console.error("读 c.txt 失败", err.message);
return;
}
console.log(dataA + dataB + dataC);
});
});
});
代码一层层往右缩进,形成金字塔形状,这就是回调地狱 (Callback Hell)。
回调地狱的问题
| 问题 | 具体表现 |
|---|---|
| 缩进爆炸 | 嵌套三层以上就难以阅读,行尾堆着一串右括号 |
| 错误处理困难 | 每层都要单独检查 err,漏一处错误就静默丢失 |
| 难以复用 | 逻辑拆不开,想单独复用”读 b 再读 c”就得整体复制 |
| 难以并行 | 想同时读三个文件,嵌套结构根本无从下手 |
Node.js 里回调的常见位置
回调不是 fs 专属,Node.js 的异步 API 基本全是这个风格
| 模块 | 回调场景 |
|---|---|
| fs | readFile、writeFile 等文件操作 |
| http | 请求完成、响应数据到达、连接关闭 |
| net | 连接建立、数据接收 |
| events | 事件监听器本身就是回调 |
| child_process | 子进程退出、输出收集 |
判断一个 API 是不是异步回调风格,看两点,最后一个参数是不是函数,函数第一个参数是不是 err。两个都符合,就是错误优先回调。
实践
把上面那段代码存成 callback-hell.js。先创建三个文本文件
echo "第一段内容" > a.txt
echo "第二段内容" > b.txt
echo "第三段内容" > c.txt
然后运行
node callback-hell.js
输出是三个文件内容按顺序拼接的结果。接下来做两个小实验加深理解。
第一个,删掉 b.txt 再运行,观察错误在哪一层报出来、后面几层还执行不执行。
第二个,在读取 a.txt 的回调里同时发起读 b 和读 c,例如
const fs = require("node:fs");
fs.readFile("a.txt", "utf8", (err, dataA) => {
if (err) {
console.error("读 a.txt 失败", err.message);
return;
}
fs.readFile("b.txt", "utf8", (err, dataB) => {
if (err) {
console.error("读 b.txt 失败", err.message);
return;
}
console.log("b 完成", dataB);
});
fs.readFile("c.txt", "utf8", (err, dataC) => {
if (err) {
console.error("读 c.txt 失败", err.message);
return;
}
console.log("c 完成", dataC);
});
console.log("合并结果", dataA);
});
多跑几次,观察 b 和 c 的完成顺序是不是每次都一样,再想想合并三个文件的代码该放在哪里才拿得到全部数据。
常见坑
第一个坑,不检查 err 直接使用 data。文件不存在时 data 是 undefined,报的错既难懂又难查,先查 err 是铁律。
第二个坑,以为 return 能终止外层代码。回调里的 return 只退出当前回调函数,外层函数照常执行,所以每一层的 err 检查都必须完整写。
第三个坑,把同步思维套进来。readFile 之后立刻用 data,此时回调还没执行,拿到的永远是 undefined。想验证可以打印一下,顺序和直觉完全不同。
第四个坑,嵌套太深后忘记在某层 return,导致成功路径和失败路径同时执行,数据被处理两遍。
第五个坑,把 readFile 和 readFileSync 混着用。readFileSync 直接返回数据,readFile 返回 undefined,数据在回调里,两个签名看起来像,用法完全不同,混用会写出”有时候有值,有时候 undefined”的代码。
作业
用回调方式依次读三个文件再合并输出,你已经感受过嵌套的痛了。思考怎么改才能让这段代码平铺开来,错误只处理一次,还能顺便支持三个文件同时读。下一章 Node.js Promise 异步编程 就给出答案。