← Stream 流处理 Path 模块 →

Node.js文件系统fs模块教程:读写、目录操作与异步陷阱

著
原创 2026-10-12 Node.js 已有人查阅

一、fs模块在Node.js中的定位

Node.js的FileSystem(简称fs)是一个内置模块,不需要额外安装,直接引入就能用。它提供的是一套跨平台的文件与目录操作接口,屏蔽了Windows和类Unix系统在路径分隔符、权限模型上的差异,让同一份代码可以在不同操作系统上运行。

它解决的核心问题很明确:应用程序需要和磁盘打交道——读配置、写日志、处理上传文件、生成报表、清理临时目录。这些操作如果处理不当,会阻塞事件循环或者引发难以排查的错误。fs模块同时提供同步和异步两套API,正是为了让开发者在不同场景下做出合理选择。

引入方式有两种,CommonJS下用constfs=require('node:fs'),ESModule下用importfsfrom'node:fs'。带node:前缀是官方推荐的写法,可以避免与第三方同名包冲突。

const fs = require('node:fs');
const fsp = require('node:fs/promises');

node:fs/promises是Promise版本的入口,配合async/await使用,代码可读性比回调版本高出一截。下面分别展开各类操作。

二、读文件:fs.readFile的异步本质

fs.readFile()是最常用的读取方法,它把整个文件内容读进内存后一次给回调。异步执行意味着读取期间事件循环不会被卡住,应用可以继续响应其他请求。

const fs = require('node:fs');

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

console.log('异步读取已发起');

输出顺序很关键:

异步读取已发起
文件内容: Hello from Node.js!

这正好说明readFile不会等待文件读完才往下执行。回调里的err参数必须优先判断,这是Node.js错误优先回调(error-firstcallback)的约定,忽略它会导致后续逻辑在data为undefined时崩溃。

经验提示:readFile适合中小文件。文件超过几百MB时,一次性读入会显著抬高内存峰值。这种场景应该改用fs.createReadStream()做流式读取,边读边处理。判断标准不是文件大小本身,而是可用内存与并发量——十个并发请求同时读200MB文件,压力会成倍放大。

utf8参数是编码声明。省略它拿到的是Buffer,需要自己调用toString()。处理文本文件建议显式指定编码,处理二进制(图片、压缩包)则不要指定。

三、写文件:覆盖、追加与Promise封装

3.1fs.writeFile覆盖写入

fs.writeFile()用于把数据写入文件,文件不存在则创建,存在则覆盖。这个"覆盖"语义要特别留意——它不会保留原内容。

const fsp = require('node:fs/promises');

async function saveConfig() {
  try {
    await fsp.writeFile('output.txt', 'Hello from Node.js!', 'utf8');
    console.log('文件已保存');
  } catch (err) {
    console.error('写入失败:', err.message);
  }
}

saveConfig();

用Promise版本配合try/catch,比回调嵌套清晰得多。写入的内容可以是字符串、Buffer或TypedArray。第三个参数除了编码,还可以传一个对象来指定文件模式:

await fsp.writeFile('output.txt', data, { encoding: 'utf8', mode: 0o600 });

0o600表示只有文件所有者可读写,适合存放密钥、令牌这类敏感内容。默认权限受系统umask影响,在共享服务器上这点值得注意。

3.2fs.appendFile追加写入

追加操作不破坏原有内容,新数据接在文件末尾。日志记录、事件流水这类场景用得最多。

const fs = require('node:fs');

fs.appendFile('example.txt', 'Hello from Node.js!\n', (err) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log('追加成功');
});

文件不存在时appendFile会自动创建,这一点和writeFile一致。它内部其实是"打开-写入-关闭"的组合操作,在高频写入场景下效率不高。如果每秒要写上千条日志,应该自己持有一个写流:

const logStream = fs.createWriteStream('app.log', { flags: 'a' });
logStream.write(`[2026-01-15] 用户登录\n`);

flags:'a'就是append模式。这样避免了反复打开关闭文件的开销,是日志系统里的常见做法。

反思:用appendFile写日志时,如果多个进程同时写同一个文件,内容可能交错。Node.js不提供跨进程的文件锁。生产环境要么用单进程写,要么用专业的日志库(如pino)配合轮转策略,别自己造轮子。

四、获取文件信息:fs.stat与Stats对象

