文件系统是服务器端最基础、最频繁使用的功能之一:读取配置文件、写入日志、处理上传文件、操作临时目录……每一项都离不开与磁盘打交道。Node.js 提供了 fs 模块来封装与文件系统的交互,它兼具简单易用的同步/异步接口和应对大文件的高性能流式处理,是日常开发中不可或缺的工具。
8.1.1 快速上手:引入与基础概念
fs 是 Node.js 的核心内置模块,无需安装,直接引入即可使用:
const fs = require('fs');
在 ESM 模式下,也可以用 import 语法:
import fs from 'fs';
import { readFile } from 'fs/promises';
fs 模块暴露的 API 大致分为三类:
- 同步 API(方法名通常以
Sync结尾):会阻塞事件循环,直接返回结果或在出错时抛出异常。 - 回调式异步 API:接受一个完成回调函数(通常是最后一个参数),符合 Node.js 早期的错误优先回调规范。
- Promise 式异步 API:位于
fs/promises下,返回 Promise 对象,可以和async/await搭配使用。
选择哪一种取决于使用场景:在服务启动阶段、一次性脚本中,同步调用简单直接,不会影响并发性能;而在请求处理过程中,必须使用异步版本,否则事件循环会被阻塞,导致整个服务失去响应能力。
8.1.2 文件读写与权限:基础操作实战
读文件
使用 fs.readFile(异步)和 fs.readFileSync(同步)可以获取文件全部内容。默认返回 Buffer,可以指定编码直接获取字符串:
// 异步读取,回调格式
fs.readFile('/path/to/file.txt', 'utf8', (err, data) => {
if (err) {
console.error('读取失败:', err.message);
return;
}
console.log(data);
});
// 同步读取(仅推荐在启动阶段或脚本中使用)
try {
const content = fs.readFileSync('/path/to/file.txt', 'utf8');
console.log(content);
} catch (err) {
console.error('读取失败:', err.message);
}
注意:
readFile会将整个文件一次性加载到内存中,对于大文件(如几百 MB 的日志文件)可能导致内存溢出,应改用流式处理(见 8.1.5 节)。
写文件
fs.writeFile 和 fs.writeFileSync 用于将数据写入文件。如果目标文件已存在,默认会覆盖原有内容;若目录不存在则报错,不会自动创建上级目录。
const data = 'Hello Node.js';
fs.writeFile('/path/to/output.txt', data, 'utf8', (err) => {
if (err) {
console.error('写入失败:', err.message);
return;
}
console.log('写入成功');
});
追加内容可以使用 fs.appendFile,它在文件末尾添加数据,文件不存在时会自动创建。
文件权限
在 POSIX 系统上,文件和目录有读、写、执行权限,且区分所有者、用户组和其他人。fs 模块遵循 Unix 权限模型:可以使用 fs.chmod 修改权限,使用 fs.access 检查当前进程是否有权访问文件。
// 检查文件是否存在且可读写
fs.access('/path/to/file', fs.constants.R_OK | fs.constants.W_OK, (err) => {
if (err) {
console.error('无访问权限或文件不存在');
} else {
console.log('可读写');
}
});
创建新文件时,可以通过 mode 参数指定权限位,例如 0o644 表示所有者可读写、同组和其他人只读。这是保证服务器文件安全的基础操作之一。
8.1.3 目录操作与路径遍历
创建与删除目录
// 创建目录(recursive: true 会自动创建中间目录)
fs.mkdirSync('/path/to/nested/dir', { recursive: true });
// 删除空目录(异步)
fs.rmdir('/path/to/emptyDir', (err) => { /* ... */ });
// 递归删除目录连同里面的所有文件(Node.js 14.14+)
fs.rm('/path/to/dir', { recursive: true, force: true }, (err) => { /* ... */ });
值得注意的是,rmdir 只删除空目录。如果需要删除非空目录,更稳妥的做法是使用 fs.rm 并指定 { recursive: true },或者使用社区包如 rimraf。
读取目录内容
fs.readdir 返回文件名数组,注意它只返回直接子级名称,不包括完整路径。如需递归遍历整个目录树,可以结合 path.join 进行递归封装,或使用 fs.opendir 等更底层的目录迭代器。
fs.readdir('/path/to/dir', (err, files) => {
if (err) throw err;
files.forEach(file => console.log(file));
});
常用文件信息查询
fs.stat 和 fs.statSync 返回一个 Stats 对象,包含文件大小、修改时间、类型判断等方法:
fs.stat('/path/to/file', (err, stats) => {
if (err) throw err;
console.log('大小:', stats.size);
console.log('是文件:', stats.isFile());
console.log('是目录:', stats.isDirectory());
console.log('最后修改时间:', stats.mtime);
});
8.1.4 文件监听:实时感知变化
在开发工具(如自动重启服务)或日志监控系统中,需要监听文件或目录的变化。fs.watch 和 fs.watchFile 提供了两种不同粒度的监听方式:
fs.watch:利用操作系统底层机制(inotify / FSEvents 等),性能较高,能监听文件或目录的各类变化事件,但不同平台上行为细节可能略有差异。fs.watchFile:通过轮询(定期对比修改时间)检测变化,跨平台一致性好,但会消耗更多 CPU 资源,适用于少量文件的精确监控。
// 使用 watch 监听文件修改
fs.watch('/path/to/file', (eventType, filename) => {
console.log(`事件: ${eventType}, 文件: ${filename}`);
});
注意:
fs.watch在某些平台上可能触发多次回调,或者filename参数可能为null,因此生产级应用常使用社区的chokidar包来提供更一致的体验。
8.1.5 文件流式读写:大文件处理利器
前面提到,readFile / writeFile 适合小文件,而当文件大到几百 MB 甚至 GB 时,一次性加载到内存会瞬间消耗大量资源,甚至导致进程崩溃。此时,应该使用流(Stream) 进行分块处理。
fs.createReadStream 创建可读流,fs.createWriteStream 创建可写流。它们可以一块一块地读/写数据,内存中任何时刻只保存一小块内容。
基础流读写
const readStream = fs.createReadStream('/path/to/large.iso');
const writeStream = fs.createWriteStream('/path/to/copy.iso');
readStream.on('data', (chunk) => {
writeStream.write(chunk);
});
readStream.on('end', () => {
writeStream.end();
console.log('拷贝完成');
});
readStream.on('error', (err) => {
console.error('读取流错误:', err);
});
更简洁的写法是使用管道 pipe:
fs.createReadStream('/path/to/large.iso')
.pipe(fs.createWriteStream('/path/to/copy.iso'))
.on('finish', () => console.log('拷贝完成'));
pipe 会自动处理背压问题:当可写流来不及消费数据时,可读流会被暂停,直到缓冲区被排空,从而避免内存积压。这是处理大文件的标准模式。
控制流参数
创建流时可以指定 highWaterMark(内部缓冲区大小,单位字节)等参数,也可启用 encoding 直接读取字符串。对于需要转换内容的场景(如压缩、加密),则结合转换流(Transform stream)使用,这部分在流章节会有更详细的展开。
8.1.6 Promise 化用法:告别回调地狱
回调式 API 虽然功能完备,但在复杂逻辑中容易形成多层嵌套,降低可读性。从 Node.js 10.0 开始,fs/promises 模块提供了 Promise 版本的 API,结合 async/await 可以写出线性的、易于错误处理的代码:
const fsp = require('fs/promises');
async function processFile() {
try {
const data = await fsp.readFile('/path/to/input.txt', 'utf8');
const result = data.toUpperCase();
await fsp.writeFile('/path/to/output.txt', result);
console.log('处理完成');
} catch (err) {
console.error('操作失败:', err.message);
}
}
processFile();
这种方式特别适合多个异步文件操作需要按顺序执行的场景,比如读取配置 > 处理 > 写入结果,代码结构和同步写法几乎一致,同时保留了非阻塞的优势。
提示:早期项目可以通过
util.promisify将回调式函数手动转为 Promise 版本:
@@SNAPSHOT_BLOCK_12@@
但现代 Node.js 版本更推荐直接使用fs/promises。
8.1.7 经常踩的坑与最佳实践
- 路径混用:在拼接路径时,不要使用字符串拼接
'/',而应使用path.join保证跨平台兼容性。相对路径的处理也应以process.cwd()或__dirname为基准。 - 目录不存在:写文件前确保父目录存在,可以用
fs.mkdirSync(dir, { recursive: true })提前创建。 - 竞态条件:多个异步操作同时修改同一文件时,可能产生交错写入。应考虑使用队列或文件锁(如
proper-lockfile库)保证顺序。 - 忘记关闭流:未正确关闭的流可能导致文件描述符泄漏,最终无法再打开新文件。在实现复杂流程时,建议使用
stream/promises中的finished函数或手动监听close事件。 - 同步方法在服务器中滥用:除开初始化阶段,请求处理函数中绝不使用
xxxSync方法,哪怕是读取一个极小的配置文件。可以由启动时预先读入内存缓存。
8.1.8 小结
fs 模块是 Node.js 与本地文件系统交互的枢纽,提供了从简单读写到复杂流控制的全套能力。掌握其同步/异步/Promise 接口的区别、目录管理、权限设置以及流式处理方法,就能应对绝大多数文件处理需求。特别是对于大文件,要养成“流优先”的思维,避免将全部数据吞入内存。在后面的章节中,我们还会结合 path、stream 等模块,进一步构建高效可靠的文件处理管线。