3、核心模块 - fs 文件系统:读取文件(同步/异步)、写入文件、文件流(stream)、监听文件变化、实战:日志文件生成器

好,咱们今天来啃 Node.js 里最常用的一个核心模块——fs(文件系统)。

说实话,后端开发里,文件操作几乎是躲不开的。无论是读取配置文件、写入日志、处理上传的图片,还是做数据持久化,都得跟文件打交道。Node.js 的 fs 模块提供了非常丰富的 API,但用不好也容易踩坑。

我个人习惯把文件操作分成两类:一次性操作流式操作。咱们一个一个来看。

3.1 读取文件:同步 vs 异步

读取文件,最基础的操作。Node.js 提供了两种方式:同步和异步。

3.1.1 异步读取(推荐)

异步是 Node.js 的基因。用 fs.readFile,不会阻塞事件循环。

const fs = require('fs');

// 异步读取
fs.readFile('./data.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('读取失败:', err);
        return;
    }
    console.log('文件内容:', data);
});

console.log('这行会先执行,因为 readFile 是异步的');

嗯,这里要注意:回调函数的第一个参数永远是错误对象。这是 Node.js 的约定俗成,我建议你养成习惯,每次写回调都先处理 err。

3.1.2 同步读取(慎用)

fs.readFileSync,代码看起来更「线性」,但会阻塞线程。

try {
    const data = fs.readFileSync('./data.txt', 'utf8');
    console.log('文件内容:', data);
} catch (err) {
    console.error('读取失败:', err);
}

console.log('这行会等上面执行完才执行');

什么时候用同步? 我个人只在程序启动时读取配置文件时用同步。因为那时候还没开始处理请求,阻塞一下无所谓。其他时候,一律用异步。

避坑指南: 我曾经在 Express 的路由处理里用了 readFileSync,结果高并发下整个服务卡死。你想想看,一个请求在读文件,其他所有请求都得等着。所以,生产环境千万别在请求处理中用同步读取。

3.2 写入文件

写入文件同样有同步和异步之分。核心 API 是 fs.writeFilefs.writeFileSync

3.2.1 异步写入

const content = 'Hello, Node.js!';

fs.writeFile('./output.txt', content, 'utf8', (err) => {
    if (err) {
        console.error('写入失败:', err);
        return;
    }
    console.log('文件写入成功');
});

默认情况下,writeFile 会覆盖文件内容。如果你想追加内容,用 fs.appendFile

fs.appendFile('./log.txt', '新的一行日志\n', (err) => {
    if (err) console.error('追加失败:', err);
});

3.2.2 写入选项

writeFile 的第三个参数可以传一个 options 对象,常用的有:

选项 说明 默认值
encoding 编码格式 'utf8'
mode 文件权限 0o666
flag 文件系统标志 'w'(写入)

flag 的值很有意思:

  • 'w' - 写入,覆盖已有内容
  • 'a' - 追加,不覆盖
  • 'wx' - 写入,但文件已存在时报错
小技巧: 我写日志文件时,习惯用 flag: 'a' 配合 appendFile,这样每次写入都是追加,不会丢失历史数据。

3.3 文件流(Stream)

好,接下来是重点——文件流。为什么需要流?

你想想看,如果用 readFile 读取一个 2GB 的文件,内存直接爆了。流式处理的核心思想是:分块读取,边读边处理

3.3.1 创建可读流

const readStream = fs.createReadStream('./bigfile.txt', {
    encoding: 'utf8',
    highWaterMark: 64 * 1024 // 每次读取 64KB
});

readStream.on('data', (chunk) => {
    console.log('收到一块数据,大小:', chunk.length);
});

readStream.on('end', () => {
    console.log('文件读取完毕');
});

readStream.on('error', (err) => {
    console.error('读取出错:', err);
});

highWaterMark 这个参数很关键。它控制每次读取的「块」大小。默认是 64KB,你可以根据场景调整。我处理大日志文件时,一般设成 1MB,平衡内存和 I/O 次数。

3.3.2 可写流

const writeStream = fs.createWriteStream('./output.txt');

writeStream.write('第一行\n');
writeStream.write('第二行\n');
writeStream.end('结束\n');

writeStream.on('finish', () => {
    console.log('写入完成');
});

3.3.3 pipe:最优雅的流式传输

