脚手架开发流程

需求分析 --> 技术选型 --> 项目初始化 --> 命令行设计 --> 模板管理 --> 项目生成 --> 依赖安装 --> 测试验证 --> 发布部署

mermaid Diagram

技术架构

mermaid Diagram

  • 命令行解析:commander 和 inquirer 库来实现即可吧

  • 模板引擎:模板的下载和模板的渲染实现,ejs 或者说 handlerbar 来实现

  • 文件操作:目录操作和文件读写的实现

  • 依赖管理:包管理器和依赖安装实现

  • 用户交互:进度处理和错误处理

必须依赖

  • commander 进行项目中管理命令的第三方库:注册命令以及构建命令行界面

  • inquirer 实现的式交互式的问答和用户输入输出的收集

  • download-git-repo 远程拉取 git 仓库资源,以及实现下载项目开发模板实现讷

  • child_process Nodejs 子进程模块,执行子进程的一些命令,核心用来执行的是 npm install 命令吧

  • ora 控制台的loading 显示实现,提供加载动画的效果实现

  • chalk 每个控制台的输入输出,显示不同的颜色文本吧

  • semver 判断版本是否符合预期效果,进行版本号的管理实现讷

  • fs-extra 文件系统的操作,增强型的文件操作方案讷

核心模块解析

命令行解析

  • 核心使用的就是我们的 commander 工具来实现吧

import commander from "commander"

// 创建出commander对象出来
const program = commander()

// 基础的命令行工具的信息注册定义吧
program
    .name("create-lhx")
    .description("juwenzhang 的工程化工具")
    .version("1.0.0")
    .action(() => {
        console.log("hello create-lhx")
    })

// 注册命令实现
program
    .command("create <projectName>")
    .description("创建项目")
    .option('-t, --template <template>', "用于指定项目模板,可以是:react、vue")
    .option('-f, --force', "是否覆盖已存在的项目")
    .action((projectName, options) => {
        require('./commands/create')(projectName, options);
    })

// 进行命令行的解析实现
program.parse(process.argv)

交互式问答

  • 核心就是使用 inquirer 库来实现吧

import inquirer from "inquirer"

async function getUserInput() {
    const answers = await inquirer.prompt([
        {
            type: "input",
            name: "projectName",
            message: "请输入项目名称",
            validate: (input) => {
                if (!input.trim()) {
                    console.log("项目名称不能为空")
                    return false
                }
                return true
            }
        },
        {
            type: "list",
            name: "template",
            message: "请选择项目模板",
            choices: [
                { name: "react", value: "react" },
                { name: "vue", value: "vue" }
            ]
        },
        {
            type: "checkbox",
            name: "features",
            message: "请选择项目功能",
            choices: [
                { name: "react-router", value: "react-router" },
                { name: "redux", value: "redux" },
                { name: "axios", value: "axios" },
                { name: "eslint", value: "eslint" },
                { name: "prettier", value: "prettier" },
                { name: "jest", value: "jest" },
                { name: "husky", value: "husky" }
            ]
        }
    ])

    return answers
}

模板下载

  • 核心就是基于 download-git-repo 来实现吧

import download from "download-git-repo"
import ora from "ora"  // 用于打印loading动画
import chalk from "chalk"  // 用于打印彩色文本

async function downloadTemplate(template, destination) {
    const spinner = ora("正在下载模板中...").start();

    try {
        await new Promise((resolve, reject) => {
            if (template.startsWith("github:")) {
                download(template, destination, {
                    clone: true,
                    shallow: true,
                }, (err) => {
                    if (err) {
                        reject(err);
                    } else {
                        resolve();
                    }
                });
            } else if (template.startsWith("https://")) {
                download(template, destination, {
                    clone: true,
                    shallow: true,
                }, (err) => {
                    if (err) {
                        reject(err);
                    } else {
                        resolve();
                    }
                });
            }
        })
        spinner.succeed("模板下载完成");
    } catch (err) {
        spinner.fail(chalk.red("模板下载失败"));
        throw err;
    }
}

命令行设计

注册一级命令

  • 就是注册配置一下package.json 中的 bin 字段吧

{
  "name": "my-cli",
  "version": "1.0.1",
  "description": "我的前端脚手架工具",
  "main": "./bin/index.js",
  "bin": {
    "my-cli": "./bin/index.js"
  },
  "scripts": {
    "test": "jest"
  },
  "keywords": ["cli", "scaffold", "generator"],
  "author": "Your Name",
  "license": "MIT"
}

注册二级命令

#!/usr/bin/env node

const { Command } = require('commander');
const chalk = require('chalk');
const semver = require('semver');
const requiredVersion = require('../package.json').engines.node;

