阶段 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, '-')
转换示例:
| 原始输入 | 规范化后 | 说明 |
|---|---|---|
MyApp | myapp | 转小写 |
My App | my-app | 空格转连字符 |
My Cool App | my--cool--app | 多个空格转多个连字符 |
my-app | my-app | 已规范,不变 |
为什么需要规范化?
npm 包名规则:
- 必须小写
- 不能有空格
- 连字符是合法的
示例:
# ✅ 合法
npm install my-app
# ❌ 非法
npm install My App
npm install MyApp # 大写也不推荐
4. JSON 文件读写流程
读取和解析:
const fileContent = JSON.parse(readFileSync(filePath, 'utf8'))
步骤:
readFileSync(filePath, 'utf8')- 读取文件为字符串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))
步骤:
- 修改对象的
name字段 JSON.stringify(obj, null, 2)- 转为格式化的 JSONwriteFileSync()- 写回文件
JSON.stringify() 参数:
JSON.stringify(obj, replacer, space)
| 参数 | 值 | 作用 |
|---|---|---|
obj | fileContent | 要序列化的对象 |
replacer | null | 不过滤字段 |
space | 2 | 缩进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 - 加载动画类
完整代码分析:
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 - 渐变色欢迎横幅
完整代码分析:
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 - 完成提示
完整代码分析:
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 - 包管理器命令适配
完整代码:
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}`
}
}
命令对照表:
| 场景 | npm | pnpm | yarn |
|---|---|---|---|
| 安装依赖 | npm install | pnpm install | yarn |
| 运行脚本 | npm run dev | pnpm dev | yarn dev |
| 带参数 | npm run build -- --mode prod | pnpm build --mode prod | yarn 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: 项目初始化
-
✅ JSON 文件读写
readFileSync()+JSON.parse()JSON.stringify(obj, null, 2)+writeFileSync()
-
✅ 项目名规范化
toLocaleLowerCase()- 转小写replace(/\s/g, '-')- 空格转连字符
-
✅ 回调扩展机制
callBack?.(root)- 可选回调- 为未来扩展预留接口
阶段 7: 用户体验优化
-
✅ Ora 加载动画
setInterval()循环更新帧\r覆盖写入- 空格填充防止残留
-
✅ 渐变色横幅
- 线性插值计算颜色
- ANSI 转义序列
- 终端兼容性降级
-
✅ 完成提示
- 停止动画并显示成功
- 打印后续操作步骤
- 针对不同模板定制提示
-
✅ 工具函数
getCommand()- 包管理器适配kolorist- 彩色输出figures- 跨平台图标
关键要点:
| 概念 | 作用 |
|---|---|
| JSON 规范化 | 确保项目名符合 npm 规范 |
| 加载动画 | 提升等待体验 |
| 渐变色 | 视觉吸引力 |
| 完成提示 | 引导用户下一步操作 |
🎉 恭喜!你已经完成了所有 7 个阶段! 🎊
你现在完全掌握了 create-unibest 的设计和实现!