Node.js 基于事件驱动和非阻塞 I/O 模型,使其能够高效处理并发请求。而这一切的核心就是回调函数和异步编程。本章将深入探讨回调函数的概念、使用场景、常见陷阱以及如何优雅地处理异步流程。
在传统同步编程中,代码按顺序执行,每个操作必须等待前一个操作完成才能继续。例如,读取文件时,程序会阻塞直到文件读取完毕。而在异步编程中,耗时的操作(如文件 I/O、网络请求)被发起后,程序会继续执行后续代码,当操作完成时通过回调函数通知结果。这种方式避免了阻塞,提高了系统的吞吐量。
Node.js 中的所有 I/O 操作都是异步的(除了部分同步 API),它们接受一个回调函数作为参数,在操作完成后被调用。
回调函数就是一个作为参数传递给另一个函数的函数,它会在特定事件发生或条件满足时被调用。在 Node.js 中,回调函数通常用于处理异步操作的结果。
虽然回调通常与异步相关,但也可以用于同步场景,例如数组的 forEach:
const arr = [1, 2, 3];
arr.forEach((item) => {
console.log(item);
});
console.log('同步回调结束');
这里的回调是同步执行的,因为 forEach 会立即为每个元素调用回调。
下面是一个典型的异步回调:使用 fs.readFile 读取文件:
const fs = require('fs');
console.log('开始读取文件');
fs.readFile('example.txt', 'utf8', (err, data) => {
if (err) {
console.error('读取失败:', err);
return;
}
console.log('文件内容:', data);
});
console.log('读取操作已发起,继续执行其他代码');
输出顺序:
开始读取文件
读取操作已发起,继续执行其他代码
文件内容: ...
可见,程序不会等待文件读取完成,而是继续执行后续代码,当文件读取完毕后再调用回调函数。
Node.js 中广泛采用一种约定:回调函数的第一个参数是错误对象(如果没有错误,则为 null 或 undefined),后续参数是成功的结果。这被称为“错误优先回调”。
const fs = require('fs');
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
// 处理错误
console.error('发生错误:', err);
} else {
// 处理成功数据
console.log('文件内容:', data);
}
});
这种模式确保错误总是被优先处理,避免遗漏异常情况。
当需要顺序执行多个异步操作,且每个操作依赖上一个操作的结果时,如果继续使用嵌套回调,代码会迅速变得难以阅读和维护,形成所谓的“回调地狱”或“金字塔厄运”。
fs.readFile('a.txt', 'utf8', (err, dataA) => {
if (err) throw err;
fs.readFile('b.txt', 'utf8', (err, dataB) => {
if (err) throw err;
fs.readFile('c.txt', 'utf8', (err, dataC) => {
if (err) throw err;
console.log('合并结果:', dataA + dataB + dataC);
});
});
});
这样的代码不仅难以阅读,而且错误处理重复、难以扩展。
有几种方式可以改善回调地狱,使异步代码更清晰。
将嵌套的回调函数提取为具名函数,减少嵌套层级:
const fs = require('fs');
function readBCallback(err, dataB) {
if (err) throw err;
fs.readFile('c.txt', 'utf8', readCCallback.bind(null, dataB));
}
function readCCallback(dataB, err, dataC) {
if (err) throw err;
console.log('结果:', dataA + dataB + dataC);
}
let dataA;
fs.readFile('a.txt', 'utf8', (err, data) => {
if (err) throw err;
dataA = data;
fs.readFile('b.txt', 'utf8', readBCallback);
});
虽然减少了嵌套,但代码变得分散,逻辑不够直观。
async 库提供了一系列流程控制函数,如 async.series、async.waterfall 等。例如使用 async.waterfall 解决上述问题:
const async = require('async');
const fs = require('fs');
async.waterfall([
(cb) => fs.readFile('a.txt', 'utf8', cb),
(dataA, cb) => fs.readFile('b.txt', 'utf8', (err, dataB) => cb(err, dataA, dataB)),
(dataA, dataB, cb) => fs.readFile('c.txt', 'utf8', (err, dataC) => cb(err, dataA + dataB + dataC))
], (err, result) => {
if (err) throw err;
console.log('合并结果:', result);
});
这种方式将任务扁平化为数组,但仍需处理回调。
ES6 引入的 Promise 大大改善了异步编程体验,而 ES2017 的 async/await 则让异步代码看起来几乎像同步代码。虽然本章主要讲回调,但简单展示 Promise 版本作为对比:
const fs = require('fs').promises;
async function mergeFiles() {
try {
const dataA = await fs.readFile('a.txt', 'utf8');
const dataB = await fs.readFile('b.txt', 'utf8');
const dataC = await fs.readFile('c.txt', 'utf8');
console.log('合并结果:', dataA + dataB + dataC);
} catch (err) {
console.error(err);
}
}
mergeFiles();
后续章节将详细介绍 Promise 和 async/await。
在异步回调中,错误处理尤为重要。以下是一些建议:
throw err),因为异常无法在异步上下文中被捕获。应使用回调传递错误或使用 Promise 的 reject。process.on('uncaughtException') 捕获未被处理的异常,但这应作为最后手段。fs.readFile('file.txt', (err, data) => {
if (err) {
// 适当处理错误,例如记录日志并返回
console.error('文件读取失败:', err);
return; // 必须返回,防止继续执行
}
// 处理数据
});
有时,API 可能同时提供同步和异步版本。混合使用可能导致意外。例如,在异步回调中调用同步方法可能阻塞事件循环。尽量在异步操作中使用异步版本。
另一个陷阱是“Zalgo”问题:一个函数在某些情况下是同步的,在某些情况下是异步的,导致不可预测的行为。应保持函数的一致性:要么总是同步,要么总是异步。
我们来自定义一个异步函数,它模拟耗时操作(如数据库查询),并采用错误优先回调:
function getUserById(id, callback) {
// 模拟异步操作
setTimeout(() => {
if (typeof id !== 'number' || id <= 0) {
// 错误情况
callback(new Error('无效的用户ID'));
} else {
// 成功情况
const user = { id, name: `User${id}` };
callback(null, user);
}
}, 100);
}
// 使用
getUserById(1, (err, user) => {
if (err) {
console.error('查询失败:', err);
} else {
console.log('用户信息:', user);
}
});
虽然回调是 Node.js 异步的基础,但现代开发中更推荐使用 Promise 和 async/await,它们提供了更清晰的错误处理和流程控制。然而,理解回调仍然重要,因为许多核心模块和第三方库仍使用回调,并且它是 Promise 的基础。
本章介绍了回调函数在 Node.js 异步编程中的核心作用,从基本概念到错误处理,再到回调地狱及其解决方案。回调是理解更高级异步模式(如 Promise 和 async/await)的基础。在下一章中,我们将深入学习 Promise 对象,探索更优雅的异步编程方式。