在 Node.js 开发中,与文件系统打交道是最基础也最高频的操作之一。无论是读取配置、存储日志、处理上传文件,还是生成临时数据,fs 模块都是第一入口。Node.js 提供了三套编程接口:同步 API、回调式异步 API 以及 Promise 异步 API。实际开发中,强烈推荐优先使用 fs/promises,避免陷入回调嵌套,也更契合 async/await 的代码风格。
文件读写:从简单读取到流式处理
最简单的文件读写方式是使用 fs.promises.readFile 和 fs.promises.writeFile,它会将整个文件内容读入内存。对于配置、JSON 数据等小文件来说,这完全足够且非常便利:
const fs = require('fs/promises');
const path = require('path');
// 读取文本文件
const content = await fs.readFile(path.join(__dirname, 'config.json'), 'utf-8');
const config = JSON.parse(content);
// 写入文件(会覆盖原有内容)
await fs.writeFile(
path.join(__dirname, 'output.txt'),
'Hello Node.js',
'utf-8'
);
// 追加内容到文件末尾
await fs.appendFile(
path.join(__dirname, 'app.log'),
`${new Date().toISOString()} - 服务启动\n`
);
当需要精细控制时,也可以使用文件描述符操作,例如在同一个文件上多次写入而不重复打开关闭:
const fs = require('fs/promises');
const fd = await fs.open('log.txt', 'a');
await fd.appendFile('第一行\n');
await fd.appendFile('第二行\n');
await fd.close();
重要提醒:对于动辄上百 MB 甚至 GB 的大文件,readFile 会一次性将全部数据载入内存,极易导致内存溢出。这时必须使用流(Stream) 式处理,它是 Node.js 处理大文件的惯用手段,我们将在第 7 章和第 8 章中详细展开。这里只给出一个用流复制文件的基础示例:
const fs = require('fs');
const readStream = fs.createReadStream('source.mp4');
const writeStream = fs.createWriteStream('dest.mp4');
readStream.pipe(writeStream);
writeStream.on('finish', () => {
console.log('复制完成');
});
目录操作:创建、列举与删除
目录操作同样提供了同步、回调与 Promise 三种风格,推荐统一使用 fs/promises。
const fs = require('fs/promises');
// 创建目录,recursive: true 会自动创建不存在的父级目录
await fs.mkdir('uploads/2024/avatars', { recursive: true });
// 列举目录内容
const files = await fs.readdir('uploads', { withFileTypes: true });
for (const dirent of files) {
if (dirent.isDirectory()) {
console.log('目录:', dirent.name);
} else if (dirent.isFile()) {
console.log('文件:', dirent.name);
}
}
// 删除目录(recursive: true 会递归删除内部所有文件和子目录)
await fs.rm('temp-cache', { recursive: true, force: true });
// 或者使用 fs.rmdir,但 rm 更为统一
路径拼接务必使用 path.join(),它能自动处理不同操作系统下的路径分隔符(/ 与 \),避免硬编码导致的跨平台问题。
文件监听:捕捉变化的两种方式
Node.js 原生提供了两种文件监听机制:
fs.watch:利用操作系统底层事件(如inotify),性能较好,但不同平台行为有差异,有时会重复触发,且文件名不一定能准确捕获。fs.watchFile:通过轮询文件状态(mtime、size),比较稳定但效率较低,不适合高频变动的文件。
// 使用 fs.watch(推荐首选)
const fs = require('fs');
const watcher = fs.watch('uploads', (eventType, filename) => {
console.log(`事件类型: ${eventType}`);
if (filename) {
console.log(`变化的文件: ${filename}`);
}
});
// 关闭监听器
// watcher.close();
// 使用 fs.watchFile(轮询方式,不推荐)
fs.watchFile('config.json', { interval: 500 }, (curr, prev) => {
console.log('文件被修改,新大小:', curr.size);
});
真实项目中的选择:原生 API 存在较多兼容性和细节问题(如 macOS 下的递归监听受限),实际开发中绝大多数项目会直接使用社区久经考验的库 chokidar。它提供了统一且丰富的 API,能正确处理各种边界情况:
npm install chokidar
const chokidar = require('chokidar');
const watcher = chokidar.watch('src', {
ignored: /(^|[\/\\])\../, // 忽略隐藏文件和目录
persistent: true
});
watcher
.on('add', path => console.log(`文件 ${path} 已添加`))
.on('change', path => console.log(`文件 ${path} 已更改`))
.on('unlink', path => console.log(`文件 ${path} 已删除`));
在 Webpack、Vite 等工具的底层,你都能看到 chokidar 的身影。就可靠性而言,它几乎已成为 Node.js 文件监听的“事实标准”。
权限管理:检查、修改与安全原则
文件权限在服务端安全中至关重要,尤其是在处理上传文件或公开目录时。Node.js 通过 fs.access、fs.chmod 和 fs.chown 提供了完整的权限控制能力。
const fs = require('fs/promises');
// 检查当前进程是否有读取权限
try {
await fs.access('secret.key', fs.constants.R_OK);
console.log('有读权限');
} catch {
console.log('无读权限');
}
// 修改文件或目录的权限模式(使用数字掩码)
// 0o600: 只有所有者可读写
// 0o644: 所有者可读写,同组和其他用户只读
// 0o755: 所有者可读写执行,同组和其他用户可读执行
await fs.chmod('script.sh', 0o755);
// 修改文件所有者(需要足够权限,通常 root 下可用)
// await fs.chown('file.txt', uid, gid);
权限数字快速参考:
0o400:所有者只读0o600:所有者读写0o644:所有者读写,其他人只读0o755:所有者读写执行,其他人读执行0o777:所有人读写执行(极其危险,避免使用)
fs.access 除了读取权限外,还可以检查 R_OK(读)、W_OK(写)、X_OK(执行)和 F_OK(文件是否存在)。但它在分布式环境中存在时间窗口竞争问题,通常建议直接尝试操作并捕获异常,而不是先检查再操作。
最佳实践:
- 上传目录务必设置为
0o750,禁止其他用户访问。 - 配置文件(如数据库密码)应设为
0o600,仅运行进程的用户可读。 - 避免以
root用户运行 Node.js 进程,应创建低权限用户(如node),并确保应用目录归属于该用户。 - 使用 Docker 等容器环境时,需同步配好容器内用户的 UID/GID,否则可能出现权限不足或文件泄露风险。
通过合理运用这些文件系统 API,你能在保证安全的前提下,灵活地管理服务器上的各类数据。后续章节会进一步讨论大文件流式处理、临时文件清理以及在生产环境中的权限兜底策略。