Node.js 文件系统(fs 模块)

后端程序几乎一定要读写文件:配置、日志、用户上传的数据、模板、缓存。Node 的 fs(file system)模块提供了完整的文件操作能力——读、写、删、复制、查信息、管目录。这一章把它讲透。

1. 三套 API:同步、回调、Promise

fs 模块最让人困惑的是它有三套 API,功能完全一样,只是调用风格不同:

const fs = require("fs");

// === 三种风格的 API ===

// 1. 同步(阻塞主线程,函数名带 Sync)
//    只用在启动期或脚本工具,服务运行时绝不要用
const text = fs.readFileSync("config.json", "utf8");

// 2. 回调(异步,传统写法)
//    错误优先回调:第一个参数是 err
fs.readFile("config.json", "utf8", (err, data) => {
  if (err) return console.error(err);
  console.log(data);
});

// 3. Promise(现代推荐)
//    用 fs.promises 或 require("fs/promises")
const fsp = require("fs/promises");
async function read() {
  const data = await fsp.readFile("config.json", "utf8");
  console.log(data);
}
read();

怎么选?三个原则:

千万别在 Web 服务里用 readFileSync——它会阻塞整个事件循环,所有请求都卡住,这是 Node 性能杀手。

2. 读取文件

const fs = require("fs");

// 读文本(指定编码,否则返回 Buffer)
fs.readFile("note.txt", "utf8", (err, text) => {
  if (err) throw err;     // 文件不存在会进 err
  console.log(text);
});

// 读二进制(图片、视频),不传编码,返回 Buffer
fs.readFile("logo.png", (err, buffer) => {
  if (err) throw err;
  console.log("大小:", buffer.length, "字节");
  // Buffer 可以转成 base64、写入别处等
});

// 同步读取
const config = JSON.parse(fs.readFileSync("config.json", "utf8"));

关键点:

3. 写入与追加

const fs = require("fs");

// 写入(覆盖已有内容)
fs.writeFile("log.txt", "第一行内容", (err) => {
  if (err) throw err;
  console.log("写入完成");
});

// 追加写入(在末尾加)
fs.appendFile("log.txt", "\n第二行", (err) => {
  if (err) throw err;
});

// 同步版
fs.writeFileSync("data.txt", "同步写入");
fs.appendFileSync("data.txt", "\n追加");

// 写二进制:把 Buffer 写入
const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
fs.writeFileSync("chunk.bin", buffer);

writeFile覆盖:如果文件已有内容,会被清空重写。appendFile 是在末尾追加。写日志、累积数据用 appendFile

4. Promise 版(推荐)

新代码一律用 fs/promises,配合 async/await——可读性最好,错误处理用 try/catch 最直观:

// 推荐:用 fs/promises,代码更清晰
const fs = require("fs/promises");
const path = require("path");

async function main() {
  try {
    // 读
    const text = await fs.readFile("note.txt", "utf8");

    // 写
    await fs.writeFile("output.txt", text.toUpperCase());

    // 追加
    await fs.appendFile("log.txt", "[完成] " + new Date() + "\n");

    // 复制文件
    await fs.copyFile("a.txt", "b.txt");

    console.log("全部完成");
  } catch (err) {
    console.error("失败:", err.message);
  }
}

main();

5. 查询文件信息

const fs = require("fs");

// stat:获取文件信息(大小、创建时间、是否目录)
fs.stat("config.json", (err, stats) => {
  if (err) throw err;
  console.log("大小:", stats.size, "字节");
  console.log("是文件:", stats.isFile());
  console.log("是目录:", stats.isDirectory());
  console.log("修改时间:", stats.mtime);
});

// existsSync:检查文件是否存在(同步,简单场景用)
if (fs.existsSync("config.json")) {
  console.log("配置文件存在");
} else {
  console.log("配置文件不存在");
}

stat 返回一个 Stats 对象,包含文件大小、创建/修改时间、是否目录/文件、权限位等。常用于"文件存在吗""多大""什么时候改过"这类判断。exists 已废弃,现在用 stat 配合 try/catch,或简单场景用 existsSync

6. 目录操作

const fs = require("fs");

