代码语言

知识点思维导图

16 个知识节点

工程化脚手架(05) - Create 命令实现

读完后,你应能完成以下任务:

  • 绘制“工程化脚手架(05) - Create 命令实现 / 概述”的关键对象与数据流,解释“create 命令是 Imber CLI 的核心功能之一,负责创建新项目。”,并用源码位置、日志或 Trace 标注证据。
  • 为“工程化脚手架(05) - Create 命令实现 / 实现详解”设计正常与异常输入,验证“输入验证:确保项目名称符合 npm 包命名规范”,输出首个偏差位置与回归测试结果。
  • 实现“工程化脚手架(05) - Create 命令实现 / 模板包管理”的最小代码或配置,检验“本地缓存:模板下载到用户主目录,避免重复下载”,输出命令、结果与 Diff,并说明不适用边界。

一、概述

create 命令是 Imber CLI 的核心功能之一,负责创建新项目。它通过交互式界面引导用户选择项目模板,然后下载、渲染并创建项目结构。本文将深入解析 create 命令的实现原理和最佳实践。

二、核心架构

2.1 命令入口

// packages/cli/src/index.ts
import create from '@imber-cli/create'

program
  .command('create')
  .description('创建项目')
  .action(async () => {
    create()
  })

2.2 主要流程

graph TD
    A[用户执行 imber-cli create] --> B[选择项目模板]
    B --> C[输入项目名称]
    C --> D[检查模板是否存在]
    D --> E{模板存在?}
    E -->|否| F[下载模板]
    E -->|是| G[更新模板]
    F --> H[复制模板到目标目录]
    G --> H
    H --> I[渲染模板文件]
    I --> J[创建项目完成]

三、实现详解

3.1 交互式界面设计

使用 @inquirer/prompts 实现用户友好的交互界面:

import { select, input } from '@inquirer/prompts'

async function create() {
  // 选择项目模板
  const projectTemplate = await select({
    message: '请选择项目模板',
    choices: [
      {
        name: 'React 项目 (TypeScript + Vite)',
        value: '@imber-cli/template-react',
        description: '现代化的 React 开发环境'
      },
      {
        name: 'Vue 项目 (TypeScript + Vite)',
        value: '@imber-cli/template-vue',
        description: '现代化的 Vue 开发环境'
      }
    ]
  })

  // 输入项目名称
  let projectName = ''
  while (!projectName) {
    projectName = await input({
      message: '请输入项目名称',
      validate: (input) => {
        if (!input.trim()) {
          return '项目名称不能为空'
        }
        if (!/^[a-zA-Z][a-zA-Z0-9-_]*$/.test(input)) {
          return '项目名称只能包含字母、数字、连字符和下划线,且必须以字母开头'
        }
        return true
      }
    })
  }
}

设计亮点:

  • 清晰的选项描述:帮助用户理解每个模板的特点
  • 输入验证:确保项目名称符合 npm 包命名规范
  • 循环验证:直到用户输入有效值才继续

3.2 模板包管理

通过 NpmPackage 类管理模板的下载、更新和缓存:

import NpmPackage from '@imber-cli/utils'

// 创建包实例
const pkg = new NpmPackage({
  name: projectTemplate,
  targetPath: path.join(os.homedir(), '.imber-cli-template')
})

// 检查模板是否存在
if (!(await pkg.exists())) {
  const spinner = ora('下载模板中...').start()
  try {
    await pkg.install()
    spinner.succeed('下载模板成功')
  } catch (error) {
    spinner.fail('下载模板失败')
    console.error(error)
    process.exit(1)
  }
} else {
  const spinner = ora('更新模板中...').start()
  try {
    await pkg.update()
    spinner.succeed('更新模板成功')
  } catch (error) {
    spinner.fail('更新模板失败')
    console.error(error)
  }
}

关键特性:

  • 本地缓存:模板下载到用户主目录,避免重复下载
  • 自动更新:检查并更新到最新版本
  • 错误处理:完善的错误提示和异常处理
  • 进度反馈:使用 ora 提供友好的加载动画

3.3 模板渲染机制

使用 EJS 模板引擎进行动态内容渲染:

import ejs from 'ejs'
import glob from 'glob'

// 复制模板到目标目录
const targetPath = path.join(process.cwd(), projectName)
fse.copySync(templatePath, targetPath)

// 准备渲染数据
const renderData: Record<string, any> = {
  projectName,
  projectNameKebab: projectName
    .toLowerCase()
    .replace(/([A-Z])/g, '-$1')
    .replace(/^-/, ''),
  projectNamePascal: projectName.charAt(0).toUpperCase() + projectName.slice(1),
  author: process.env.USER || 'Developer',
  year: new Date().getFullYear()
}

// 获取所有需要渲染的文件
const files = await glob('**', {
  cwd: targetPath,
  nodir: true,
  ignore: ['node_modules/**', '.git/**']
})

// 渲染每个文件
for (const file of files) {
  const filePath = path.join(targetPath, file)

  try {
    // 读取文件内容
    const content = fse.readFileSync(filePath, 'utf-8')

    // 渲染模板
    const rendered = await ejs.render(content, renderData, {
      async: true
    })

    // 写回文件
    fse.writeFileSync(filePath, rendered)
  } catch (error) {
    console.warn(`渲染文件 ${file} 失败:`, error.message)
  }
}