fs.stat()返回一个Stats对象,里面包含文件大小、创建时间、修改时间、访问时间以及类型判断方法。

const fs = require('node:fs');

fs.stat('example.txt', (err, stats) => {
  if (err) {
    console.error('获取信息失败:', err.message);
    return;
  }
  console.log('文件大小:', stats.size, 'bytes');
  console.log('是否为文件:', stats.isFile());
  console.log('是否为目录:', stats.isDirectory());
});

输出类似:

文件大小: 18 bytes
是否为文件: true
是否为目录: false

stats.isFile()和stats.isDirectory()是常用的类型判断入口,比手动判断路径字符串可靠。还有stats.mtime(之后修改时间)和stats.birthtime(创建时间),在做缓存失效判断时很有用——比较文件修改时间和缓存记录的时间戳,决定是否需要重新读取。

还有一个容易被忽略的方法fs.access(),用于检查文件是否存在以及当前进程是否具备读写权限。不过它有个经典问题:检查完到实际使用之间,文件状态可能已经变化。这种"检查后使用"的竞态在并发环境下会出错。更稳妥的做法是直接操作,然后捕获ENOENT错误。

五、目录操作:创建与读取

5.1fs.mkdir创建目录

const fs = require('node:fs');

fs.mkdir('new-folder', (err) => {
  if (err) console.error(err);
  else console.log('目录已创建');
});

默认情况下,如果父目录不存在,mkdir会报ENOENT。要递归创建多级目录,需要加recursive:true:

fs.mkdir('logs/2026/01', { recursive: true }, (err) => {
  if (err) console.error(err);
  else console.log('多级目录已创建');
});

递归模式下,如果目录已存在,Node.js不会报错,而是正常返回。这个特性让代码可以幂等地准备目录结构,不需要先判断再创建。

5.2fs.readdir读取目录内容

const fs = require('node:fs');

fs.readdir('.', (err, files) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(files);
});

输出是一个文件名数组:

[ 'example.txt', 'output.txt', 'new-folder' ]

默认只返回名称,不包含类型信息。如果需要区分文件和目录,加withFileTypes:true:

const entries = await fsp.readdir('.', { withFileTypes: true });
for (const entry of entries) {
  console.log(entry.name, entry.isDirectory() ? '[目录]' : '[文件]');
}

这样就不必对每个条目再调一次stat,在遍历大目录时能省下大量系统调用。readdir不会递归子目录,需要递归遍历得自己写函数或用fs.glob(Node.js22起提供)。

六、重命名与复制:路径变更操作

6.1fs.rename移动或改名

const fs = require('node:fs');

fs.rename('old.txt', 'new.txt', (err) => {
  if (err) {
    console.error('重命名失败:', err.code, err.message);
    return;
  }
  console.log('文件已重命名');
});

当old.txt不存在时,错误信息会是这样:

重命名失败: ENOENT no such file or directory, rename 'old.txt' -> 'new.txt'

err.code是ENOENT,这是排查文件操作问题最直接的线索。rename不仅可以改名,还能把文件移动到另一个目录,前提是目标路径的目录存在。跨设备移动时,底层可能退化为复制加删除,这一点在容器环境里要留意。

6.2fs.copyFile复制文件

const fs = require('node:fs');

fs.copyFile('old.txt', 'copy.txt', (err) => {
  if (err) {
    console.error('复制失败:', err.code);
    return;
  }
  console.log('文件已复制');
});

copyFile默认覆盖目标文件。如果不想覆盖,可以传fs.constants.COPYFILE_EXCL标志,目标已存在时直接报错:

fs.copyFile('old.txt', 'copy.txt', fs.constants.COPYFILE_EXCL, (err) => {
  if (err) console.error('目标已存在,未覆盖');
});

这个标志在生成唯一文件名的场景有用,避免无意中覆盖别人的数据。

七、删除文件与目录:fs.rm的递归能力

fs.rm()可以删除文件和目录,加上recursive:true后能递归删除整个目录树。

const fs = require('node:fs');

fs.rm('example.txt', (err) => {
  if (err) {
    console.error('删除失败:', err.code, err.message);
    return;
  }
  console.log('文件已删除');
});

文件不存在时报错:

删除失败: ENOENT no such file or directory, lstat 'example.txt'

如果希望文件不存在时不报错,加force:true:

