跟着Claude读Cli源码之四:node文件系统

51 阅读8分钟

阶段 4: 文件系统操作

📌 核心知识点

1. Node.js 文件系统 API 总览

create-unibest 使用的核心 API (来自 node:fs):

API用途返回值
existsSync(path)检查路径是否存在boolean
readdirSync(dir)读取目录内容string[] (文件名数组)
lstatSync(path)获取文件/目录信息Stats 对象
mkdirSync(path)创建目录void
unlinkSync(file)删除文件void
rmdirSync(dir)删除空目录void

2. canSkipEmptying - 智能目录检查

文件: src/utils/canSkipEmptying.ts

完整代码:
import { existsSync, readdirSync } from 'node:fs'

export function canSkipEmptying(dir: string) {
  // 情况 1: 目录不存在
  if (!existsSync(dir))
    return true

  // 情况 2: 读取目录内容
  const files = readdirSync(dir)

  // 情况 2a: 目录为空
  if (files.length === 0)
    return true

  // 情况 2b: 只有 .git 目录
  if (files.length === 1 && files[0] === '.git')
    return true

  // 情况 3: 目录非空
  return false
}
逻辑流程图:
canSkipEmptying(dir)
      ↓
目录是否存在?
  ├─ 否 → 返回 true (可以跳过)
  └─ 是 → 继续检查
      ↓
  读取目录内容
      ↓
  内容为空?
  ├─ 是 → 返回 true
  └─ 否 → 继续检查
      ↓
  只有 .git?
  ├─ 是 → 返回 true
  └─ 否 → 返回 false (需要询问用户)
返回值含义:
返回值含义操作
true可以跳过询问直接使用该目录
false需要询问用户显示覆盖确认问题
测试场景:
// 场景 1: 目录不存在
canSkipEmptying('./new-project')  // true

// 场景 2: 空目录
canSkipEmptying('./empty-dir')  // true

// 场景 3: 只有 .git
canSkipEmptying('./repo-with-git-only')  // true

// 场景 4: 有其他文件
canSkipEmptying('./existing-project')  // false
设计思路:

为什么允许 .git 目录?

  • 用户可能在已有的 git 仓库中创建项目
  • .git 目录不影响新项目的创建
  • 保留版本历史是合理的

例如:

# 用户先初始化了 git 仓库
mkdir my-project && cd my-project
git init

# 然后用 create-unibest 创建项目
pnpm create unibest .
# 应该允许,不需要询问覆盖

3. 目录遍历算法

文件: src/utils/directoryTraverse.ts

3.1 前序遍历 vs 后序遍历

前序遍历 (Pre-order):

处理父目录 → 递归处理子内容

后序遍历 (Post-order):

递归处理子内容 → 处理父目录
示例目录结构:
test/
├── file1.txt
├── dir1/
│   ├── file2.txt
│   └── subdir/
│       └── file3.txt
└── dir2/
    └── file4.txt
前序遍历顺序:
1. 处理 dir1/          ← 先处理父目录
2. 处理 file2.txt
3. 处理 subdir/        ← 先处理父目录
4. 处理 file3.txt
5. 处理 dir2/          ← 先处理父目录
6. 处理 file4.txt
7. 处理 file1.txt
后序遍历顺序:
1. 处理 file2.txt
2. 处理 file3.txt
3. 处理 subdir/        ← 子内容处理完才处理父目录
4. 处理 dir1/          ← 子内容处理完才处理父目录
5. 处理 file4.txt
6. 处理 dir2/          ← 子内容处理完才处理父目录
7. 处理 file1.txt

3.2 后序遍历实现

代码:

export const postOrderDirectoryTraverse: DirectoryTraverse = (
  dir,
  dirCallback,
  fileCallback
) => {
  for (const filename of fs.readdirSync(dir)) {
    // 跳过 .git 目录
    if (filename === '.git')
      continue

    const fullpath = path.resolve(dir, filename)

    if (fs.lstatSync(fullpath).isDirectory()) {
      // 🔑 关键:先递归处理子目录
      postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)

      // 然后处理当前目录
      dirCallback(fullpath)
      continue
    }

    // 处理文件
    fileCallback(fullpath)
  }
}
逐步分析:

步骤 1: 读取目录内容

for (const filename of fs.readdirSync(dir))
  • readdirSync(dir) 返回目录中所有文件/文件夹名
  • 例如: ['file1.txt', 'dir1', 'dir2']

步骤 2: 跳过 .git

if (filename === '.git') continue
  • 保护 .git 目录不被删除
  • 保留用户的版本历史

步骤 3: 获取完整路径