// 创建目录(可指定递归)
fs.mkdir("logs/2024/01", { recursive: true }, (err) => {
  if (err) throw err;
  console.log("目录创建成功(包括各级父目录)");
});

// 读取目录内容
fs.readdir(".", (err, files) => {
  if (err) throw err;
  console.log("当前目录:", files);
  // ["index.js", "package.json", "logs", ...]
});

// 读取目录带详细信息
fs.readdir(".", { withFileTypes: true }, (err, items) => {
  if (err) throw err;
  items.forEach(item => {
    const type = item.isDirectory() ? "目录" : "文件";
    console.log(type, item.name);
  });
});

// 删除空目录
fs.rmdir("empty", (err) => { /* ... */ });
// 删除目录树(包括内容)
fs.rm("old-folder", { recursive: true, force: true }, (err) => { /* ... */ });

删除目录的 API 历史上比较混乱:rmdir 只能删空目录,rm -r 风格的删除要用 fs.rm 并在第二个参数传 recursive 选项(Node 14.14+)。删目录要非常小心——一旦删错没有回收站,生产事故经常是手抖删错文件夹导致的。

7. path 模块:路径处理的救星

路径处理是 Node 新手高发 bug 区。Mac/Linux 用 /,Windows 用 \\,手拼字符串一定出问题。永远用 path.join():

const path = require("path");

// join:拼路径(自动用对应系统的分隔符)
const file = path.join("data", "users", "42.json");
// macOS/Linux: data/users/42.json
// Windows:    data\users\42.json

// __dirname:当前文件所在目录(CommonJS)
const configPath = path.join(__dirname, "config.json");

// ESM 里没有 __dirname,要这样取
// import { fileURLToPath } from "url";
// const __dirname = path.dirname(fileURLToPath(import.meta.url));

// resolve:解析成绝对路径
console.log(path.resolve("data", "x.txt"));
// /Users/你的用户名/项目目录/data/x.txt

// 其他常用方法
path.basename("/a/b/c.txt");      // "c.txt"(文件名)
path.dirname("/a/b/c.txt");        // "/a/b"(目录)
path.extname("c.txt");             // ".txt"(扩展名)
path.parse("/a/b/c.txt");
// { root: "/", dir: "/a/b", base: "c.txt", ext: ".txt", name: "c" }

__dirname(CommonJS)和 import.meta.url(ESM)是定位项目内文件的关键——它们指向当前文件所在目录,无论你在哪个目录运行 node,路径都对。直接写相对路径(如 "./config.json")会相对当前工作目录(你 cd 到哪),容易出 bug。

8. watch:监听文件变化

const fs = require("fs");

// 监听文件变化(类似 nodemon、vite 的底层)
fs.watch("config.json", (eventType, filename) => {
  console.log("文件变了:", filename, "事件:", eventType);
  // eventType 是 "change" 或 "rename"
});

// 监听整个目录
fs.watch("./src", { recursive: true }, (eventType, filename) => {
  console.log("src 下变化:", filename);
});

// 注意:不同系统 watch 行为不一致,生产环境用 chokidar 包更稳

9. 实战:写一个简单的日志工具

const fs = require("fs/promises");
const path = require("path");

const LOG_FILE = path.join(__dirname, "app.log");

async function log(level, message) {
  const line = `[${new Date().toISOString()}] [${level}] ${message}\n`;
  await fs.appendFile(LOG_FILE, line);
}

// 用法
(async () => {
  await log("INFO", "服务启动");
  await log("ERROR", "数据库连接失败");
  await log("WARN", "内存占用偏高");
})();

这是个生产可用的雏形——加上日志轮转(按天切分文件)、级别过滤、格式化,就是一个迷你版 winston/pino。日志库的核心就是这么简单。

小结

fs 是 Node 最常用的内置模块之一。记住几条:三套 API 选 Promise 版;服务运行时绝不用 xxxSync;路径永远用 path.join;大文件用流不用 readFile。下一篇我们就讲流(Stream)——它是 fs、http、网络背后共同的抽象。

← 上一篇 Node.js 事件

下一篇 Node.js 流

✈️💬