pipe 方法可以把可读流直接「管道」到可写流。说白了,就是自动读取、自动写入,还带背压控制。

const readStream = fs.createReadStream('./source.txt');
const writeStream = fs.createWriteStream('./dest.txt');

readStream.pipe(writeStream);

readStream.on('end', () => {
    console.log('文件复制完成');
});

一行 pipe,搞定文件复制。我记得第一次用 pipe 时,感觉这 API 设计得太优雅了。

核心要点: 处理大文件时,永远优先考虑流。readFile 适合小文件(几 MB 以内),流适合任意大小的文件。

3.4 监听文件变化

有时候我们需要监控文件的变化,比如配置文件改了自动重载、日志文件新增了实时读取。Node.js 提供了 fs.watchfs.watchFile

3.4.1 fs.watch(推荐)

fs.watch('./config.json', (eventType, filename) => {
    console.log(`文件 ${filename} 发生了 ${eventType} 事件`);
    // eventType 可能是 'rename' 或 'change'
});

这个 API 底层依赖操作系统的文件通知机制,效率很高。但有个坑:在 macOS 上,rename 事件可能触发多次。我曾经被这个坑过,后来加了防抖处理。

3.4.2 fs.watchFile(轮询)

fs.watchFile('./config.json', {
    interval: 1000 // 每秒检查一次
}, (curr, prev) => {
    console.log('文件修改时间:', curr.mtime);
});

watchFile 是轮询方式,不推荐用于生产环境。除非你监控的是网络文件系统(NFS),那种场景下 watch 可能不工作。

注意: fs.watch 在不同平台上的行为有差异。Linux 用 inotify,macOS 用 FSEvents,Windows 用 ReadDirectoryChangesW。跨平台部署时,一定要测试文件监控功能。

3.5 实战:日志文件生成器

理论讲完了,咱们来写个实战项目。需求很简单:

  • 每秒生成一条日志,格式为 [时间戳] [级别] 消息
  • 日志写入到 app.log 文件
  • 当日志文件超过 1MB 时,自动归档(重命名为 app-日期.log),然后新建文件继续写
const fs = require('fs');
const path = require('path');

const LOG_FILE = './app.log';
const MAX_SIZE = 1 * 1024 * 1024; // 1MB

function getTimestamp() {
    return new Date().toISOString();
}

function archiveLog() {
    const dateStr = new Date().toISOString().slice(0, 10);
    const archiveName = `app-${dateStr}.log`;
    fs.renameSync(LOG_FILE, archiveName);
    console.log(`日志已归档: ${archiveName}`);
}

function writeLog(level, message) {
    const logLine = `[${getTimestamp()}] [${level}] ${message}\n`;

    // 检查文件大小
    try {
        const stats = fs.statSync(LOG_FILE);
        if (stats.size >= MAX_SIZE) {
            archiveLog();
        }
    } catch (err) {
        // 文件不存在,忽略错误
    }

    // 追加写入
    fs.appendFile(LOG_FILE, logLine, (err) => {
        if (err) console.error('写入日志失败:', err);
    });
}

// 模拟日志生成
setInterval(() => {
    const levels = ['INFO', 'WARN', 'ERROR'];
    const level = levels[Math.floor(Math.random() * levels.length)];
    const messages = [
        '用户登录成功',
        '数据库查询耗时 150ms',
        '内存使用率 78%',
        '请求超时,重试中',
        '缓存命中率 92%'
    ];
    const message = messages[Math.floor(Math.random() * messages.length)];
    writeLog(level, message);
}, 1000);

console.log('日志生成器已启动,按 Ctrl+C 停止');

这个生成器用到了:

  • fs.appendFile 异步追加写入
  • fs.statSync 同步检查文件大小(这里用同步没问题,因为只在写入前检查一次)
  • fs.renameSync 重命名文件实现归档
扩展思路: 实际项目中,我还会加上日志级别过滤、按天分目录存储、自动清理过期日志等功能。你可以把这个生成器作为基础,按需扩展。

好了,fs 模块的核心内容就这些。总结一下:

  • 小文件用 readFile/writeFile,大文件用流
  • 生产环境避免同步操作
  • 文件监控注意跨平台兼容性
  • 日志系统要考虑归档和轮转

下一章,咱们聊聊网络编程,看看 Node.js 怎么处理 TCP 和 HTTP 请求。


公众号:蓝海资料掘金营,微信deep3321