const fullpath = path.resolve(dir, filename)
  • path.resolve() 生成绝对路径
  • 例如: /Users/xxx/my-project/dir1

步骤 4: 判断是目录还是文件

if (fs.lstatSync(fullpath).isDirectory())
  • lstatSync() 获取文件信息
  • .isDirectory() 判断是否为目录

步骤 5: 递归处理目录

// 先递归子目录
postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)

// 再处理当前目录
dirCallback(fullpath)
  • 🔑 核心:先递归,再回调
  • 确保子内容先处理,父目录后处理

步骤 6: 处理文件

fileCallback(fullpath)
  • 直接调用文件回调

3.3 为什么删除操作需要后序遍历?

问题: rmdirSync() 只能删除空目录

// ❌ 错误:目录不为空
rmdirSync('./dir1')  // Error: ENOTEMPTY

// ✅ 正确:先删除子内容
unlinkSync('./dir1/file.txt')  // 删除文件
rmdirSync('./dir1')             // 删除空目录

后序遍历确保:

  1. 先删除子文件 (fileCallback)
  2. 再删除子目录 (递归)
  3. 最后删除父目录 (dirCallback)
  4. 每次调用 rmdirSync() 时,目录已经是空的

可视化示例:

目录结构:
dir1/
  ├── file.txt
  └── subdir/
      └── nested.txt

后序遍历删除顺序:
1. unlinkSync('dir1/file.txt')         ← 先删除子文件
2. unlinkSync('dir1/subdir/nested.txt') ← 递归删除
3. rmdirSync('dir1/subdir')             ← 删除空的子目录
4. rmdirSync('dir1')                    ← 删除空的父目录 ✅

4. emptyDir - 清空目录函数

文件: src/index.ts:128-137

完整代码:
function emptyDir(dir: string) {
  // 目录不存在,直接返回
  if (!existsSync(dir))
    return

  // 使用后序遍历删除所有内容
  postOrderDirectoryTraverse(
    dir,
    dir => rmdirSync(dir),      // 删除目录
    file => unlinkSync(file),   // 删除文件
  )
}
工作流程:
emptyDir('./my-project')
      ↓
目录是否存在?
  ├─ 否 → 直接返回
  └─ 是 → 继续
      ↓
postOrderDirectoryTraverse(
  './my-project',
  dir => rmdirSync(dir),    ← 目录回调
  file => unlinkSync(file)  ← 文件回调
)
      ↓
后序遍历:
  1. 处理子文件 → unlinkSync()
  2. 处理子目录 → 递归
  3. 处理当前目录 → rmdirSync()
      ↓
目录被清空(但目录本身保留)
使用示例:
// create-unibest 实际使用
const root = join(cwd, result.projectName!)  // '/path/to/my-app'

if (existsSync(root) && result.shouldOverwrite) {
  emptyDir(root)  // 清空 my-app 目录
}
else if (!existsSync(root)) {
  mkdirSync(root)  // 创建 my-app 目录
}
执行效果:

清空前:

my-app/
├── old-file.txt
├── src/
│   ├── index.js
│   └── utils/
│       └── helper.js
└── .git/
    └── ...

执行 emptyDir('./my-app') 后:

my-app/
└── .git/      ← .git 被保留
设计要点:
  1. 不删除目录本身

    • 只清空内容
    • 目录框架保留
  2. 保护 .git 目录

    • 跳过 .git
    • 保留版本历史
  3. 安全检查

    • 目录不存在时直接返回
    • 避免错误
  4. 后序遍历

    • 确保删除顺序正确
    • 避免删除非空目录

5. 完整文件系统操作流程

create-unibest 主流程中的文件操作:
async function init() {
  // ... 前面的问答流程

  // 1️⃣ 计算目标路径
  const cwd = process.cwd()              // 当前工作目录
  const root = join(cwd, projectName)    // 项目目录

  // 2️⃣ 处理目录
  if (existsSync(root) && result.shouldOverwrite) {
    // 目录存在且用户选择覆盖
    emptyDir(root)
  }
  else if (!existsSync(root)) {
    // 目录不存在,创建
    mkdirSync(root)
  }

  // 3️⃣ 下载模板(下一阶段讲解)
  await dowloadTemplate(...)
}
流程图:
用户输入: my-app
     ↓
计算路径: /current/path/my-app
     ↓
目录是否存在?
  ├─ 否 → mkdirSync() → 创建目录
  └─ 是 → 检查 shouldOverwrite
      ├─ trueemptyDir() → 清空目录
      └─ false → 跳过(不应该到这里,问答阶段已处理)
     ↓
