一、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等预期错误。
十一、练习与思考
-
写一个函数,接收目录路径,返回该目录下所有
.log文件的名称与大小,要求只调用一次readdir且不逐个stat。 -
实现一个安全的配置读取函数:文件不存在时返回默认配置,权限不足时抛出明确错误,其余错误原样上抛。
-
用
fs.createWriteStream实现一个简易日志器,支持按日期分文件,说明为什么不用appendFile。 -
思考:为什么
fs.access检查文件存在性在并发场景下不可靠?给出一个会出问题的具体时序。 -
在TypeScript中为
readdir的withFileTypes结果编写类型守卫,区分文件与目录分支。
十二、生产环境中的注意事项
路径拼接不要手动拼字符串,用path.join()处理分隔符差异。用户输入的路径要防止../穿越,写入前用path.resolve()规范化并校验是否在允许的根目录内。批量文件操作要考虑并发上限,Promise.all同时打开上千个文件句柄会触发EMFILE,用p-limit之类的工具控制并发数。容器环境下挂载卷的权限与宿主机不同,EACCES往往来自这里而非代码本身。
文件系统操作看起来简单,真正决定代码质量的是错误分支是否覆盖完整、资源是否及时释放、并发是否受控。把这三点做好,fs模块用起来就稳了。