文件系统与 path
这一课你将能做到
- 用
node:fs/promises 完成读、写、追加、建目录、列目录、改名、删除等常见文件操作
- 用
node:path 拼出跨平台不出错的路径,说清 join 与 resolve 的区别
- 判断什么时候能用同步 API、什么时候必须异步,并理解 ENOENT 等常见错误码
为什么要学
Node 离开浏览器最大的红利就是能碰文件系统:内容批量替换、日志分析、生成报表、把配置落到磁盘……全都绕不开 fs。而路径错误是文件操作里最高频的事故源——Windows 用 \,macOS/Linux 用 /,C:\ 和 /home/ 的绝对路径写法也不同,全靠 path 模块兜底。
本站的很多工程脚本(把 Markdown 导入数据库、生成静态页)就是 fs + path + 第 2 课的 process.argv 组合出来的。这课学完,你就能独立写「读一批文件、处理、写回去」的完整工具。
先建立图像
fs 的三套 API(同一件事三种写法):
node:fs/promises → await fs.readFile(...) ← 首选,不阻塞、写法清爽
node:fs (callback) → fs.readFile(..., cb) ← 老代码,保持会读即可
node:fs (sync) → fs.readFileSync(...) ← 启动期/脚本一次性场景
path 的分工:
join('a', 'b', 'c.txt') → a\b\c.txt 拼接(自动补分隔符)
resolve('a', 'b.txt') → h:\work\a\b.txt 从 cwd 出发算绝对路径
__dirname → 脚本自己的目录(最稳的锚点)
记忆口诀:路径用 join 拼、位置用 resolve 算、锚点是 __dirname。
跟着做
步骤 1:读文件(promises 版)
新建 node-fs 目录,npm init -y。先造一个样本文件 note.txt(内容随意),再新建 read.js:
// read.js
const fs = require('node:fs/promises');
const path = require('node:path');
async function main() {
const filePath = path.join(__dirname, 'note.txt');
const text = await fs.readFile(filePath, 'utf8');
console.log('内容:', text);
}
main();
node read.js
# 内容: (你 note.txt 里的内容)
两个关键点:'utf8' 不传会拿到 Buffer(二进制对象,打印出来是十六进制);readFile 返回 Promise,必须 await 或 .then。
步骤 2:写文件与追加
新建 write.js:
// write.js
const fs = require('node:fs/promises');
const path = require('node:path');
async function main() {
const file = path.join(__dirname, 'log.txt');
await fs.writeFile(file, '第一行\n', 'utf8'); // 整体覆盖
await fs.appendFile(file, '第二行\n', 'utf8'); // 追加
await fs.appendFile(file, '第三行\n', 'utf8');
const content = await fs.readFile(file, 'utf8');
console.log(content);
}
main();
node write.js
# 第一行
# 第二行
# 第三行
node write.js
# 还是这三行 —— writeFile 每次整体覆盖
注意语义:writeFile 是「覆盖写」,想追加必须 appendFile。跑两次脚本输出不变,正好验证了覆盖语义。
步骤 3:目录操作
新建 dirs.js,演示建目录、列目录、判断存在:
// dirs.js
const fs = require('node:fs/promises');
const path = require('node:path');
async function main() {
const dir = path.join(__dirname, 'data', 'posts');
// recursive: 父目录不存在就一并创建,已存在也不报错
await fs.mkdir(dir, { recursive: true });
// 在里面造两个文件
await fs.writeFile(path.join(dir, 'a.txt'), 'A');
await fs.writeFile(path.join(dir, 'b.txt'), 'B');
// 列出目录内容
const entries = await fs.readdir(dir);
console.log('文件:', entries); // [ 'a.txt', 'b.txt' ]
// 判断文件/目录是否存在:用 access,不要用 try-catch 包 readFile 之外的花活
try {
await fs.access(dir);
console.log('目录存在');
} catch {
console.log('目录不存在');
}
}
main();
node dirs.js
# 文件: [ 'a.txt', 'b.txt' ]
# 目录存在
老代码里常见 fs.existsSync(dir)(同步版),脚本里偶尔用没问题;异步代码里统一用 fs.access + try/catch。
步骤 4:stat——区分文件与目录、拿大小
// stat.js
const fs = require('node:fs/promises');
const path = require('node:path');
async function main() {
const dir = __dirname;
const entries = await fs.readdir(dir);
for (const name of entries) {
const full = path.join(dir, name);
const s = await fs.stat(full);
const type = s.isDirectory() ? '目录' : '文件';
console.log(`${type} ${String(s.size).padStart(6)} 字节 ${name}`);
}
}
main();
node stat.js
# 文件 128 note.txt
# 目录 data
# ...
stat 返回 size(字节)、mtime(修改时间)等元数据,配合 isFile() / isDirectory() 就能写出「扫目录、按类型分发」的通用工具。
步骤 5:改名、复制、删除
// manage.js
const fs = require('node:fs/promises');
const path = require('node:path');
async function main() {
const dir = path.join(__dirname, 'data', 'posts');
await fs.rename(path.join(dir, 'a.txt'), path.join(dir, 'a-renamed.txt'));
console.log('改名完成');
await fs.copyFile(
path.join(dir, 'a-renamed.txt'),
path.join(dir, 'a-copy.txt')
);
console.log('复制完成');
await fs.unlink(path.join(dir, 'a-copy.txt')); // 删除文件
console.log('删除完成');
await fs.rm(path.join(dir, 'b.txt'), { force: true }); // 不存在也不报错
await fs.rm(path.join(__dirname, 'data'), { recursive: true, force: true });
console.log('目录清理完成');
}
main();
安全红线:unlink/rm 没有回收站。涉及删除的脚本,先打印将要删除的完整路径让人确认,再真正执行;批量删除加 recursive 时务必先核对目标目录。
步骤 6:path 的核心方法
// path-demo.js
const path = require('node:path');
// 1. join:拼接,自动处理分隔符和 ..
console.log(path.join('src', 'utils', 'a.js')); // src\utils\a.js (win)
console.log(path.join('src', '..', 'public', 'x.css')); // public\x.css
// 2. resolve:从 cwd(或给定起点)算出绝对路径
console.log(path.resolve('data', 'a.txt'));
// h:\work\node-fs\data\a.txt(取决于你终端的当前目录)
// 3. 解析路径成分
const p = path.join(__dirname, 'data', 'posts', 'a.txt');
console.log(path.dirname(p)); // .../data/posts 目录
console.log(path.basename(p)); // a.txt 文件名
console.log(path.basename(p, '.txt')); // a 去后缀
console.log(path.extname(p)); // .txt 后缀(含点)
// 4. 跨平台分隔符
console.log(path.sep); // win 是 \,linux/mac 是 /
join vs resolve 一句话:join 只做拼接(结果可相对可绝对,取决于输入),resolve 把结果算成绝对路径(以 cwd 或第一个绝对路径参数为基准)。
讲清楚要点
- 优先
node:fs/promises:代码线性、异常用统一 try/catch。callback 版只在改老代码时遇到;xxxSync 版会阻塞事件循环,只适合启动加载配置、一次性 CLI 脚本。
- 不传
'utf8' 拿到的是 Buffer:写二进制(图片、gzip 数据)时 Buffer 正合适;读写文本永远显式传 'utf8'。
- 相对路径参照 cwd 而不是脚本:
fs.readFile('note.txt') 读的是终端当前目录的文件。锚定脚本目录用 path.join(__dirname, ...),ESM 用 import.meta.url 推导。
- 存在性检查与操作之间有竞态:
access 通过不代表后面 readFile 一定成功(文件可能刚被删)。实践准则:先操作、用 try/catch 接住错误,而不是先检查再操作。
- 删除操作最危险:
rm 的 recursive + force 组合等于「删整棵树、错也不报」,脚本里用它必须先校验路径来自可信配置而非用户输入。
你可能会懵的地方
-
ENOENT / EEXIST / EACCES 这些错误码什么意思?
ENOENT = 文件或目录不存在;EEXIST = 已存在(如不带 recursive 的 mkdir);EACCES/EPERM = 无权限;EISDIR = 期望文件却是目录。catch 里按 err.code 分支处理,比看报错文案可靠。
-
fs.rename 能跨盘符移动文件吗?
不能保证:跨设备(如 C 盘到 D 盘)rename 会报 EXDEV。稳妥的「移动」是 copyFile 成功后 unlink 原文件。
-
readFile 一次读 2GB 的文件行吗?
语法上行,内存上不行——整个文件进内存。大文件用第 9 课的流(createReadStream),按块处理。
-
path.join('/a', 'b') 在 Windows 输出什么?
\a\b。path 只关心分隔符语法,不校验磁盘真实存在。另外注意「字符串碰巧像路径」和「真实文件系统路径」的区别:拼接后的字符串要真正去读写才算数。
-
遍历子目录树怎么做?
fs.readdir(dir, { withFileTypes: true }) 返回带 isDirectory() 的对象数组,是目录就递归。进阶可用 Node 20 的 fs.glob(实验性)或 glob 这类包。
错法对照
| 错法 | 问题 | 正解 |
|---|
'data/' + name + '/file.txt' 手拼路径 | 分隔符错、.. 不清理、跨平台崩 | path.join('data', name, 'file.txt') |
fs.readFile(p) 打印出 <Buffer ...> | 忘了编码 | fs.readFile(p, 'utf8') |
| 同步 API 写进 HTTP 请求处理器 | 一次慢读写卡住所有请求 | 用 fs/promises 异步版 |
先 access 再 open,不做 catch | 存在性检查与操作之间文件可能消失 | 直接操作,catch 里按 err.code 处理 |
rm(dir, { recursive: true }) 删变量拼的路径 | 路径算错整棵树没了 | 删除前 console.log 确认 + 白名单校验 |
ESM 里直接用 __dirname | ReferenceError | fileURLToPath(import.meta.url) 推导 |
卡点急救
ENOENT: no such file or directory:三查——相对路径是不是踩了 cwd 坑(改 path.join(__dirname, ...));文件名拼写;父目录是否已创建。
- 写文件后内容是乱码或
[object Object]:写对象前 JSON.stringify(obj, null, 2);读回来 JSON.parse。直接把对象传给 writeFile 会变成 [object Object] 字符串。
EACCES 权限被拒:Windows 检查文件是否被占用(Excel 开着的 csv 不能写);Linux 检查文件属主与目录权限,必要时用管理员运行。
EEXIST: file already exists:mkdir 没加 { recursive: true },或 writeFileSync 开了 { flag: 'wx' }(强制新建)却撞了已有文件。
- 改了文件但程序读到旧内容:编辑器「安全写入」会换 inode,用
rename 类保存的编辑器+监听工具会失灵;确认脚本读的路径和编辑器保存的路径是同一个。
面试常问
-
fs 的 promise / callback / sync 三套 API 怎么选?
新代码选 fs/promises(非阻塞 + 语法简洁);callback 版用于维护存量代码;sync 版只用于进程启动加载配置、一次性 CLI 脚本等「阻塞无妨」的场景,绝不能放进并发服务的请求路径。
-
path.join 和 path.resolve 的区别?
join 把片段用分隔符连起来并规范化(不保证绝对路径);resolve 从右往左解析,遇到绝对路径片段就用它替换左侧,否则以 cwd 为基准,最终返回绝对路径。
-
如何安全地批量删除目录?
校验路径来源(白名单/前缀检查)、打印目标让人确认、用 rm 的 recursive+force 前先限制在受控根目录之内;关键任务先复制或打包再删。
-
大文件为什么不能用 readFile?
readFile 把整个文件载入内存,GB 级文件会撑爆内存。用 createReadStream 按块(chunk)处理,内存占用恒定。
自测三问
fs.writeFile 和 fs.appendFile 的语义区别?
- 在
h:\work 目录执行 node tools/a.js,脚本里 path.resolve('data') 和 path.join(__dirname, 'data') 各是什么?
err.code === 'ENOENT' 代表什么?在什么操作里最常见?
小结一句话
fs/promises 负责文件增删改查,path 负责把路径拼对拼稳;记住「异步优先、utf8 别忘、__dirname 当锚、删除先确认」四条,文件脚本就立得住。
延伸