如果你正在学习Node.js,那么文件系统(fs模块)绝对是你必须掌握的核心技能之一。想象一下,文件系统就像是Node.js与计算机硬盘之间的翻译官,它让JavaScript能够读写文件、创建目录、监听文件变化——这些在浏览器环境中无法实现的操作,在Node.js里变得轻而易举。
我刚开始接触fs模块时,最让我惊讶的是它的三种调用方式:
javascript复制// 同步方式 - 简单直接但会阻塞程序
const data = fs.readFileSync('file.txt');
console.log(data.toString());
// 回调方式 - 传统的异步处理
fs.readFile('file.txt', (err, data) => {
if (err) throw err;
console.log(data.toString());
});
// Promise方式 - 现代异步写法
import { readFile } from 'node:fs/promises';
readFile('file.txt').then(data => {
console.log(data.toString());
});
这三种方式各有适用场景。同步方式适合在程序启动时加载配置文件;回调方式在早期Node版本中普遍使用;而Promise方式则是现在最推荐的写法,配合async/await语法可以让异步代码看起来像同步一样清晰。
实际开发中,简单的readFile/writeFile往往不能满足需求。fs模块提供了丰富的配置选项,比如编码格式和文件标志(flag):
javascript复制import { readFile } from 'node:fs/promises';
// 读取UTF-8编码的文本文件
const content = await readFile('config.json', {
encoding: 'utf-8',
flag: 'r' // 只读模式打开
});
// 写入文件时的选项
await writeFile('log.txt', '新日志内容', {
flag: 'a', // 追加模式
mode: 0o666 // 文件权限
});
文件标志(flag)特别重要,它决定了文件的打开方式。比如:
处理目录是文件系统的另一大功能。我经常用到的几个方法:
javascript复制import { mkdir, readdir, rm } from 'node:fs/promises';
// 创建多级目录
await mkdir('path/to/new/dir', { recursive: true });
// 读取目录内容
const files = await readdir('some/directory');
// 删除目录(包括子目录和文件)
await rm('path/to/dir', { recursive: true, force: true });
这里有个坑我踩过:早期版本使用rmdir删除目录,它不能删除非空目录。现在推荐使用rm方法,配合recursive选项可以安全删除整个目录树。
当我第一次处理一个500MB的日志文件时,readFile直接让程序内存爆了。这时候就需要流(Stream)来拯救了。流就像水管,数据像水流一样分块处理,不会一次性占用大量内存。
javascript复制import { createReadStream, createWriteStream } from 'node:fs';
// 创建可读流
const readStream = createReadStream('large_file.log', {
encoding: 'utf8',
highWaterMark: 64 * 1024 // 每次读取64KB
});
// 创建可写流
const writeStream = createWriteStream('output.log');
// 管道操作 - 将读取的数据直接写入目标文件
readStream.pipe(writeStream);
// 或者逐块处理数据
readStream.on('data', (chunk) => {
console.log(`收到 ${chunk.length} 字节数据`);
// 处理数据...
});
readStream.on('end', () => {
console.log('文件读取完成');
});
在实际项目中,我经常结合流和管道实现复杂处理:
javascript复制import { createGzip } from 'node:zlib';
// 文件压缩管道
createReadStream('source.log')
.pipe(createGzip()) // 压缩流
.pipe(createWriteStream('source.log.gz'));
// 带错误处理的管道
pipeline(
createReadStream('input.txt'),
transformStream, // 自定义转换流
createWriteStream('output.txt'),
(err) => {
if (err) {
console.error('管道处理失败', err);
} else {
console.log('管道处理完成');
}
}
);
使用pipeline代替pipe的好处是它能自动处理错误和清理资源,特别是在复杂的管道链中。
fs.watch API 可以监听文件变化,这在开发热重载工具或日志监控时非常有用:
javascript复制import { watch } from 'node:fs';
// 监听文件变化
const watcher = watch('config.json', (eventType, filename) => {
if (eventType === 'change') {
console.log(`${filename} 文件已修改`);
// 重新加载配置文件...
}
});
// 需要时关闭监听
setTimeout(() => {
watcher.close();
}, 60 * 60 * 1000); // 1小时后停止监听
不过要注意,fs.watch在不同平台上的行为可能不一致。在MacOS上我用过更好的chokidar库,它提供了更稳定的文件监控功能。
Node.js的文件操作底层依赖libuv库,理解这一点对性能优化很有帮助。比如:
javascript复制// 这段代码的输出顺序可能会让你意外
fs.readFile('file.txt', () => {
console.log('文件读取完成');
});
setImmediate(() => {
console.log('setImmediate');
});
你可能会看到setImmediate先执行。这是因为文件I/O操作由libuv管理,而setImmediate由V8的事件循环处理。文件读取完成后,libuv才会将回调放入事件队列。
另一个性能技巧是合理使用文件描述符缓存。频繁打开关闭文件会影响性能,可以考虑保持文件描述符打开或使用第三方库如graceful-fs来优化。
让我们把这些知识综合运用到一个实际场景中。假设我们需要处理服务器日志:
javascript复制import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { Transform } from 'node:stream';
// 自定义转换流 - 过滤包含错误的日志
const errorFilter = new Transform({
transform(chunk, encoding, callback) {
const log = chunk.toString();
if (log.includes('ERROR')) {
this.push(log);
}
callback();
}
});
// 处理管道
createReadStream('server.log', 'utf8')
.pipe(errorFilter)
.pipe(createGzip())
.pipe(createWriteStream('errors.log.gz'));
// 同时监控新日志
watch('server.log', (event) => {
if (event === 'change') {
// 实时处理新增日志...
}
});
这个例子展示了如何组合使用流、管道和文件监控来构建一个高效的日志处理系统。我在实际项目中用类似方案处理过每天几个GB的日志文件,内存使用始终保持在稳定水平。
文件系统操作看似简单,但要写出健壮高效的代码需要注意很多细节。比如正确处理文件路径(建议使用path模块)、处理权限问题、考虑跨平台兼容性等。经过多个项目的实践,我发现良好的错误处理和资源清理习惯尤为重要,特别是在长时间运行的服务中。