渲染数据包括:

  • projectName: 原始项目名称
  • projectNameKebab: kebab-case 格式(用于文件名)
  • projectNamePascal: PascalCase 格式(用于组件名)
  • author: 当前用户
  • year: 当前年份

3.4 条件性文件生成

支持根据用户选择生成不同的文件结构:

// 在模板中使用条件渲染
<% if (features.includes('typescript')) { %>
// 生成 TypeScript 配置文件
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  }
}
<% } %>

四、模板设计最佳实践

4.1 目录结构设计

template/
├── src/
│   ├── components/
│   ├── pages/
│   ├── utils/
│   └── styles/
├── public/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── .gitignore
└── README.md

4.2 配置文件模板

package.json 模板:

{
  "name": "<%= projectNameKebab %>",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.43",
    "@types/react-dom": "^18.2.17",
    "@typescript-eslint/eslint-plugin": "^6.14.0",
    "@typescript-eslint/parser": "^6.14.0",
    "@vitejs/plugin-react": "^4.2.1",
    "eslint": "^8.55.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.5",
    "typescript": "^5.2.2",
    "vite": "^5.0.8"
  }
}

4.3 代码模板示例

React 组件模板:

// src/components/<%= projectNamePascal %>.tsx
import React from 'react'

interface <%= projectNamePascal %>Props {
  title?: string
  children?: React.ReactNode
}

const <%= projectNamePascal %>: React.FC<<%= projectNamePascal %>Props> = ({
  title = '<%= projectName %>',
  children
}) => {
  return (
    <div className="<%= projectNameKebab %>">
      <h1>{title}</h1>
      {children}
    </div>
  )
}

export default <%= projectNamePascal %>

五、错误处理与用户体验

5.1 完善的错误处理

async function create() {
  try {
    // 主要逻辑
  } catch (error) {
    console.error('创建项目失败:', error.message)

    // 清理已创建的文件
    if (fse.existsSync(targetPath)) {
      fse.removeSync(targetPath)
    }

    process.exit(1)
  }
}

5.2 用户友好的提示

// 成功创建后的提示
console.log(`
🎉 项目创建成功!

📁 项目路径: ${targetPath}
🚀 开始开发:
   cd ${projectName}
   npm install
   npm run dev

📚 更多信息请查看 README.md
`)

5.3 进度反馈

const steps = [
  { name: '选择模板', status: 'pending' },
  { name: '下载模板', status: 'pending' },
  { name: '复制文件', status: 'pending' },
  { name: '渲染模板', status: 'pending' },
  { name: '创建完成', status: 'pending' }
]

// 更新步骤状态
const updateStep = (index: number, status: 'pending' | 'running' | 'completed' | 'failed') => {
  steps[index].status = status
  // 显示进度
}

六、扩展性设计

6.1 插件化架构

interface CreatePlugin {
  name: string
  beforeCreate?: (context: CreateContext) => Promise<void>
  afterCreate?: (context: CreateContext) => Promise<void>
}

interface CreateContext {
  projectName: string
  templatePath: string
  targetPath: string
  renderData: Record<string, any>
}

6.2 自定义模板支持

// 支持本地模板
const localTemplate = await input({
  message: '是否使用本地模板?',
  type: 'confirm'
})

if (localTemplate) {
  const templatePath = await input({
    message: '请输入本地模板路径'
  })
  // 使用本地模板
}

七、性能优化

7.1 并行处理

// 并行下载多个依赖
const downloadPromises = dependencies.map((dep) => pkg.install(dep))

await Promise.all(downloadPromises)

7.2 缓存策略

// 检查模板版本
const cachedVersion = await getCachedVersion(templateName)
const latestVersion = await getLatestVersion(templateName)

if (cachedVersion === latestVersion) {
  console.log('使用缓存模板')
  return
}

八、总结

  • 概述:create 命令是 Imber CLI 的核心功能之一,负责创建新项目。
  • 实现详解:输入验证:确保项目名称符合 npm 包命名规范
  • 模板包管理:本地缓存:模板下载到用户主目录,避免重复下载
  • 模板渲染机制:projectNameKebab: kebab-case 格式(用于文件名)

学完自测

选择所有正确答案;提交后逐项核对判断依据。

1在“Create 命令实现”中,需要同时满足“概述”与“交互式界面设计”。给定正文约束“create 命令是 Imber CLI 的核心功能之一,负责创建新项目。”,哪些判断保持了原有处理机制?多选
2“Create 命令实现”出现偏差:“在“Create 命令实现 / 模板包管理”中,即使不满足“通过 NpmPackage 类管理模板的下载、更新和缓存”,结果与副作用仍会保持不变。”已成为实际行为。围绕“模板包管理”与“模板渲染机制”,哪些判断能定位被改变的职责或边界?多选
3评审“Create 命令实现”方案时,验收条件包含“支持根据用户选择生成不同的文件结构。”。关于“条件性文件生成”与“配置文件模板”的哪些决策符合正文机制?多选