阶段 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') // 删除空目录
后序遍历确保:
- 先删除子文件 (
fileCallback) - 再删除子目录 (递归)
- 最后删除父目录 (
dirCallback) - 每次调用
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 被保留
设计要点:
-
不删除目录本身
- 只清空内容
- 目录框架保留
-
保护 .git 目录
- 跳过
.git - 保留版本历史
- 跳过
-
安全检查
- 目录不存在时直接返回
- 避免错误
-
后序遍历
- 确保删除顺序正确
- 避免删除非空目录
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
├─ true → emptyDir() → 清空目录
└─ 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 总结
你学到了:
-
✅ Node.js 文件系统核心 API
existsSync,readdirSync,lstatSyncmkdirSync,unlinkSync,rmdirSync
-
✅ canSkipEmptying 智能检查
- 目录不存在 → true
- 目录为空 → true
- 只有 .git → true
- 其他情况 → false
-
✅ 目录遍历算法
- 前序遍历 vs 后序遍历
- 后序遍历的必要性
- 递归实现
-
✅ emptyDir 清空目录
- 后序遍历删除
- 保护 .git 目录
- 只清空内容,不删除目录本身
-
✅ 设计思路
- 安全第一(检查再操作)
- 用户友好(智能判断)
- 算法正确(后序删除)
关键要点:
| 概念 | 作用 |
|---|---|
| 后序遍历 | 确保删除顺序正确(子→父) |
| canSkipEmptying | 智能判断是否需要询问用户 |
| emptyDir | 安全清空目录内容 |
| 保护 .git | 允许在 git 仓库中创建项目 |
准备好进入阶段 5 了吗? 🚀
下一阶段将学习 Git 模板下载机制,揭秘如何通过 Node.js 执行 git 命令!