// 检查 Node.js 版本
function checkNodeVersion() {
  if (!semver.satisfies(process.version, requiredVersion)) {
    console.log(chalk.red(
      `您的 Node.js 版本是 ${process.version}, 但是此工具需要 ${requiredVersion}.\n` +
      '请升级您的 Node.js 版本.'
    ));
    process.exit(1);
  }
}

// 主程序
function main() {
  checkNodeVersion();
  
  const program = new Command();
  
  program
    .name('my-cli')
    .description('我的前端脚手架工具')
    .version(require('../package.json').version)
    .usage('<command> [options]');

  // 创建项目命令
  program
    .command('create <project-name>')
    .description('创建新项目')
    .option('-t, --template <template>', '指定模板', 'vue3-ts')
    .option('-f, --force', '强制覆盖现有目录')
    .option('--skip-install', '跳过依赖安装')
    .action(async (projectName, options) => {
      const createCommand = require('../commands/create');
      await createCommand(projectName, options);
    });

  // 初始化命令
  program
    .command('init')
    .description('在当前目录初始化项目')
    .option('-t, --template <template>', '指定模板', 'vue3-ts')
    .action(async (options) => {
      const initCommand = require('../commands/init');
      await initCommand(options);
    });

  // 列出模板命令
  program
    .command('list')
    .alias('ls')
    .description('列出所有可用模板')
    .action(() => {
      const listCommand = require('../commands/list');
      listCommand();
    });

  // 添加模板命令
  program
    .command('add <template>')
    .description('添加新模板')
    .option('-u, --url <url>', '模板仓库地址')
    .option('-b, --branch <branch>', '指定分支', 'main')
    .action(async (template, options) => {
      const addCommand = require('../commands/add');
      await addCommand(template, options);
    });

  // 删除模板命令
  program
    .command('remove <template>')
    .alias('rm')
    .description('删除模板')
    .action(async (template) => {
      const removeCommand = require('../commands/remove');
      await removeCommand(template);
    });

  // 帮助信息
  program.on('--help', () => {
    console.log();
    console.log('Examples:');
    console.log('  $ my-cli create my-project');
    console.log('  $ my-cli create my-project --template vue3-ts');
    console.log('  $ my-cli init --template react-ts');
    console.log('  $ my-cli list');
    console.log('  $ my-cli add my-template --url https://github.com/user/repo.git');
  });

  // 解析命令行参数
  program.parse(process.argv);

  // 如果没有参数,显示帮助
  if (!process.argv.slice(2).length) {
    program.outputHelp();
  }
}

// 错误处理
process.on('unhandledRejection', (err) => {
  console.error(chalk.red('未处理的Promise拒绝:'));
  console.error(err);
  process.exit(1);
});

process.on('uncaughtException', (err) => {
  console.error(chalk.red('未捕获的异常:'));
  console.error(err);
  process.exit(1);
});

main();

模板管理

模板配置

// config/templates.js
module.exports = {
  'vue3-ts': {
    name: 'Vue 3 + TypeScript',
    description: 'Vue 3 + TypeScript + Vite 模板',
    url: 'github:my-templates/vue3-typescript-template',
    branch: 'main',
    offline: false
  },
  'react-ts': {
    name: 'React + TypeScript',
    description: 'React + TypeScript + Vite 模板',
    url: 'github:my-templates/react-typescript-template',
    branch: 'main',
    offline: false
  },
  'node-api': {
    name: 'Node.js API',
    description: 'Node.js + Express + TypeScript API 模板',
    url: 'github:my-templates/node-api-template',
    branch: 'main',
    offline: false
  }
};

模板列表处理

const chalk = require('chalk');
const templates = require('../config/templates');

function listTemplates() {
  console.log(chalk.bold.blue('\n📋 可用模板列表:\n'));
  
  Object.keys(templates).forEach(key => {
    const template = templates[key];
    console.log(`  ${chalk.bold.cyan(key)}`);
    console.log(`    ${chalk.gray('名称:')} ${template.name}`);
    console.log(`    ${chalk.gray('描述:')} ${template.description}`);
    console.log(`    ${chalk.gray('地址:')} ${template.url}`);
    console.log(`    ${chalk.gray('分支:')} ${template.branch}`);
    console.log('');
  });
}

module.exports = listTemplates;

项目生成

核心创建命令

// commands/create.js
const path = require('path');
const fs = require('fs-extra');
const chalk = require('chalk');
const ora = require('ora');
const inquirer = require('inquirer');
const download = require('download-git-repo');
const { exec } = require('child_process');
const { promisify } = require('util');

const execAsync = promisify(exec);
const templates = require('../config/templates');

