跟着Claude读Cli源码之六:体验优化

43 阅读7分钟

阶段 6 & 7: 项目初始化处理 & 用户体验优化

这是最后两个阶段的综合笔记,涵盖项目初始化和用户体验的所有细节。


📌 阶段 6: 项目初始化处理

1. 核心概念

下载模板后,需要将模板"个性化"为用户的项目:

  • 修改 package.json 的项目名
  • 规范化项目名格式
  • 为扩展预留回调机制

2. replaceProjectName - 项目名替换

文件: src/utils/setPackageName.ts

完整代码:
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

function replaceNameContent(filePath: string, projectName: string) {
  // 1. 读取 package.json
  const fileContent = JSON.parse(readFileSync(filePath, 'utf8'))

  // 2. 修改 name 字段
  fileContent.name = projectName

  // 3. 写回文件(格式化,缩进2空格)
  writeFileSync(filePath, JSON.stringify(fileContent, null, 2))
}

export function replaceProjectName(root: string, name: string) {
  // 1. 规范化项目名
  const projectName = name.toLocaleLowerCase().replace(/\s/g, '-')

  // 2. 定位 package.json
  const pkgPath = join(root, 'package.json')

  // 3. 执行替换
  replaceNameContent(pkgPath, projectName)
}

3. 项目名规范化

规则:
name.toLocaleLowerCase().replace(/\s/g, '-')

转换示例:

原始输入规范化后说明
MyAppmyapp转小写
My Appmy-app空格转连字符
My Cool Appmy--cool--app多个空格转多个连字符
my-appmy-app已规范,不变

为什么需要规范化?

npm 包名规则:

  • 必须小写
  • 不能有空格
  • 连字符是合法的

示例:

# ✅ 合法
npm install my-app

# ❌ 非法
npm install My App
npm install MyApp  # 大写也不推荐

4. JSON 文件读写流程

读取和解析:
const fileContent = JSON.parse(readFileSync(filePath, 'utf8'))

步骤:

  1. readFileSync(filePath, 'utf8') - 读取文件为字符串
  2. JSON.parse() - 解析 JSON 字符串为对象

原始 package.json:

{
  "name": "unibest",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite"
  }
}

读取后:

{
  name: "unibest",
  version: "1.0.0",
  scripts: {
    dev: "vite"
  }
}

修改和写回:
fileContent.name = projectName
writeFileSync(filePath, JSON.stringify(fileContent, null, 2))

步骤:

  1. 修改对象的 name 字段
  2. JSON.stringify(obj, null, 2) - 转为格式化的 JSON
  3. writeFileSync() - 写回文件

JSON.stringify() 参数:

JSON.stringify(obj, replacer, space)
参数作用
objfileContent要序列化的对象
replacernull不过滤字段
space2缩进2个空格

效果对比:

// 无格式化
JSON.stringify(obj)
// {"name":"my-app","version":"1.0.0"}

// 格式化(space: 2)
JSON.stringify(obj, null, 2)
// {
//   "name": "my-app",
//   "version": "1.0.0"
// }

5. 完整执行流程

调用链:
// src/utils/cloneRepo.ts:73
replaceProjectName(root, name)
      ↓
// src/utils/setPackageName.ts:10
const projectName = name.toLocaleLowerCase().replace(/\s/g, '-')
      ↓
const pkgPath = join(root, 'package.json')
      ↓
replaceNameContent(pkgPath, projectName)
      ↓
const fileContent = JSON.parse(readFileSync(pkgPath, 'utf8'))
fileContent.name = projectName
writeFileSync(pkgPath, JSON.stringify(fileContent, null, 2))
实际示例:

用户输入:

pnpm create unibest "My Cool App"

执行过程:

// 1. 规范化
"My Cool App""my-cool-app"

// 2. 读取模板的 package.json
{
  "name": "unibest",  // 模板原始名称
  "version": "1.0.0",
  ...
}

// 3. 修改 name
{
  "name": "my-cool-app",  // ← 替换为用户项目名
  "version": "1.0.0",
  ...
}

// 4. 写回文件
my-cool-app/package.json 已更新 ✅

6. 回调扩展机制

代码: src/utils/cloneRepo.ts:74

data.callBack?.(root)

可选链调用:

  • ?. 表示如果 callBack 存在才调用
  • 允许模板定义自定义的后处理逻辑

使用示例:

// 模板配置
{
  type: 'demo',
  branch: 'main',
  url: { ... },
  callBack: (root) => {
    // 自定义处理
    console.log('Demo 模板下载完成!', root)

    // 例如:创建额外的配置文件
    const configPath = join(root, '.demorc')
    writeFileSync(configPath, JSON.stringify({ mode: 'demo' }))
  }
}

