阶段 5: Git 模板下载机制
📌 核心知识点
1. child_process 模块简介
Node.js 的 child_process 模块允许我们在 Node.js 中执行系统命令。
核心 API:
| API | 用途 | 特点 |
|---|---|---|
exec() | 执行 shell 命令 | 缓冲输出,适合短命令 |
execFile() | 执行可执行文件 | 不启动 shell,更安全 |
spawn() | 启动子进程 | 流式输出,适合长命令 |
fork() | 创建 Node.js 子进程 | 专门用于 Node.js 脚本 |
create-unibest 使用 exec() 的原因:
- git clone 命令简短
- 输出量小
- 不需要流式处理
- 代码简洁
2. exec() API 详解
基本语法:
import { exec } from 'node:child_process'
exec(command, callback)
exec(command, options, callback)
参数说明:
command - 要执行的shell命令(字符串)
exec('ls -la')
exec('git --version')
exec('npm install')
callback - 回调函数 (error, stdout, stderr) => {...}
exec('echo "Hello"', (error, stdout, stderr) => {
// error: 命令执行失败时的错误对象
// stdout: 命令的标准输出(成功信息)
// stderr: 命令的标准错误输出(错误信息)
})
完整示例:
exec('git --version', (error, stdout, stderr) => {
if (error) {
console.error('执行失败:', error.message)
return
}
if (stderr) {
console.error('错误输出:', stderr)
}
console.log('成功输出:', stdout)
// 输出: git version 2.x.x
})
3. Promise 化 exec
问题: exec 使用回调,不支持 async/await
解决方案: 手动包装为 Promise
function execPromise(command) {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject(error)
} else {
resolve(stdout.trim())
}
})
})
}
// 使用
const output = await execPromise('git --version')
console.log(output)
create-unibest 的实现:
await new Promise<void>((resolve, reject) => {
const execStr = `git clone -b ${branch} ${gitUrl} ${localPath}`
exec(execStr, async (error) => {
if (error) {
reject(error)
return
}
try {
await removeGitFolder(localPath)
resolve()
} catch (error) {
reject(error)
}
})
})
4. git clone 命令详解
基本语法:
git clone <repository> [<directory>]
git clone -b <branch> <repository> [<directory>]
create-unibest 使用的命令:
git clone -b base https://gitee.com/codercup/unibest.git my-app
# │ │ │ │
# │ │ └─ 仓库 URL └─ 目标目录
# │ └─ 分支名
# └─ 指定分支选项
参数说明:
| 参数 | 说明 | 示例 |
|---|---|---|
-b <branch> | 克隆指定分支 | -b main |
<repository> | Git 仓库地址 | https://github.com/user/repo.git |
<directory> | 本地目录名 | my-project |
执行效果:
git clone -b base https://gitee.com/codercup/unibest.git my-app
结果:
my-app/
├── .git/ # Git 仓库元数据
├── src/
├── package.json
└── ... (模板的所有文件)
5. create-unibest 的完整下载流程
架构概览:
dowloadTemplate() # 入口函数
↓
getRepoUrlList() # 获取 URL 列表
↓
cloneRepo() # 克隆仓库(支持多 URL 容错)
↓
removeGitFolder() # 删除 .git 目录
↓
replaceProjectName() # 替换项目名
↓
callBack?() # 执行回调(如果有)
5.1 dowloadTemplate - 主入口
文件: src/utils/cloneRepo.ts:60-75
代码:
export async function dowloadTemplate(
data: BaseTemplateList['value'], // 模板配置
name: string, // 项目名
root: string, // 目标路径
loading: Ora // 加载动画
) {
// 1. 获取仓库 URL 列表(gitee + github)
const repoUrlList = getRepoUrlList(data.url)
try {
// 2. 克隆仓库
await cloneRepo(
repoUrlList,
data.branch || 'base', // 默认 base 分支
root
)
} catch (error) {
// 3. 失败处理
loading.fail('模板创建失败!')
process.exit(1)
}
// 4. 替换项目名
replaceProjectName(root, name)
// 5. 执行回调(可选)
data.callBack?.(root)
}
参数详解:
// data - 模板配置对象
{
type: 'base',
branch: 'base',
url: {
gitee: 'https://gitee.com/codercup/unibest.git',
github: 'https://github.com/codercup/unibest.git'
},
callBack: (root) => {
// 可选的后处理逻辑
}
}
// name - 项目名
'my-app'
// root - 完整路径
'/Users/xxx/my-app'
// loading - 加载动画实例
ora('正在创建模板...').start()
5.2 getRepoUrlList - URL 列表生成
文件: src/utils/cloneRepo.ts:55-58
代码:
function getRepoUrlList(url: BaseTemplateList['value']['url']) {
const { github, gitee } = url
return [gitee, github].filter(Boolean) as string[]
}
逻辑:
// 输入
url = {
gitee: 'https://gitee.com/codercup/unibest.git',
github: 'https://github.com/codercup/unibest.git'
}
// 提取并过滤
[gitee, github].filter(Boolean)
// 输出(gitee 在前!)
[
'https://gitee.com/codercup/unibest.git', // 优先
'https://github.com/codercup/unibest.git' // 备用
]
为什么 gitee 在前?
- Gitee 是国内服务,访问更快
- GitHub 在国内可能较慢或被墙
- 双源容错,提升成功率
5.3 cloneRepo - 核心克隆逻辑 ⭐
文件: src/utils/cloneRepo.ts:15-53
完整代码分析:
async function cloneRepo(
gitUrls: string[], // URL 列表
branch: string, // 分支名
localPath: string // 目标路径
): Promise<void> {
let lastError = null
// 🔑 关键:遍历所有 URL,一个失败尝试下一个
for (const gitUrl of gitUrls) {
try {
// 🔑 关键:将回调式 exec 包装为 Promise
await new Promise<void>((resolve, reject) => {
// 构建 git clone 命令
const execStr = `git clone -b ${branch} ${gitUrl} ${localPath}`
// 执行命令
exec(execStr, async (error) => {
if (error) {
console.error('exec error:', error)
reject(error)
return
}
try {
// 🔑 关键:删除 .git 目录
await removeGitFolder(localPath)
resolve()
} catch (error) {
reject(error)
}
})
})
return // 🔑 关键:成功则立即退出,不尝试其他 URL
} catch (error) {
console.error('cloneRepo error:', error)
lastError = error
// 继续尝试下一个 URL
}
}
// 所有 URL 都失败
if (lastError) {
throw new Error('All URLs failed')
}
}
执行流程可视化:
cloneRepo([giteeUrl, githubUrl], 'base', './my-app')
↓
┌─────────────────────────────────┐
│ 尝试 giteeUrl │
│ │
│ exec('git clone -b base...') │
│ ├─ 成功 → removeGitFolder() │
│ │ └─ return ✅ │
│ └─ 失败 → catch │
│ └─ 继续下一个 │
└─────────────────────────────────┘
↓ (如果失败)
┌─────────────────────────────────┐
│ 尝试 githubUrl │
│ │
│ exec('git clone -b base...') │
│ ├─ 成功 → removeGitFolder() │
│ │ └─ return ✅ │
│ └─ 失败 → catch │
│ └─ throw Error │
└─────────────────────────────────┘
容错设计的精髓:
- 多源备份:
[gitee, github]两个源 - 顺序尝试: 一个失败自动尝试下一个
- 快速退出: 一个成功立即返回,不浪费时间
- 错误保留: 保存最后一个错误,便于调试
5.4 removeGitFolder - 删除 Git 元数据
文件: src/utils/cloneRepo.ts:10-13
代码:
async function removeGitFolder(localPath: string): Promise<void> {
const gitFolderPath = join(localPath, '.git')
await fs.rm(gitFolderPath, { recursive: true, force: true })
}
为什么要删除 .git?
克隆后的目录:
my-app/
├── .git/ ← 包含原始仓库的 Git 历史
│ ├── objects/
│ ├── refs/
│ └── ...
├── src/
└── package.json
问题:
.git目录包含 unibest 仓库的历史- 用户项目不应该关联到 unibest 仓库
- 用户应该初始化自己的 git 仓库
删除后:
my-app/
├── src/
└── package.json
# 用户可以自己初始化
$ cd my-app
$ git init # 创建新的 git 仓库
$ git remote add origin <user-repo>
fs.rm() 参数:
await fs.rm(gitFolderPath, {
recursive: true, // 递归删除子目录
force: true // 强制删除,忽略不存在的错误
})
6. 完整执行流程示例
用户执行:
pnpm create unibest my-app -t demo
内部流程:
1️⃣ 参数解析
projectName = 'my-app'
templateType = {
type: 'demo',
branch: 'main',
url: {
gitee: 'https://gitee.com/codercup/hello-unibest.git',
github: 'https://github.com/codercup/hello-unibest.git'
}
}
2️⃣ dowloadTemplate() 被调用
getRepoUrlList(url)
↓
repoUrlList = [ 'https://gitee.com/codercup/hello-unibest.git', 'https://github.com/codercup/hello-unibest.git']
3️⃣ cloneRepo() 执行
尝试 URL 1: gitee
↓
exec('git clone -b main https://gitee.com/codercup/hello-unibest.git my-app')
↓
命令执行中...
↓
成功!
↓
removeGitFolder('my-app')
↓
删除 my-app/.git
↓
返回 ✅
4️⃣ replaceProjectName()
修改 my-app/package.json
"name": "unibest" → "name": "my-app"
5️⃣ 完成!
my-app/
├── src/
├── package.json # name: "my-app"
└── ...
7. 错误处理和容错机制
7.1 多源容错
场景: Gitee 服务器故障或网络问题
尝试 Gitee
├─ 网络错误 → catch → 继续
↓
尝试 GitHub
├─ 成功 ✅
└─ 失败 → throw Error
代码体现:
for (const gitUrl of gitUrls) {
try {
await cloneRepo(...)
return // 成功退出
} catch (error) {
lastError = error // 保存错误
// 继续尝试下一个
}
}
if (lastError) {
throw new Error('All URLs failed')
}
7.2 加载动画集成
try {
await cloneRepo(...)
} catch (error) {
loading.fail('模板创建失败!') // 停止动画并显示失败
process.exit(1) // 退出程序
}
用户体验:
⠋ 正在创建模板... # 加载中
↓
✔ 模板创建完成! # 成功
或
✖ 模板创建失败! # 失败
7.3 错误信息
exec 错误:
exec('git clone ...', (error) => {
if (error) {
console.error('exec error:', error)
// Error: Command failed: git clone ...
// fatal: repository 'xxx' not found
}
})
常见错误类型:
| 错误 | 原因 | 解决 |
|---|---|---|
repository not found | 仓库地址错误 | 检查 URL |
could not resolve host | 网络问题 | 检查网络/尝试其他源 |
Permission denied | 权限不足 | 检查 SSH 密钥 |
already exists | 目标目录已存在 | 应该已被 emptyDir 处理 |
8. 模板数据配置
文件: src/question/template/templateDate.ts
模板配置示例:
{
title: `demo${green('(演示项目)')}`,
description: `${red('(多TAB演示项目)')}`,
value: {
type: 'demo',
branch: 'main', // 🔑 指定分支
url: {
gitee: 'https://gitee.com/codercup/hello-unibest.git',
github: 'https://github.com/codercup/hello-unibest.git'
},
callBack: (root) => {
// 可选:下载后的自定义处理
console.log('Demo 模板下载完成!', root)
}
}
}
不同模板使用不同分支:
// base 模板 - 使用 base 分支
{
type: 'base',
branch: 'base',
url: { /* same repo */ }
}
// i18n 模板 - 使用 i18n 分支
{
type: 'i18n',
branch: 'i18n',
url: { /* same repo */ }
}
// demo 模板 - 使用不同仓库的 main 分支
{
type: 'demo',
branch: 'main',
url: {
gitee: 'https://gitee.com/codercup/hello-unibest.git',
github: 'https://github.com/codercup/hello-unibest.git'
}
}
设计优势:
- 一个仓库,多个分支 = 多个模板
- 减少仓库数量,方便维护
- 不同场景可以用不同仓库
💡 设计思路总结
1. 动态下载 vs 打包内置
动态下载(create-unibest 采用):
✅ 优势:
- CLI 包体积小(~94KB)
- 模板可以随时更新
- 无需重新发布 CLI
- 用户始终获得最新模板
❌ 劣势:
- 需要网络连接
- 下载需要时间
- 依赖 Git
打包内置:
✅ 优势:
- 离线可用
- 速度快(无需下载)
- 不依赖外部服务
❌ 劣势:
- CLI 包体积大
- 更新模板需要发布新版 CLI
- 用户需要更新 CLI 才能获得新模板
2. 多源容错设计
问题: 单一数据源的风险
- 服务器故障
- 网络问题
- 地域限制(GitHub 在国内较慢)
解决方案:
const repoUrlList = [gitee, github]
for (const url of repoUrlList) {
try {
await clone(url)
return // 成功则退出
} catch {
continue // 失败则尝试下一个
}
}
效果:
- 提升成功率
- 用户体验更好
- 国内外都能快速访问
3. 清理 .git 的必要性
保留 .git 的问题:
cd my-app
git remote -v
# origin https://gitee.com/codercup/unibest.git ❌
# 用户的项目竟然关联到 unibest 仓库!
删除 .git 后:
cd my-app
git init
git remote add origin <user-repo>
# 用户可以关联自己的仓库 ✅
4. Promise 包装的优雅性
回调地狱:
exec('cmd1', (err1, out1) => {
if (err1) return handleError(err1)
exec('cmd2', (err2, out2) => {
if (err2) return handleError(err2)
// ...
})
})
Promise 化后:
await execPromise('cmd1')
await execPromise('cmd2')
// 清晰、易读、易维护
🔧 child_process API 对比
| API | 启动 Shell | 返回类型 | 输出方式 | 适用场景 |
|---|---|---|---|---|
exec() | ✅ | Buffer | 缓冲 | 短命令、简单输出 |
execFile() | ❌ | Buffer | 缓冲 | 可执行文件、更安全 |
spawn() | ❌ | Stream | 流式 | 长命令、大量输出 |
fork() | ❌ | Process | 消息 | Node.js 子进程 |
为什么 create-unibest 用 exec?
- git clone 命令短小
- 输出量小,适合缓冲
- 需要 shell 解析命令
- 代码简洁
📚 延伸阅读
Node.js 官方文档
Git 文档
推荐库
- execa - 更好的 child_process 包装
- simple-git - Git 操作的 Node.js 封装
- degit - 无 Git 历史的仓库下载
✅ 阶段 5 总结
你学到了:
-
✅ child_process.exec() API
- 基本用法和参数
- 回调函数 (error, stdout, stderr)
- Promise 包装技巧
-
✅ git clone 命令
- 语法:
git clone -b <branch> <url> <dir> - 克隆指定分支
- 克隆到指定目录
- 语法:
-
✅ 多源容错设计 ⭐核心
- URL 列表:
[gitee, github] - 顺序尝试,一个成功即返回
- 保存最后错误,便于调试
- URL 列表:
-
✅ 完整下载流程
dowloadTemplate()→ 入口getRepoUrlList()→ 生成 URL 列表cloneRepo()→ 克隆(容错)removeGitFolder()→ 清理 .gitreplaceProjectName()→ 修改项目名
-
✅ 设计思路
- 动态下载 vs 打包内置
- 多源容错提升成功率
- 删除 .git 避免关联错误
- Promise 化提升代码可读性
关键要点:
| 概念 | 作用 |
|---|---|
exec() | 在 Node.js 中执行 shell 命令 |
| Promise 包装 | 将回调式 API 转为 async/await |
| 多源容错 | 提升下载成功率 |
| 删除 .git | 避免用户项目关联到模板仓库 |
准备好进入阶段 6 了吗? 🚀
下一阶段将学习 项目初始化处理,包括 JSON 文件的读写和项目名替换!