async function createProject(projectName, options) {
  const targetDir = path.resolve(process.cwd(), projectName);
  
  // 检查目录是否存在
  if (fs.existsSync(targetDir)) {
    if (options.force) {
      await fs.remove(targetDir);
    } else {
      const { overwrite } = await inquirer.prompt([
        {
          type: 'confirm',
          name: 'overwrite',
          message: `目录 ${projectName} 已存在,是否覆盖?`,
          default: false
        }
      ]);
      
      if (!overwrite) {
        console.log(chalk.yellow('取消创建'));
        return;
      }
      
      await fs.remove(targetDir);
    }
  }
  
  // 获取模板配置
  let template = options.template;
  if (!templates[template]) {
    const { selectedTemplate } = await inquirer.prompt([
      {
        type: 'list',
        name: 'selectedTemplate',
        message: '请选择项目模板:',
        choices: Object.keys(templates).map(key => ({
          name: `${templates[key].name} - ${templates[key].description}`,
          value: key
        }))
      }
    ]);
    template = selectedTemplate;
  }
  
  const templateConfig = templates[template];
  
  // 收集项目信息
  const projectInfo = await collectProjectInfo(projectName, templateConfig);
  
  // 下载模板
  await downloadTemplate(templateConfig, targetDir);
  
  // 渲染模板
  await renderTemplate(targetDir, projectInfo);
  
  // 安装依赖
  if (!options.skipInstall) {
    await installDependencies(targetDir);
  }
  
  // 初始化 Git
  await initGit(targetDir);
  
  console.log(chalk.green(`\n🎉 项目 ${projectName} 创建成功!\n`));
  console.log(chalk.cyan('下一步:'));
  console.log(chalk.cyan(`  cd ${projectName}`));
  if (options.skipInstall) {
    console.log(chalk.cyan(`  npm install`));
  }
  console.log(chalk.cyan(`  npm run dev`));
}

async function collectProjectInfo(projectName, templateConfig) {
  const questions = [
    {
      type: 'input',
      name: 'author',
      message: '作者:',
      default: 'Your Name'
    },
    {
      type: 'input',
      name: 'description',
      message: '项目描述:',
      default: `${templateConfig.name} project`
    },
    {
      type: 'input',
      name: 'version',
      message: '版本号:',
      default: '1.0.0'
    }
  ];
  
  // 根据模板动态添加问题
  if (templateConfig.name.includes('Vue')) {
    questions.push({
      type: 'confirm',
      name: 'useRouter',
      message: '是否使用 Vue Router?',
      default: true
    });
    
    questions.push({
      type: 'confirm',
      name: 'usePinia',
      message: '是否使用 Pinia 状态管理?',
      default: true
    });
  }
  
  const answers = await inquirer.prompt(questions);
  
  return {
    name: projectName,
    ...answers
  };
}

async function downloadTemplate(templateConfig, destination) {
  const spinner = ora('正在下载模板...').start();
  
  try {
    await new Promise((resolve, reject) => {
      const repo = templateConfig.url.replace('github:', '');
      const url = `direct:https://github.com/${repo}.git#${templateConfig.branch}`;
      
      download(url, destination, { clone: true }, (err) => {
      if (err) {
          reject(err);
        } else {
          resolve();
        }
      });
    });
    
    spinner.succeed(chalk.green('模板下载成功'));
  } catch (error) {
    spinner.fail(chalk.red('模板下载失败'));
    throw error;
  }
}

async function renderTemplate(targetDir, projectInfo) {
  const spinner = ora('正在渲染模板...').start();
  
  try {
    // 读取并更新 package.json
    const packageJsonPath = path.join(targetDir, 'package.json');
    if (fs.existsSync(packageJsonPath)) {
      const packageJson = await fs.readJson(packageJsonPath);
      
      packageJson.name = projectInfo.name;
      packageJson.version = projectInfo.version;
      packageJson.description = projectInfo.description;
      packageJson.author = projectInfo.author;
      
      await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
    }
    
    // 处理模板变量
    const filesToProcess = [
      'README.md',
      'src/main.ts',
      'src/App.vue',
      'index.html'
    ];
    
    for (const file of filesToProcess) {
      const filePath = path.join(targetDir, file);
      if (fs.existsSync(filePath)) {
        let content = await fs.readFile(filePath, 'utf-8');
        
        // 替换模板变量
        content = content.replace(/\{\{name\}\}/g, projectInfo.name);
        content = content.replace(/\{\{description\}\}/g, projectInfo.description);
        content = content.replace(/\{\{author\}\}/g, projectInfo.author);
        
        await fs.writeFile(filePath, content);
      }
    }
    
    // 删除 .git 目录
    const gitDir = path.join(targetDir, '.git');
    if (fs.existsSync(gitDir)) {
      await fs.remove(gitDir);
    }
    
    spinner.succeed(chalk.green('模板渲染成功'));
  } catch (error) {
    spinner.fail(chalk.red('模板渲染失败'));
    throw error;
  }
}