当前 create-unibest 的使用:

  • 所有模板都没有定义 callBack
  • 这是为未来扩展预留的接口

📌 阶段 7: 用户体验优化

1. 核心理念

好的 CLI 工具不仅要功能强大,更要用户体验出色:

  • 🎨 视觉吸引 - 彩色输出、动画效果
  • 📝 清晰反馈 - 每步操作都有提示
  • ⏱️ 进度可见 - 加载动画消除等待焦虑
  • 💡 引导明确 - 完成后告诉用户下一步做什么

2. Ora - 加载动画类

文件: src/utils/loading.ts

完整代码分析:
import process from 'node:process'
import { green, red } from 'kolorist'
import figures from 'prompts/lib/util/figures.js'

// 动画帧:旋转的字符
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']

export class Ora {
  private message: string
  private interval: NodeJS.Timeout | null

  constructor(message: string) {
    this.message = message
    this.interval = null
  }

  // 填充空格,防止旧消息残留
  private setFinishMessage(newMessage: string): string {
    return newMessage + ' '.repeat(this.message.length - newMessage.length)
  }

  // 🔑 启动动画
  start(): Ora {
    let i = 0
    this.interval = setInterval(() => {
      // \r 回到行首,覆盖原内容
      process.stdout.write('\r' + `${frames[i % 9]} ${this.message}`)
      i++
    }, 100)  // 每 100ms 更新一次
    return this
  }

  // 🔑 失败结束
  fail(message: string): void {
    if (!this.interval) return
    clearInterval(this.interval)
    process.stdout.write('\r' + `${red(figures.cross)} ${this.setFinishMessage(message)}\n`)
  }

  // 🔑 成功结束
  succeed(message: string): void {
    if (!this.interval) return
    clearInterval(this.interval)
    process.stdout.write('\r' + `${green(figures.tick)} ${this.setFinishMessage(message)}\n`)
  }
}

// 工厂函数
export function ora(message: string) {
  return new Ora(message)
}

动画原理详解:

1. 动画帧:

const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
  • 使用 Braille 字符(盲文点字)
  • 旋转效果的 10 帧
  • Unicode 字符,跨平台兼容

2. setInterval 循环:

this.interval = setInterval(() => {
  process.stdout.write('\r' + `${frames[i % 9]} ${this.message}`)
  i++
}, 100)

关键点:

  • setInterval() - 每 100ms 执行一次
  • i % 9 - 循环使用帧(0-8,共9帧)
  • \r - 回车符,回到行首
  • process.stdout.write() - 直接写入标准输出(不换行)

3. 覆盖写入:

1 次: ⠋ 正在创建模板...
         ↑ 覆盖
第 2 次: ⠙ 正在创建模板...
         ↑ 覆盖
第 3 次: ⠹ 正在创建模板...
...

4. 清理空格:

private setFinishMessage(newMessage: string): string {
  return newMessage + ' '.repeat(this.message.length - newMessage.length)
}

为什么需要?

初始消息: "正在创建模板..." (长度 10)
完成消息: "完成!" (长度 3)

不填充:
⠋ 正在创建模板...
✔ 完成!创建模板...  ← 旧消息残留!

填充后:
⠋ 正在创建模板...
✔ 完成!          ← 空格覆盖旧消息

使用示例:
// 创建并启动
const loading = ora('正在创建模板...').start()

// 执行异步操作
await downloadTemplate(...)

// 成功结束
loading.succeed('模板创建完成!')

// 或失败结束
loading.fail('模板创建失败!')

效果:

⠋ 正在创建模板...  (动画中)
⠙ 正在创建模板...
⠹ 正在创建模板...
↓
✔ 模板创建完成!   (成功)
或
✖ 模板创建失败!   (失败)

3. printBanner - 渐变色欢迎横幅

文件: src/utils/banners.ts

完整代码分析:
import process from 'node:process'
import { lightCyan } from 'kolorist'
import { version } from '../../package.json'

export function printBanner() {
  const text = `create-unibest@v${version} 快速创建 unibest 项目`
  let colorText = ''

  // 起始颜色(绿色)
  const startColor = { r: 0x3B, g: 0xD1, b: 0x91 }
  // 结束颜色(蓝色)
  const endColor = { r: 0x2B, g: 0x4C, b: 0xEE }

  // 🔑 为每个字符计算渐变色
  for (let i = 0; i < text.length; i++) {
    // 计算当前字符的位置比例
    const ratio = i / (text.length - 1)

    // 线性插值计算颜色
    const red = Math.round(startColor.r + (endColor.r - startColor.r) * ratio)
    const green = Math.round(startColor.g + (endColor.g - startColor.g) * ratio)
    const blue = Math.round(startColor.b + (endColor.b - startColor.b) * ratio)

    // ANSI 转义序列设置 RGB 颜色
    colorText += `\x1B[38;2;${red};${green};${blue}m${text[i]}\x1B[0m`
  }

  // 🔑 终端兼容性检查
  const output = process.stdout.isTTY && process.stdout.getColorDepth() > 8
    ? colorText        // 支持 256 色
    : lightCyan(text)  // 降级为青色

  console.log()
  console.log(output)
  console.log()
}

