05 文件与目录的日常操作
1. 建议使用 node:fs/promises
import {
copyFile,
mkdir,
readdir,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
Promise API 便于配合 async/await 和 try/finally。需要 Stream 时再从 node:fs 导入 createReadStream()、createWriteStream()。
2. 创建目录
await mkdir(outputDirectory, {
recursive: true,
});
recursive: true 表示父目录不存在时一并创建;目标已存在时通常不会报错。它不验证现有目标是否满足你的权限和业务归属。
3. 读取目录
const entries = await readdir(sourceDirectory, {
withFileTypes: true,
});
for (const entry of entries) {
if (entry.isFile()) {
console.log("file", entry.name);
} else if (entry.isDirectory()) {
console.log("directory", entry.name);
} else if (entry.isSymbolicLink()) {
console.log("link", entry.name);
}
}
使用 Dirent 能避免仅凭扩展名猜测目录类型。
4. 写入与追加
await writeFile(targetPath, html, "utf8");
默认可能覆盖已有文件。需要“已存在就失败”时:
await writeFile(targetPath, html, {
encoding: "utf8",
flag: "wx",
});
appendFile() 适合明确的追加语义,但多个进程同时追加复杂日志不能自动保证每条记录绝不交叉;生产日志应交给日志库和集中收集系统。
5. 复制与移动
await copyFile(sourcePath, targetPath);
await rename(tempPath, finalPath);
rename() 在同一文件系统中通常高效,适合临时文件完成后的替换。跨文件系统可能得到 EXDEV,此时需要复制、验证成功后再删除源文件。不要在未处理失败的情况下直接“复制后删除”。
6. 删除
单文件:
import { unlink } from "node:fs/promises";
await unlink(filePath);
任务目录:
await rm(taskDirectory, {
recursive: true,
force: true,
});
递归删除前必须验证解析后的绝对路径位于明确的任务根目录,并且不能等于根目录。force: true 只减少“不存在”错误,不会让错误目标变安全。
7. stat()、lstat() 与 access()
const info = await stat(filePath);
console.log(info.size, info.mtime);
stat() 跟随符号链接,lstat() 查看链接本身。安全解压和清理程序需要明确选择。
不要使用:
access() 检查存在
→ 一段时间
→ readFile()/writeFile()
两次操作之间目标可能变化。通常直接执行最终操作并处理 ENOENT、EEXIST 更可靠。
8. 递归查找 Markdown
import path from "node:path";
import { readdir } from "node:fs/promises";
export async function findMarkdownFiles(
directory: string,
): Promise<string[]> {
const result: string[] = [];
const entries = await readdir(directory, {
withFileTypes: true,
});
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
result.push(...await findMarkdownFiles(entryPath));
continue;
}
if (
entry.isFile() &&
path.extname(entry.name).toLowerCase() === ".md"
) {
result.push(entryPath);
}
}
return result;
}
这段代码适合受控的小目录。对任意深度和海量文件,还要增加深度、数量和并发上限,并决定是否拒绝符号链接。
练习题
- 使用
wx实现不覆盖写入,并处理EEXIST。 - 编写只清理
work/jobs/<taskId>的函数,禁止删除 jobs 根目录。 - 修改递归查找函数,增加最大深度和最大文件数量。
- 分析
access()后再writeFile()的竞态窗口。