目录准备完成,开始下载模板

6. path 模块关键 API

create-unibest 使用的 node:path API:

API用途示例
join(...paths)连接路径join('a', 'b')'a/b'
resolve(...paths)解析为绝对路径resolve('a', 'b')'/cwd/a/b'
join vs resolve:
// join - 简单连接
path.join('/a', 'b', 'c')    // '/a/b/c'
path.join('a', 'b', 'c')     // 'a/b/c'  (相对路径)

// resolve - 解析为绝对路径
path.resolve('/a', 'b', 'c') // '/a/b/c'
path.resolve('a', 'b', 'c')  // '/current/working/dir/a/b/c'

// create-unibest 的使用
const root = join(cwd, projectName)  // 连接路径
const fullpath = resolve(dir, filename)  // 解析绝对路径

💡 设计思路总结

1. 安全第一

检查再操作:

if (!existsSync(dir)) return  // 目录不存在,安全返回

保护重要数据:

if (filename === '.git') continue  // 跳过 .git

2. 用户友好

智能判断:

  • 空目录 → 自动跳过询问
  • 只有 .git → 自动跳过询问
  • 有其他文件 → 询问用户

保留版本历史:

  • 允许在 git 仓库中创建项目
  • 不删除 .git 目录

3. 算法选择

后序遍历删除:

  • 先删除子文件
  • 再删除子目录
  • 最后删除父目录
  • 确保 rmdirSync() 总是删除空目录

4. 模块化设计

src/utils/
├── canSkipEmptying.ts     # 目录状态检查
├── directoryTraverse.ts   # 遍历算法
└── (其他工具函数)

src/index.ts
└── emptyDir()             # 清空目录

🔧 Node.js 文件系统 API 速查

同步 API (Sync)

// 检查
existsSync(path): boolean
statSync(path): Stats
lstatSync(path): Stats

// 读取
readdirSync(dir): string[]
readFileSync(file): Buffer | string

// 写入
writeFileSync(file, data): void
mkdirSync(path): void

// 删除
unlinkSync(file): void     // 删除文件
rmdirSync(dir): void       // 删除空目录
rmSync(path, options): void  // 删除文件或目录(递归)

Stats 对象方法

const stats = lstatSync(path)

stats.isFile(): boolean       // 是文件
stats.isDirectory(): boolean  // 是目录
stats.isSymbolicLink(): boolean  // 是符号链接
stats.size: number            // 文件大小(字节)

🎯 常见模式和最佳实践

模式 1: 安全删除目录

function safeRemoveDir(dir: string) {
  if (!existsSync(dir)) return

  postOrderDirectoryTraverse(
    dir,
    (d) => rmdirSync(d),
    (f) => unlinkSync(f)
  )
}

模式 2: 检查并创建目录

function ensureDir(dir: string) {
  if (!existsSync(dir)) {
    mkdirSync(dir, { recursive: true })
  }
}

模式 3: 安全读取目录

function readDirSafe(dir: string): string[] {
  if (!existsSync(dir)) return []
  return readdirSync(dir)
}

模式 4: 遍历目录文件

function getFiles(dir: string): string[] {
  const files: string[] = []

  for (const item of readdirSync(dir)) {
    const fullPath = join(dir, item)

    if (lstatSync(fullPath).isDirectory()) {
      files.push(...getFiles(fullPath))  // 递归
    } else {
      files.push(fullPath)
    }
  }

  return files
}

📚 延伸阅读

Node.js 官方文档

推荐库


✅ 阶段 4 总结

你学到了:

  1. Node.js 文件系统核心 API

    • existsSync, readdirSync, lstatSync
    • mkdirSync, unlinkSync, rmdirSync
  2. canSkipEmptying 智能检查

    • 目录不存在 → true
    • 目录为空 → true
    • 只有 .git → true
    • 其他情况 → false
  3. 目录遍历算法

    • 前序遍历 vs 后序遍历
    • 后序遍历的必要性
    • 递归实现
  4. emptyDir 清空目录

    • 后序遍历删除
    • 保护 .git 目录
    • 只清空内容,不删除目录本身
  5. 设计思路

    • 安全第一(检查再操作)
    • 用户友好(智能判断)
    • 算法正确(后序删除)

关键要点:

概念作用
后序遍历确保删除顺序正确(子→父)
canSkipEmptying智能判断是否需要询问用户
emptyDir安全清空目录内容
保护 .git允许在 git 仓库中创建项目

准备好进入阶段 5 了吗? 🚀

下一阶段将学习 Git 模板下载机制,揭秘如何通过 Node.js 执行 git 命令!