await fsp.rm('temp-dir', { recursive: true, force: true });

这行代码的作用是:删除temp-dir及其所有内容,目录不存在也不报错。在清理临时文件的脚本里,这个组合很实用。需要提醒的是,force不会阻止权限错误,只忽略"不存在"这一类错误。

个人建议:早期版本用fs.unlink删文件、fs.rmdir删目录,现在官方推荐统一用fs.rm。rmdir在递归场景下已被标记为废弃,新项目直接用rm即可,减少API选择负担。

八、fs模块方法速查

方法 作用 备注
fs.readFile() 异步读取文件内容 大文件改用流
fs.writeFile() 写入文件,不存在则创建 会覆盖原内容
fs.appendFile() 追加数据到文件末尾 高频写入改用写流
fs.stat() 获取文件或目录信息 返回Stats对象
fs.mkdir() 创建目录 递归需加recursive
fs.readdir() 读取目录内容 加withFileTypes省stat
fs.rename() 重命名或移动 跨设备可能退化
fs.copyFile() 复制文件 可用EXCL防覆盖
fs.rm() 删除文件或目录 递归需加recursive

九、错误处理与类型安全的实际做法

文件操作的错误几乎都带code属性,常见的几个值得记住。ENOENT表示路径不存在,EACCES表示权限不足,EISDIR表示把目录当文件操作,ENOTDIR表示路径中间某段不是目录,EEXIST表示目标已存在。根据code做分支处理,比匹配错误信息字符串可靠得多,因为错误信息会随系统语言变化。

在TypeScript项目里,node:fs/promises的类型定义比较完整,readFile返回Promise<Buffer>,指定编码后返回Promise<string>。建议把文件操作封装成独立的工具函数,统一处理错误转换,避免code判断散落在业务逻辑各处。

async function readTextFile(path: string): Promise<string | null> {
  try {
    return await fsp.readFile(path, 'utf8');
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
    throw err;
  }
}

返回null表示文件不存在,其余错误继续抛出。这样调用方只需处理"有内容"和"没内容"两种情况,异常路径保持清晰。

十、本节课程知识要点

  • fs是内置模块,提供同步与异步两套API,异步版本不阻塞事件循环。

  • node:fs/promises配合async/await,是当下更易读的写法,优先选用。

  • readFile一次性读入内存,大文件应改用createReadStream。

  • writeFile覆盖,appendFile追加,高频写入用写流代替重复调用。

  • stat返回的Stats对象可判断类型与获取时间戳,access存在检查后使用的竞态。

  • mkdir加recursive可幂等创建多级目录。

  • readdir加withFileTypes避免逐条stat。

  • rm加recursive与force可安全清理目录树。

  • 错误处理认准err.code,不要依赖错误消息文本。

  • TypeScript下把文件操作封装为工具函数,集中处理ENOENT等预期错误。

十一、练习与思考

  1. 写一个函数,接收目录路径,返回该目录下所有.log文件的名称与大小,要求只调用一次readdir且不逐个stat。

  2. 实现一个安全的配置读取函数:文件不存在时返回默认配置,权限不足时抛出明确错误,其余错误原样上抛。

  3. 用fs.createWriteStream实现一个简易日志器,支持按日期分文件,说明为什么不用appendFile。

  4. 思考:为什么fs.access检查文件存在性在并发场景下不可靠?给出一个会出问题的具体时序。

  5. 在TypeScript中为readdir的withFileTypes结果编写类型守卫,区分文件与目录分支。

十二、生产环境中的注意事项

路径拼接不要手动拼字符串,用path.join()处理分隔符差异。用户输入的路径要防止../穿越,写入前用path.resolve()规范化并校验是否在允许的根目录内。批量文件操作要考虑并发上限,Promise.all同时打开上千个文件句柄会触发EMFILE,用p-limit之类的工具控制并发数。容器环境下挂载卷的权限与宿主机不同,EACCES往往来自这里而非代码本身。

文件系统操作看起来简单,真正决定代码质量的是错误分支是否覆盖完整、资源是否及时释放、并发是否受控。把这三点做好,fs模块用起来就稳了。

← Node.js Stream流处理:Readable、Writable、Duplex、Transform、pipe管道与背压处理指南 Node.js Path模块实战教程:路径拼接、解析与跨平台处理 →
分享笔记 (共有 篇笔记)
验证码:
微信公众号