Skip to content

Node系列 · Node基础:文件 I/O

Node 的文件 I/O 几乎全靠 fs 模块。它同一份能力提供三套 API(同步 / 异步回调 / Promise),混用会产生"事件循环被阻塞"或"回调金字塔"问题。理解三者的取舍,就能写好所有 Node 文件操作。

一、fs 模块的三套 API

同一个操作 readFilefs 提供三种写法:

风格函数返回阻塞主线程
同步fs.readFileSync()数据 / 抛异常✅ 阻塞
异步回调fs.readFile(cb)undefined / 通过 cb(err, data) 返回❌ 非阻塞
Promisefs.promises.readFile()Promise<data> / reject(err)❌ 非阻塞

1.1 同步 API

javascript
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

javascript
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(推荐)

javascript
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 完整签名

javascript
// 异步 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');         // Buffer

2.2 writeFile 完整签名

javascript
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

javascript
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 字节 → 关闭"的精细控制:

javascript
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)
频繁读小块 + 需要 seekfd

四、文件信息查询

查询文件元信息(大小、时间戳、类型)用 fs.stat

javascript
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 承担:

javascript
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 模块,避免字符串拼接:

javascript
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打开的文件描述符过多
javascript
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 并发,后写的覆盖先写的,且中间状态可能损坏文件:

javascript
// ❌ 危险:两个 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.readFilereadFileSync(阻塞事件循环)
大文件createReadStream(详见 文件流readFile(一次性读入内存)
批量小文件Promise.all([...]) 并发串行 await(延迟叠加)
错误处理err.code 分支catch (e) { /* 静默 */ }
路径拼接path.join字符串 +

十一、小结

  • fs 提供同步 / 异步回调 / Promise 三套 API;默认用 Promise
  • readFile / writeFile 是最简单的读写;大文件用 createReadStream
  • 写文件注意 flag:'w' 覆盖、'a' 追加、'ax' 独占创建
  • 错误处理按 err.code 分支(ENOENT / EACCES / EMFILE 等)
  • 并发写同一文件无原子保证,要串行或用追加模式
  • 路径处理永远走 path 模块,不要直接拼字符串