async function installDependencies(targetDir) {
  const spinner = ora('正在安装依赖...').start();
  
  try {
    const { stdout } = await execAsync('npm install', {
      cwd: targetDir,
      stdio: 'pipe'
    });
    
    spinner.succeed(chalk.green('依赖安装成功'));
  } catch (error) {
    spinner.fail(chalk.red('依赖安装失败'));
    console.log(chalk.yellow('您可以手动运行 npm install 来安装依赖'));
  }
}

async function initGit(targetDir) {
  const spinner = ora('正在初始化 Git...').start();
  
  try {
    await execAsync('git init', { cwd: targetDir });
    await execAsync('git add .', { cwd: targetDir });
    await execAsync('git commit -m "feat: initial commit"', { cwd: targetDir });
    
    spinner.succeed(chalk.green('Git 初始化成功'));
  } catch (error) {
    spinner.fail(chalk.red('Git 初始化失败'));
    console.log(chalk.yellow('您可以手动初始化 Git 仓库'));
  }
}

module.exports = createProject;

插件系统设计

// lib/PluginManager.js
class PluginManager {
  constructor() {
    this.plugins = [];
  }
  
  addPlugin(plugin) {
    this.plugins.push(plugin);
  }
  
  async runHook(hookName, context) {
    for (const plugin of this.plugins) {
      if (plugin[hookName]) {
        await plugin[hookName](context);
      }
    }
  }
}

// 插件示例
class ESLintPlugin {
  async beforeCreate(context) {
    console.log('准备配置 ESLint...');
  }
  
  async afterCreate(context) {
    // 添加 ESLint 配置
    const eslintConfig = {
      extends: ['@vue/typescript/recommended'],
      rules: {
        'no-console': 'warn'
      }
    };
    
    await fs.writeJson(
      path.join(context.targetDir, '.eslintrc.json'),
      eslintConfig,
      { spaces: 2 }
    );
  }
}

module.exports = { PluginManager, ESLintPlugin };

进度跟踪

// lib/ProgressTracker.js
const chalk = require('chalk');

class ProgressTracker {
  constructor(steps) {
    this.steps = steps;
    this.current = 0;
  }
  
  start() {
    console.log(chalk.blue(`\n🚀 开始执行 ${this.steps.length} 个步骤...\n`));
  }
  
  nextStep(message) {
    this.current++;
    console.log(chalk.cyan(`[${this.current}/${this.steps.length}] ${message}`));
  }
  
  complete() {
    console.log(chalk.green(`\n✅ 所有步骤完成!`));
  }
  
  error(message) {
    console.log(chalk.red(`\n❌ 错误: ${message}`));
  }
}

module.exports = ProgressTracker;

模板缓存

// lib/TemplateCache.js
const path = require('path');
const fs = require('fs-extra');
const os = require('os');

class TemplateCache {
  constructor() {
    this.cacheDir = path.join(os.homedir(), '.my-cli', 'cache');
    this.ensureCacheDir();
  }
  
  ensureCacheDir() {
    if (!fs.existsSync(this.cacheDir)) {
      fs.mkdirpSync(this.cacheDir);
    }
  }
  
  getCachePath(template) {
    return path.join(this.cacheDir, template);
  }
  
  exists(template) {
    return fs.existsSync(this.getCachePath(template));
  }
  
  async get(template) {
    const cachePath = this.getCachePath(template);
    if (this.exists(template)) {
      return cachePath;
    }
    return null;
  }
  
  async set(template, sourcePath) {
    const cachePath = this.getCachePath(template);
    await fs.copy(sourcePath, cachePath);
    return cachePath;
  }
  
  async clear(template) {
    const cachePath = this.getCachePath(template);
    if (this.exists(template)) {
      await fs.remove(cachePath);
    }
  }
  
  async clearAll() {
    await fs.remove(this.cacheDir);
    this.ensureCacheDir();
  }
}

module.exports = TemplateCache;

最佳实践

my-cli/
├── bin/
│   └── index.js          # 入口文件
├── commands/
│   ├── create.js         # 创建命令
│   ├── init.js           # 初始化命令
│   ├── list.js           # 列表命令
│   └── add.js            # 添加模板命令
├── config/
│   ├── templates.js      # 模板配置
│   └── index.js          # 全局配置
├── lib/
│   ├── PluginManager.js  # 插件管理
│   ├── TemplateCache.js  # 模板缓存
│   └── utils.js          # 工具函数
├── templates/
│   ├── vue3-ts/          # Vue 3 TypeScript 模板
│   ├── react-ts/         # React TypeScript 模板
│   └── node-api/         # Node.js API 模板
├── tests/
│   └── *.test.js         # 测试文件
├── package.json
└── README.md