Node系列 · Node基础:文件 I/O
Node 的文件 I/O 几乎全靠
fs模块。它同一份能力提供三套 API(同步 / 异步回调 / Promise),混用会产生"事件循环被阻塞"或"回调金字塔"问题。理解三者的取舍,就能写好所有 Node 文件操作。
一、fs 模块的三套 API
同一个操作 readFile,fs 提供三种写法:
| 风格 | 函数 | 返回 | 阻塞主线程 |
|---|---|---|---|
| 同步 | fs.readFileSync() | 数据 / 抛异常 | ✅ 阻塞 |
| 异步回调 | fs.readFile(cb) | undefined / 通过 cb(err, data) 返回 | ❌ 非阻塞 |
| Promise | fs.promises.readFile() | Promise<data> / reject(err) | ❌ 非阻塞 |
1.1 同步 API
const fs = require('node:fs');
try {
const data = fs.readFileSync('./config.json', 'utf-8');
console.log(JSON.parse(data));
} catch (err) {
console.error('读取失败:', err.message);
}1.2 异步回调 API
const fs = require('node:fs');
fs.readFile('./config.json', 'utf-8', (err, data) => {
if (err) {
console.error('读取失败:', err.message);
return;
}
console.log(JSON.parse(data));
});回调第一个参数永远是 err,这是 Node 的"错误优先回调"约定。
1.3 Promise API(推荐)
const fs = require('node:fs/promises');
async function loadConfig() {
try {
const data = await fs.readFile('./config.json', 'utf-8');
return JSON.parse(data);
} catch (err) {
console.error('读取失败:', err.message);
throw err;
}
}
loadConfig().then((cfg) => console.log(cfg));TIP
默认用 Promise 版本。它和 async/await 配合最自然,错误用 try/catch 捕获,与同步代码视觉上接近。同步版本只适合"启动期必须串行"的场景(如读取配置文件初始化),回调风格已基本被淘汰。
二、读写文件
2.1 readFile 完整签名
// 异步 Promise 版
const buf = await fs.readFile(path, options);
// options 可以是:
// string → 'utf-8' / 'hex' / 'base64' / 'latin1' / 'ascii'
// object → { encoding, flag }
// 省略 → 返回 Buffer(不自动转字符串)
const text = await fs.readFile('./README.md', 'utf-8'); // string
const bytes = await fs.readFile('./image.png'); // Buffer2.2 writeFile 完整签名
await fs.writeFile(file, data, options);
// data 可以是:
// string → 按 encoding 写入
// Buffer → 字节写入
// TypedArray / DataView → 字节写入
await fs.writeFile('./out.txt', 'hello\n', 'utf-8');
await fs.writeFile('./out.bin', Buffer.from([0xff, 0xfe]));writeFile 默认覆盖整个文件。要追加内容用 appendFile:
await fs.appendFile('./access.log', `${new Date().toISOString()} GET /\n`);WARNING
writeFile 不存在会创建,存在会覆盖。对重要文件先用 fs.access 检查或直接用追加模式('a' flag)更安全。
2.3 常用 flag 一览
| flag | 含义 |
|---|---|
'r' | 只读(默认) |
'w' | 写入,不存在则创建,存在则清空 |
'a' | 追加,不存在则创建 |
'r+' | 读写,不存在则报错 |
'w+' | 读写,不存在则创建,存在则清空 |
'ax' | 追加,文件已存在则失败(用于"独占创建"场景,避免并发覆盖) |
三、文件描述符(File Descriptor)
open / read / close 三个底层调用组合出"打开文件 → 读 N 字节 → 关闭"的精细控制:
const fs = require('node:fs/promises');
const fd = await fs.open('./big.txt', 'r');
try {
const buf = Buffer.alloc(1024); // 1KB 缓冲区
let pos = 0;
while (true) {
const { bytesRead } = await fd.read(buf, 0, 1024, pos);
if (bytesRead === 0) break;
process.stdout.write(buf.subarray(0, bytesRead));
pos += bytesRead;
}
} finally {
await fd.close();
}什么时候用 fd 而不是 readFile:
| 场景 | 推荐 |
|---|---|
| 整个文件能装入内存 | readFile 简洁 |
| 大文件(GB 级别)流式处理 | createReadStream(第 7 章) |
| 部分读取(读头 1KB 看 magic number) | fd.read(buf, 0, len, position) |
| 频繁读小块 + 需要 seek | fd |
四、文件信息查询
查询文件元信息(大小、时间戳、类型)用 fs.stat:
const fs = require('node:fs/promises');
const stats = await fs.stat('./config.json');
stats.isFile(); // 是否普通文件
stats.isDirectory(); // 是否目录
stats.size; // 字节数
stats.atime; // 上次访问
stats.mtime; // 上次修改内容
stats.ctime; // 上次修改元数据(权限等)
stats.birthtime; // 创建时间(不保证可用)
stats.atimeMs; // 毫秒时间戳(用于计算)fs.stat 跟随符号链接;要查链接本身用 fs.lstat。
五、目录操作
目录的创建、读取、重命名、删除由一组 API 承担:
const fs = require('node:fs/promises');
// 创建(recursive: true 等价于 mkdir -p)
await fs.mkdir('./a/b/c', { recursive: true });
// 读取目录条目
const entries = await fs.readdir('./src');
// entries 是 string[];Node 20+ 可加 { withFileTypes: true } 拿到 Dirent[]
// 删除(recursive: true 才能删非空目录;Node 14.14+)
await fs.rm('./a', { recursive: true, force: true });
// 重命名 / 移动
await fs.rename('./old.txt', './new.txt');
// 读目录 + 过滤
const jsFiles = (await fs.readdir('./src'))
.filter((f) => f.endsWith('.js'));六、文件路径处理
文件 I/O 几乎总要配合 path 模块,避免字符串拼接:
const fs = require('node:fs/promises');
const path = require('node:path');
// ❌ 错误:直接拼
const filePath = __dirname + '/config/' + filename;
// ✅ 正确:path.join
const filePath = path.join(__dirname, 'config', filename);
// ✅ 跨平台:os.homedir() + path.join
const userConfig = path.join(os.homedir(), '.myapp', 'config.json');
// ✅ 读取 package.json 同目录的相对路径文件
const pkgDir = path.dirname(require.resolve('./package.json'));七、错误处理
文件操作的错误类型固定可枚举,常见的 err.code:
| code | 含义 |
|---|---|
ENOENT | 文件 / 目录不存在 |
EACCES | 权限不足 |
EISDIR | 当成文件打开目录 |
ENOTDIR | 当成目录进入文件 |
EEXIST | 文件已存在(创建时) |
EMFILE | 打开的文件描述符过多 |
const fs = require('node:fs/promises');
async function readConfig() {
try {
return await fs.readFile('./config.json', 'utf-8');
} catch (err) {
if (err.code === 'ENOENT') {
// 配置文件不存在是预期情况:用默认值
return '{}';
}
if (err.code === 'EACCES') {
throw new Error('配置目录无读取权限');
}
throw err; // 其他错误继续上抛
}
}TIP
不要吞掉错误码就 throw 一个字符串。保留 err.code 方便上游根据类型决策(如 ENOENT 走默认配置,EACCES 走错误提示)。
八、并发读写同一文件
多个 fs 调用并发操作同一文件,Node 不保证原子性。例如两个 writeFile 并发,后写的覆盖先写的,且中间状态可能损坏文件:
// ❌ 危险:两个 writeFile 并发
await Promise.all([
fs.writeFile('log.txt', 'A'),
fs.writeFile('log.txt', 'B'),
]); // 结果不可预测:可能是 "A"、"B",也可能是 "BA" 或损坏
// ✅ 方案 1:用 appendFile(追加模式,POSIX 保证原子)
await Promise.all([
fs.appendFile('log.txt', 'A'),
fs.appendFile('log.txt', 'B'),
]);
// ✅ 方案 2:串行(必要时)
await fs.writeFile('log.txt', 'A');
await fs.writeFile('log.txt', 'B');对大文件或频繁更新的场景,文件 I/O 也不适合;用 SQLite / Redis / 专门的日志库(pino + 日志聚合)替代。
九、文件锁
Node 没有内置文件锁。需要互斥时:
| 场景 | 方案 |
|---|---|
| 单进程内 | await 串行调用 / fs.promises 的串行队列 |
| 多进程间 | proper-lockfile 包 |
| 分布式 | Redis / Zookeeper / etcd |
十、性能与最佳实践
| 场景 | 推荐 | 反例 |
|---|---|---|
| 启动期读配置 | readFileSync(必须阻塞到读完才能继续) | 用异步版本然后 .then 启动 |
| 请求处理中读文件 | fs.promises.readFile | readFileSync(阻塞事件循环) |
| 大文件 | createReadStream(详见 文件流) | readFile(一次性读入内存) |
| 批量小文件 | Promise.all([...]) 并发 | 串行 await(延迟叠加) |
| 错误处理 | 按 err.code 分支 | catch (e) { /* 静默 */ } |
| 路径拼接 | path.join | 字符串 + |
十一、小结
fs提供同步 / 异步回调 / Promise 三套 API;默认用 PromisereadFile/writeFile是最简单的读写;大文件用createReadStream- 写文件注意 flag:
'w'覆盖、'a'追加、'ax'独占创建 - 错误处理按
err.code分支(ENOENT/EACCES/EMFILE等) - 并发写同一文件无原子保证,要串行或用追加模式
- 路径处理永远走
path模块,不要直接拼字符串
