05 文件与目录的日常操作

1. 建议使用 node:fs/promises

import {
  copyFile,
  mkdir,
  readdir,
  rename,
  rm,
  stat,
  writeFile,
} from "node:fs/promises";

Promise API 便于配合 async/awaittry/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()

两次操作之间目标可能变化。通常直接执行最终操作并处理 ENOENTEEXIST 更可靠。

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;
}

这段代码适合受控的小目录。对任意深度和海量文件,还要增加深度、数量和并发上限,并决定是否拒绝符号链接。

练习题

  1. 使用 wx 实现不覆盖写入,并处理 EEXIST
  2. 编写只清理 work/jobs/<taskId> 的函数,禁止删除 jobs 根目录。
  3. 修改递归查找函数,增加最大深度和最大文件数量。
  4. 分析 access() 后再 writeFile() 的竞态窗口。