跟着Claude读源码之五:Git模版下载机制

35 阅读5分钟

阶段 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    │
└─────────────────────────────────┘

容错设计的精髓:

  1. 多源备份: [gitee, github] 两个源
  2. 顺序尝试: 一个失败自动尝试下一个
  3. 快速退出: 一个成功立即返回,不浪费时间
  4. 错误保留: 保存最后一个错误,便于调试

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 总结

你学到了:

  1. child_process.exec() API

    • 基本用法和参数
    • 回调函数 (error, stdout, stderr)
    • Promise 包装技巧
  2. git clone 命令

    • 语法: git clone -b <branch> <url> <dir>
    • 克隆指定分支
    • 克隆到指定目录
  3. 多源容错设计 ⭐核心

    • URL 列表: [gitee, github]
    • 顺序尝试,一个成功即返回
    • 保存最后错误,便于调试
  4. 完整下载流程

    • dowloadTemplate() → 入口
    • getRepoUrlList() → 生成 URL 列表
    • cloneRepo() → 克隆(容错)
    • removeGitFolder() → 清理 .git
    • replaceProjectName() → 修改项目名
  5. 设计思路

    • 动态下载 vs 打包内置
    • 多源容错提升成功率
    • 删除 .git 避免关联错误
    • Promise 化提升代码可读性

关键要点:

概念作用
exec()在 Node.js 中执行 shell 命令
Promise 包装将回调式 API 转为 async/await
多源容错提升下载成功率
删除 .git避免用户项目关联到模板仓库

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

下一阶段将学习 项目初始化处理,包括 JSON 文件的读写和项目名替换!