fs 模块是 Node.js 中使用频率最高的内置模块之一,但它提供的 API 有三种调用风格:同步、回调异步以及基于 Promise 的异步。理解这三种方式的差异、适用场景以及如何相互转换,是掌握文件操作的基础。
三种调用方式概览
Node.js 的 fs 模块几乎对每一个文件操作都提供了同步和异步两个版本,而在 Node.js 10 之后,又通过 fs/promises 提供了官方的 Promise 接口。开发者可以根据需要灵活选择。
| 调用方式 | 函数命名特征 | 返回值 | 错误处理 | 是否阻塞 |
|---------|-------------|--------|---------|---------|
| 同步 | 以 Sync 结尾(如 readFileSync) | 直接返回数据 | 抛出异常(try/catch) | 是 |
| 回调异步 | 无 Sync 后缀,接受回调函数 | undefined | 通过回调的第一个参数传递错误 | 否 |
| Promise 异步 | 位于 fs/promises,函数名无 Sync | Promise 对象 | 通过 catch 或 try/catch (await) 捕获 | 否 |
下面通过一个读取 JSON 配置文件的例子来演示这三种方式。
1. 同步 API:fs.readFileSync
const fs = require('fs');
const path = require('path');
function readConfigSync() {
try {
const raw = fs.readFileSync(path.join(__dirname, 'config.json'), 'utf-8');
return JSON.parse(raw);
} catch (err) {
console.error('读取配置文件失败:', err.message);
return {};
}
}
const config = readConfigSync();
console.log(config.port);
同步 API 的优点是代码直观,适合在程序初始化阶段使用,例如在服务启动前加载配置文件。如果在请求处理过程中使用同步 API,会因为阻塞事件循环而导致并发能力急剧下降,应严格避免。
2. 回调异步 API:fs.readFile
fs.readFile('config.json', 'utf-8', (err, data) => {
if (err) {
console.error('读取失败:', err);
return;
}
try {
const config = JSON.parse(data);
console.log(config.port);
} catch (parseErr) {
console.error('解析 JSON 失败:', parseErr);
}
});
这是 Node.js 早期最常见的写法,遵循 错误优先回调(Error-first Callback)约定:回调的第一个参数是错误对象(没有错误时为 null),第二个参数是结果数据。这种方式不会阻塞事件循环,但当需要多个异步操作顺序执行时,容易陷入“回调地狱”。
3. Promise 异步 API:fs/promises
从 Node.js 10 开始,fs/promises 提供了返回 Promise 的文件操作接口,可以直接与 async/await 配合使用:
const fsp = require('fs/promises');
async function readConfigAsync() {
try {
const raw = await fsp.readFile('config.json', 'utf-8');
return JSON.parse(raw);
} catch (err) {
console.error('读取失败:', err);
return {};
}
}
// 使用
readConfigAsync().then(config => {
console.log(config.port);
});
这种写法兼具同步代码的清晰性和异步代码的非阻塞特性,是目前推荐的主流用法。fs/promises 中所有方法都返回 Promise,可以非常方便地配合 Promise.all 进行并发操作:
const [users, products] = await Promise.all([
fsp.readFile('users.json', 'utf-8').then(JSON.parse),
fsp.readFile('products.json', 'utf-8').then(JSON.parse),
]);
将回调风格 API Promise 化
在实际项目中,除了 fs 模块,还有许多第三方库仍然使用错误优先的回调风格。为了统一使用 async/await,Node.js 在 util 模块中提供了 promisify 工具函数,可以将遵循错误优先回调的异步函数转换为返回 Promise 的函数。
const fs = require('fs');
const util = require('util');
// 将 fs.readFile 转换为 Promise 版本
const readFilePromise = util.promisify(fs.readFile);
async function getContent(filePath) {
try {
const data = await readFilePromise(filePath, 'utf-8');
return data;
} catch (err) {
console.error('读取错误:', err);
}
}
util.promisify 要求原函数的大致签名是 (arg1, arg2, ..., callback),且回调函数的第一个参数是 error。转换后,返回的新函数除了不再接受回调参数外,其他参数与原函数一致。
你也可以对一个对象上的多个方法批量转换:
const fs = require('fs');
const util = require('util');
const fsAsync = {
readFile: util.promisify(fs.readFile),
writeFile: util.promisify(fs.writeFile),
readdir: util.promisify(fs.readdir),
};
但更推荐直接使用 fs/promises,因为它是官方维护的,类型定义更完善,且在 Node.js 12+ 中功能已与回调版本对齐。
选型建议:何时用同步、何时用异步?
- 服务器请求处理:绝对不要使用同步 API。一个
readFileSync会让整个事件循环停顿,造成所有请求响应变慢。 - 启动时的一次性操作:如加载配置文件、初始化全局变量,可以使用同步 API,因为此时服务还未接受请求,阻塞不会影响并发性能。
- 工具脚本与命令行程序:短生命周期的脚本使用同步 API 通常更简单,不必处理异步流程。
- 日常业务逻辑:优先使用
fs/promises+async/await,代码可读性高,且性能符合要求。
真实的注意事项
- 大文件读取:
readFile和readFileSync会一次性将文件内容加载到内存,操作几百 MB 以上的大文件时可能导致内存溢出。此时应使用流式处理(fs.createReadStream),这将在“流(Stream)与背压原理”章节详细讲解。 - 权限与路径问题:同步和异步版本都可能因权限不足、文件不存在等原因失败,务必处理错误。缺失文件时错误码为
ENOENT,可在生产日志中根据错误码分类处理。 utf-8编码:不传编码时,读取到的是Buffer对象,转为字符串需指定编码。fs/promises的readFile如果不传编码,也返回Buffer。- 性能差异微乎其微:对于小文件,同步和异步本身的运行时间差异极小(主要耗时在磁盘 I/O),但阻塞带来的事件循环延迟是绝对不能接受的,这就是服务环境下必须用异步的根本原因。
掌握了这三种调用方式,你就已经能够应对绝大多数文件操作场景。接下来的小节我们将继续深入 fs 模块的其他高级用法,包括文件流、目录操作和文件监听等。