人人都会AI编程

8.1 文件系统:fs 模块

更新时间:2026-07-10

文件系统是服务器端最基础、最频繁使用的功能之一:读取配置文件、写入日志、处理上传文件、操作临时目录……每一项都离不开与磁盘打交道。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.writeFilefs.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.statfs.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.watchfs.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 经常踩的坑与最佳实践

  1. 路径混用:在拼接路径时,不要使用字符串拼接 '/',而应使用 path.join 保证跨平台兼容性。相对路径的处理也应以 process.cwd()__dirname 为基准。
  2. 目录不存在:写文件前确保父目录存在,可以用 fs.mkdirSync(dir, { recursive: true }) 提前创建。
  3. 竞态条件:多个异步操作同时修改同一文件时,可能产生交错写入。应考虑使用队列或文件锁(如 proper-lockfile 库)保证顺序。
  4. 忘记关闭流:未正确关闭的流可能导致文件描述符泄漏,最终无法再打开新文件。在实现复杂流程时,建议使用 stream/promises 中的 finished 函数或手动监听 close 事件。
  5. 同步方法在服务器中滥用:除开初始化阶段,请求处理函数中绝不使用 xxxSync 方法,哪怕是读取一个极小的配置文件。可以由启动时预先读入内存缓存。

8.1.8 小结

fs 模块是 Node.js 与本地文件系统交互的枢纽,提供了从简单读写到复杂流控制的全套能力。掌握其同步/异步/Promise 接口的区别、目录管理、权限设置以及流式处理方法,就能应对绝大多数文件处理需求。特别是对于大文件,要养成“流优先”的思维,避免将全部数据吞入内存。在后面的章节中,我们还会结合 pathstream 等模块,进一步构建高效可靠的文件处理管线。