渐变色原理:

1. 线性插值公式:

const ratio = i / (text.length - 1)  // 0.0 ~ 1.0
const red = startColor.r + (endColor.r - startColor.r) * ratio

示例计算:

文本长度: 10
起始色: RGB(59, 209, 145)  # 绿色
结束色: RGB(43, 76, 238)   # 蓝色

第 0 字符 (ratio=0.0):
  red   = 59 + (43-59) * 0.0 = 59
  green = 209 + (76-209) * 0.0 = 209
  blue  = 145 + (238-145) * 0.0 = 145
  → RGB(59, 209, 145) 绿色

第 5 字符 (ratio=0.5):
  red   = 59 + (43-59) * 0.5 = 51
  green = 209 + (76-209) * 0.5 = 142.5
  blue  = 145 + (238-145) * 0.5 = 191.5
  → RGB(51, 143, 192) 青绿色

第 9 字符 (ratio=1.0):
  red   = 59 + (43-59) * 1.0 = 43
  green = 209 + (76-209) * 1.0 = 76
  blue  = 145 + (238-145) * 1.0 = 238
  → RGB(43, 76, 238) 蓝色

2. ANSI 转义序列:

`\x1B[38;2;${red};${green};${blue}m${text[i]}\x1B[0m`

格式:

  • \x1B[38;2;r;g;bm - 设置前景色为 RGB(r, g, b)
  • text[i] - 字符
  • \x1B[0m - 重置颜色

示例:

'\x1B[38;2;59;209;145mc\x1B[0m'  // 绿色 'c'
'\x1B[38;2;43;76;238me\x1B[0m'   // 蓝色 'e'

3. 终端兼容性:

process.stdout.isTTY && process.stdout.getColorDepth() > 8

检查:

  • isTTY - 是否为终端(不是管道/文件)
  • getColorDepth() > 8 - 是否支持 256 色

降级策略:

支持 256 色 → 渐变色
不支持     → lightCyan(text) 单色青色

4. printFinish - 完成提示

文件: src/utils/printFinish.ts

完整代码分析:
import { relative } from 'node:path'
import { bold, green } from 'kolorist'
import { getCommand } from './getCommand'
import type { Ora } from './loading'

export function printFinish(
  root: string,
  cwd: string,
  packageManager: 'pnpm' | 'npm' | 'yarn',
  loading: Ora,
  type?: 'base' | 'demo' | 'i18n' | 'ucharts' | 'hbx-base' | 'hbx-demo',
) {
  // 1. 停止加载动画并显示成功
  loading.succeed('模板创建完成!')
  console.log()

  // 2. 打印 cd 命令(如果需要)
  if (root !== cwd) {
    const cdProjectName = relative(cwd, root)
    console.log(
      `  ${bold(green(`cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`))}`,
    )
  }

  // 3. 打印安装依赖命令
  console.log(`  ${bold(green(getCommand(packageManager, 'i')))}`)

  // 4. 打印运行命令(HBX 特殊提示)
  if (type && type.startsWith('hbx-'))
    console.log(`  ${bold(green('请通过 HBuilderX 打开项目并运行!'))}`)
  else
    console.log(`  ${bold(green(getCommand(packageManager, 'dev')))}`)

  console.log()
}

输出示例:

场景 1: 普通模板

pnpm create unibest my-app -t base

输出:

✔ 模板创建完成!

  cd my-app
  pnpm install
  pnpm dev

场景 2: HBuilderX 模板

pnpm create unibest my-app -t hbx-base

输出:

✔ 模板创建完成!

  cd my-app
  pnpm install
  请通过 HBuilderX 打开项目并运行!

场景 3: 当前目录创建

mkdir my-app && cd my-app
pnpm create unibest .

输出:

✔ 模板创建完成!

  pnpm install
  pnpm dev

(没有 cd 命令,因为已经在项目目录中)


路径处理细节:

1. relative() 计算相对路径:

const cdProjectName = relative(cwd, root)

示例:

cwd  = '/Users/xxx/projects'
root = '/Users/xxx/projects/my-app'
relative(cwd, root) = 'my-app'

cwd  = '/Users/xxx'
root = '/Users/xxx/projects/my-app'
relative(cwd, root) = 'projects/my-app'

2. 空格路径处理:

cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName

示例:

// 无空格
'my-app''my-app'
输出: cd my-app

// 有空格
'my cool app''"my cool app"'
输出: cd "my cool app"

5. getCommand - 包管理器命令适配

文件: src/utils/getCommand.ts

完整代码:
export function getCommand(
  packageManager: string,
  scriptName: string,
  args?: string
) {
  // 特殊处理 install 命令
  if (scriptName === 'install')
    return packageManager === 'yarn' ? 'yarn' : `${packageManager} install`

  // 带参数的命令
  if (args) {
    return packageManager === 'npm'
      ? `npm run ${scriptName} -- ${args}`
      : `${packageManager} ${scriptName} ${args}`
  }

  // 普通命令
  else {
    return packageManager === 'npm'
      ? `npm run ${scriptName}`
      : `${packageManager} ${scriptName}`
  }
}
命令对照表:
场景npmpnpmyarn
安装依赖npm installpnpm installyarn
运行脚本npm run devpnpm devyarn dev
带参数npm run build -- --mode prodpnpm build --mode prodyarn build --mode prod

为什么 npm 需要 run?

  • npm 的脚本必须用 npm run <script>
  • pnpm/yarn 支持省略 run

为什么 npm 参数需要 --?

  • npm 用 -- 分隔脚本参数
  • pnpm/yarn 直接传递参数

6. kolorist - 彩色输出库

核心 API:

import { red, green, blue, cyan, yellow, bold } from 'kolorist'

console.log(red('错误信息'))
console.log(green('成功信息'))
console.log(bold('加粗文本'))
console.log(bold(green('加粗绿色')))

create-unibest 的使用:

// 成功
loading.succeed(bold('模板创建完成!'))
console.log(bold(green(`cd ${projectName}`)))

// 失败
console.log(red(figures.cross) + ' ' + bold('操作已取消'))
console.log(red('exec error:'), error)

7. figures - 跨平台图标

来源: prompts/lib/util/figures.js

常用图标:

图标Unicode说明
figures.tick成功标记
figures.cross失败/取消标记
figures.pointer指针
figures.ellipsis省略号

create-unibest 的使用:

// 成功
`${green(figures.tick)} 模板创建完成!`
// ✔ 模板创建完成!

// 失败
`${red(figures.cross)} 操作已取消`
// ✖ 操作已取消

💡 设计思路总结

1. 用户体验优先

视觉反馈:

  • 🎨 渐变色横幅 - 吸引注意
  • ⏱️ 加载动画 - 消除等待焦虑
  • ✅ 成功/失败图标 - 清晰反馈

操作引导:

  • 📝 完成后提示下一步操作
  • 💡 针对不同模板提供不同指引
  • 🛡️ 空格路径自动加引号

2. 兼容性设计

终端兼容:

process.stdout.isTTY && process.stdout.getColorDepth() > 8
  ? colorText
  : lightCyan(text)
  • 支持彩色 → 显示渐变
  • 不支持 → 降级为单色

包管理器适配:

getCommand(packageManager, scriptName)
  • npm, pnpm, yarn 统一处理
  • 自动生成正确的命令格式

3. 细节打磨

空格填充:

' '.repeat(this.message.length - newMessage.length)
  • 防止旧消息残留
  • 视觉效果更干净

路径引号:

cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName
  • 空格路径自动加引号
  • shell 命令正确执行

✅ 阶段 6 & 7 总结

你学到了:

阶段 6: 项目初始化
  1. JSON 文件读写

    • readFileSync() + JSON.parse()
    • JSON.stringify(obj, null, 2) + writeFileSync()
  2. 项目名规范化

    • toLocaleLowerCase() - 转小写
    • replace(/\s/g, '-') - 空格转连字符
  3. 回调扩展机制

    • callBack?.(root) - 可选回调
    • 为未来扩展预留接口
阶段 7: 用户体验优化
  1. Ora 加载动画

    • setInterval() 循环更新帧
    • \r 覆盖写入
    • 空格填充防止残留
  2. 渐变色横幅

    • 线性插值计算颜色
    • ANSI 转义序列
    • 终端兼容性降级
  3. 完成提示

    • 停止动画并显示成功
    • 打印后续操作步骤
    • 针对不同模板定制提示
  4. 工具函数

    • getCommand() - 包管理器适配
    • kolorist - 彩色输出
    • figures - 跨平台图标

关键要点:

概念作用
JSON 规范化确保项目名符合 npm 规范
加载动画提升等待体验
渐变色视觉吸引力
完成提示引导用户下一步操作

🎉 恭喜!你已经完成了所有 7 个阶段! 🎊

你现在完全掌握了 create-unibest 的设计和实现!