文件系统与路径
文件读写是后端开发的基本功。Node.js 的 fs(文件系统)与 path(路径)两个内置模块,一个负责与磁盘打交道,一个负责把路径字符串处理明白。两者结合,几乎能覆盖日常所有的文件操作场景。
一、fs 模块的三种 API 风格
fs 模块提供三种 API 风格:同步(*Sync)、回调(callback)与 Promise(fs/promises)。
| 风格 | 导入方式 | 特点 | 适用场景 |
|---|---|---|---|
| 同步 | require("fs") | 阻塞事件循环,代码简单 | 启动时一次性读取配置 |
| 回调 | require("fs") | 非阻塞,嵌套回调易产生回调地狱 | 需要兼容老代码 |
| Promise | require("fs/promises") | 非阻塞,配合 async/await | 新项目首选 |
// 同步风格:会阻塞后续代码
const fs = require("fs");
const data = fs.readFileSync("config.json", "utf8");
console.log(data); // 读完才继续往下
// 回调风格:非阻塞
fs.readFile("config.json", "utf8", (err, data) => {
if (err) throw err;
console.log(data);
});
console.log("我不等文件,先执行");
// Promise 风格:推荐
const fsp = require("fs/promises");
(async () => {
try {
const data = await fsp.readFile("config.json", "utf8");
console.log(data);
} catch (err) {
console.error("读取失败:", err.message);
}
})();三种风格的方法名对应关系:readFileSync、readFile、promises.readFile。文件操作多而杂时,统一用 fs/promises 能让错误处理集中到 try/catch 中。
二、读取文件
2.1 readFile 读取文本与二进制
const fsp = require("fs/promises");
// 读取文本文件
const text = await fsp.readFile("note.txt", "utf8");
// 不传编码得到 Buffer(二进制)
const buf = await fsp.readFile("logo.png");
console.log(buf instanceof Buffer); // true
// 一次性读取的局限:整个文件都要进内存2.2 readFile 与 createReadStream 对比
| 场景 | 用 readFile | 用 createReadStream |
|---|---|---|
| 小文件(配置文件、模板) | 合适 | 过度设计 |
| 大文件(日志、视频、压缩包) | 内存可能被撑爆 | 分块读取,内存恒定 |
| 需要边读边处理 | 不适合 | 合适 |
三、写入文件
const fsp = require("fs/promises");
// writeFile:覆盖写入(文件不存在会自动创建)
await fsp.writeFile("out.txt", "第一行\n", "utf8");
// appendFile:追加写入
await fsp.appendFile("out.txt", "第二行\n", "utf8");
// 同步写法
const fs = require("fs");
fs.writeFileSync("out.txt", "hello");| 方法 | 行为 | 文件不存在时 |
|---|---|---|
writeFile | 覆盖整个文件 | 自动创建 |
appendFile | 在末尾追加 | 自动创建 |
write | 流/文件描述符上的写入 | 需要先 open |
四、流式读写大文件
当文件大到以 GB 计时,用 createReadStream 配合 createWriteStream 分块搬运,内存占用始终平稳:
const fs = require("fs");
const { pipeline } = require("stream/promises");
// 方式一:pipe 管道,一行完成复制
fs.createReadStream("big.iso").pipe(fs.createWriteStream("big-copy.iso"));
// 方式二:pipeline,自带错误处理与清理,推荐
await pipeline(
fs.createReadStream("big.iso"),
fs.createWriteStream("big-copy.iso")
);流的内容会在《流与管道》一章深入讲解,这里先记住:大文件一律走流,不要一次性 readFile。
五、目录操作
const fsp = require("fs/promises");
// 创建目录(recursive 支持多级创建,已存在也不报错)
await fsp.mkdir("a/b/c", { recursive: true });
// 读取目录内容(返回文件名字符串数组)
const names = await fsp.readdir("src");
// ['index.js', 'utils', 'app.js']
// 删除空目录
await fsp.rmdir("empty-dir");
// 删除文件或目录(recursive 可递归删除目录,force 忽略不存在的报错)
await fsp.rm("target", { recursive: true, force: true });| 方法 | 说明 | 注意 |
|---|---|---|
mkdir(path, { recursive }) | 创建目录 | recursive: true 可一次性创建多级 |
readdir(path) | 列出目录项 | 只返回名字,不返回类型 |
readdir(path, { withFileTypes: true }) | 列出目录项 | 每项带 isFile() / isDirectory() |
rmdir | 删除空目录 | 目录非空会报错 |
rm | 删除文件/目录 | Node 14+ 推荐,可递归 |
// 带类型地读取目录,区分文件与子目录
const fsp = require("fs/promises");
const entries = await fsp.readdir("src", { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
console.log("目录:", entry.name);
} else if (entry.isFile()) {
console.log("文件:", entry.name);
}
}六、文件信息:stat
stat 获取文件/目录的元信息,是判断类型、检查修改时间的标准方式:
const fsp = require("fs/promises");
const st = await fsp.stat("app.js");
console.log(st.isFile()); // true
console.log(st.isDirectory()); // false
console.log(st.size); // 字节数,如 2048
console.log(st.mtime); // 最后修改时间(Date 对象)
console.log(st.birthtime); // 创建时间
console.log(st.isSymbolicLink()); // 是否符号链接| 属性/方法 | 含义 |
|---|---|
isFile() | 是否为普通文件 |
isDirectory() | 是否为目录 |
size | 文件大小(字节) |
mtime | 内容最后修改时间 |
ctime | 状态(权限等)变更时间 |
birthtime | 创建时间 |
七、文件监听 watch
监听文件或目录的变化,可用于热重载、日志监控:
const fs = require("fs");
fs.watch("data.txt", (eventType, filename) => {
console.log("事件:", eventType); // 'change' 或 'rename'
console.log("文件:", filename);
});
// 监听整个目录
fs.watch("logs", { recursive: true }, (eventType, filename) => {
console.log("logs 目录发生变化:", filename);
});注意:
fs.watch依赖操作系统原生能力(Windows 上是 ReadDirectoryChangesW),不同平台的eventType语义略有差异,跨平台项目建议用社区库 chokidar。
八、path 模块
path 模块处理路径字符串,屏蔽了 Windows(反斜杠 \)与 POSIX(斜杠 /)的差异。
8.1 拼接与解析
const path = require("path");
// join:智能拼接,会规范化多余的分隔符
path.join("a", "b", "..", "c"); // 'a/c'
path.join(__dirname, "data.json"); // 基于当前目录拼接
// resolve:从右往左拼接,直到拼出绝对路径
path.resolve("a", "b"); // 'D:\\code\\demo\\a\\b'(基于 cwd)
path.resolve(__dirname, "dist"); // 绝对路径结果
// 取文件名与扩展名
path.basename("a/b/app.js"); // 'app.js'
path.basename("a/b/app.js", ".js"); // 'app'
path.dirname("a/b/app.js"); // 'a/b'
path.extname("a/b/app.js"); // '.js'8.2 join 与 resolve 的区别
| 方法 | 特点 | 典型用途 |
|---|---|---|
join | 纯字符串拼接规范化,不关心是否绝对 | 拼相对路径片段 |
resolve | 结果一定是绝对路径,基于 process.cwd() | 把相对路径转绝对路径 |
path.join("a", "/b", "c"); // 'a\\b\\c'(规范化,不产生绝对路径)
path.resolve("a", "/b", "c"); // 遇到以 / 开头的片段就重置基准,结果如 'D:\\b\\c'8.3 parse 与相对/绝对路径
const path = require("path");
const parsed = path.parse("D:/code/demo/app.js");
// {
// root: 'D:\\',
// dir: 'D:/code/demo',
// base: 'app.js',
// ext: '.js',
// name: 'app'
// }
console.log(path.isAbsolute("/x/y")); // true(POSIX 观点)
console.log(path.isAbsolute("x/y")); // false
// 计算相对路径
console.log(path.relative("/a/b/c", "/a/d")); // 结果为 '../../d'| 方法 | 作用 |
|---|---|
path.parse(p) | 把路径拆成 root/dir/base/ext/name |
path.isAbsolute(p) | 判断是否绝对路径 |
path.relative(from, to) | 求 from 到 to 的相对路径 |
path.normalize(p) | 规范化路径中的 ..、.、多余分隔符 |
九、path 与 URL 转换
Node 内置模块(如 fs)既接受路径字符串,也接受 file:// 协议的 URL 对象,两者需要互转时:
const path = require("path");
const { pathToFileURL, fileURLToPath } = require("url");
// 路径 → URL
const url = pathToFileURL("D:/code/demo/app.js");
console.log(url.href); // file:///D:/code/demo/app.js
// URL → 路径(ESM 中常用来替代 __dirname)
const dir = fileURLToPath(new URL(".", import.meta.url));
console.log(dir);ESM 模块没有 __dirname,这是获取当前目录的标准做法:
// app.mjs("type": "module" 下)
import { fileURLToPath } from "node:url";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
console.log(__dirname);十、错误处理
文件操作失败的典型错误:ENOENT(文件或目录不存在)与 EACCES(权限不足)。
| 错误码 | 含义 | 常见原因 |
|---|---|---|
ENOENT | 文件/目录不存在 | 路径写错、文件未创建 |
EACCES | 权限不足 | 只读目录写入、系统目录访问 |
EISDIR | 对目录执行了文件操作 | 对目录调用 readFile |
ENOTDIR | 路径中的某一段不是目录 | 把文件当目录拼接 |
EEXIST | 已存在 | mkdir 不递归且目录已存在 |
const fsp = require("fs/promises");
try {
const data = await fsp.readFile("no-such-file.txt", "utf8");
} catch (err) {
if (err.code === "ENOENT") {
console.log("文件不存在,走创建逻辑");
} else if (err.code === "EACCES") {
console.log("没有权限读取");
} else {
console.error("其他错误:", err);
}
}// 判断文件是否存在:不要先 existsSync 再操作(存在竞态),直接尝试并捕获
const fsp = require("fs/promises");
async function readOrEmpty(p) {
try {
return await fsp.readFile(p, "utf8");
} catch (err) {
if (err.code === "ENOENT") return "";
throw err; // 其他错误继续抛
}
}文件系统与路径是 Node 后端的地基,配合流与管道,就能构建出稳健的日志、上传、备份等能力。