从零开始构建脚手架(Build Scaffold from Scratch)

从创建一个空目录开始,逐步搭建出完整的前端脚手架系统,包括 CLI 命令处理、用户交互、文件生成等核心功能。


📋 目录


1. 项目初始化

1.1 创建项目目录

mkdir my-cli
cd my-cli
npm init -y

1.2 安装依赖

# 核心依赖
npm install commander inquirer chalk fs-extra ejs
 
# 开发依赖
npm install -D typescript @types/node @types/inquirer ts-node nodemon

1.3 目录结构

my-cli/
├── package.json
├── tsconfig.json
├── bin/
│   └── cli.js          # CLI 入口文件
├── src/
│   ├── index.ts        # 主入口
│   ├── commands/       # 命令处理
│   │   └── create.ts
│   ├── utils/          # 工具函数
│   │   ├── logger.ts
│   │   └── file.ts
│   └── templates/      # 模板文件
│       └── basic/
└── dist/               # 编译输出

1.4 package.json 配置

{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "前端脚手架工具",
  "main": "dist/index.js",
  "bin": {
    "my-cli": "./bin/cli.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "ts-node src/index.ts",
    "start": "node dist/index.js"
  },
  "keywords": ["cli", "scaffold", "frontend"],
  "author": "",
  "license": "MIT"
}

1.5 TypeScript 配置

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

2. CLI 命令处理

2.1 创建 CLI 入口文件

#!/usr/bin/env node
 
// bin/cli.js
require('../dist/index.js');

2.2 主入口文件

// src/index.ts
import { Command } from 'commander';
import { createCommand } from './commands/create';
 
const program = new Command();
 
program
  .name('my-cli')
  .description('前端脚手架工具')
  .version('1.0.0');
 
// 注册 create 命令
program
  .command('create')
  .description('创建新项目')
  .argument('<project-name>', '项目名称')
  .option('-t, --template <template>', '选择模板', 'basic-js')
  .option('--no-install', '不安装依赖')
  .option('--package-manager <manager>', '包管理器', 'npm')
  .action(async (projectName, options) => {
    await createCommand(projectName, options);
  });
 
program.parse();

2.3 Create 命令实现

// src/commands/create.ts
import inquirer from 'inquirer';
import { createProject } from '../utils/file';
import { installDependencies } from '../utils/package';
import { logger } from '../utils/logger';
 
export async function createCommand(
  projectName: string,
  options: {
    template?: string;
    install?: boolean;
    packageManager?: string;
  }
) {
  try {
    // 1. 收集用户输入
    const answers = await collectUserInput(projectName, options);
 
    // 2. 创建项目
    logger.info(`正在创建项目: ${projectName}`);
    await createProject(projectName, answers);
 
    // 3. 安装依赖
    if (answers.installDeps) {
      logger.info('正在安装依赖...');
      await installDependencies(projectName, answers.packageManager);
    }
 
    logger.success(`项目 ${projectName} 创建成功!`);
  } catch (error) {
    logger.error(`创建项目失败: ${error.message}`);
    process.exit(1);
  }
}
 
async function collectUserInput(
  projectName: string,
  options: any
): Promise<any> {
  // 如果已经通过命令行参数指定,直接使用
  if (options.template && options.packageManager) {
    return {
      projectName,
      template: options.template,
      installDeps: options.install !== false,
      packageManager: options.packageManager,
      initGit: false
    };
  }
 
  // 否则进行交互式询问
  const answers = await inquirer.prompt([
    {
      type: 'list',
      name: 'template',
      message: '选择项目模板:',
      choices: [
        { name: 'Basic JavaScript', value: 'basic-js' },
        { name: 'Basic TypeScript', value: 'basic-ts' },
        { name: 'React', value: 'react' },
        { name: 'React + TypeScript', value: 'react-ts' },
        { name: 'Vue', value: 'vue' },
        { name: 'Vue + TypeScript', value: 'vue-ts' }
      ],
      default: options.template || 'basic-js'
    },
    {
      type: 'list',
      name: 'packageManager',
      message: '选择包管理器:',
      choices: ['npm', 'yarn', 'pnpm'],
      default: options.packageManager || 'npm'
    },
    {
      type: 'confirm',
      name: 'installDeps',
      message: '是否立即安装依赖?',
      default: options.install !== false
    },
    {
      type: 'confirm',
      name: 'initGit',
      message: '是否初始化 Git 仓库?',
      default: true
    }
  ]);
 
  return {
    projectName,
    ...answers
  };
}

3. 用户交互实现

3.1 使用 Inquirer

import inquirer from 'inquirer';
 
// 文本输入
const { name } = await inquirer.prompt([
  {
    type: 'input',
    name: 'name',
    message: '请输入项目名称:',
    validate: (input) => {
      if (!input.trim()) {
        return '项目名称不能为空';
      }
      return true;
    }
  }
]);
 
// 选择列表
const { template } = await inquirer.prompt([
  {
    type: 'list',
    name: 'template',
    message: '选择模板:',
    choices: ['react', 'vue', 'angular']
  }
]);
 
// 确认
const { confirm } = await inquirer.prompt([
  {
    type: 'confirm',
    name: 'confirm',
    message: '是否继续?',
    default: true
  }
]);
 
// 多选
const { features } = await inquirer.prompt([
  {
    type: 'checkbox',
    name: 'features',
    message: '选择功能:',
    choices: [
      { name: 'ESLint', value: 'eslint' },
      { name: 'Prettier', value: 'prettier' },
      { name: 'Husky', value: 'husky' }
    ]
  }
]);

3.2 自定义提示样式

import chalk from 'chalk';
 
// 使用 chalk 美化输出
console.log(chalk.blue('正在创建项目...'));
console.log(chalk.green('✓ 项目创建成功'));
console.log(chalk.red('✗ 创建失败'));
console.log(chalk.yellow('⚠ 警告信息'));

4. 文件生成与目录创建

4.1 文件工具函数

// src/utils/file.ts
import fs from 'fs-extra';
import path from 'path';
import ejs from 'ejs';
 
export async function createProject(
  projectName: string,
  options: {
    template: string;
    [key: string]: any;
  }
) {
  const targetDir = path.resolve(process.cwd(), projectName);
  const templateDir = path.resolve(__dirname, '../templates', options.template);
 
  // 检查目录是否存在
  if (await fs.pathExists(targetDir)) {
    throw new Error(`目录 ${projectName} 已存在`);
  }
 
  // 检查模板是否存在
  if (!(await fs.pathExists(templateDir))) {
    throw new Error(`模板 ${options.template} 不存在`);
  }
 
  // 创建目标目录
  await fs.ensureDir(targetDir);
 
  // 复制并处理模板文件
  await copyTemplate(templateDir, targetDir, {
    projectName,
    ...options
  });
}
 
async function copyTemplate(
  srcDir: string,
  targetDir: string,
  data: any
) {
  const files = await fs.readdir(srcDir);
 
  for (const file of files) {
    const srcPath = path.join(srcDir, file);
    const targetPath = path.join(targetDir, file);
    const stat = await fs.stat(srcPath);
 
    if (stat.isDirectory()) {
      await fs.ensureDir(targetPath);
      await copyTemplate(srcPath, targetPath, data);
    } else {
      // 处理模板文件
      if (file.endsWith('.ejs')) {
        const template = await fs.readFile(srcPath, 'utf-8');
        const content = ejs.render(template, data);
        const targetFile = targetPath.replace(/\.ejs$/, '');
        await fs.writeFile(targetFile, content);
      } else {
        await fs.copy(srcPath, targetPath);
      }
    }
  }
}

4.2 模板文件示例

// templates/basic-js/package.json.ejs
{
  "name": "<%= projectName %>",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "keywords": [],
  "author": "<%= author || '' %>",
  "license": "MIT"
}
// templates/basic-js/index.js
console.log('Hello, <%= projectName %>!');

5. 包管理器兼容

5.1 包管理器工具函数

// src/utils/package.ts
import { execSync } from 'child_process';
import path from 'path';
import { logger } from './logger';
 
export async function installDependencies(
  projectName: string,
  packageManager: 'npm' | 'yarn' | 'pnpm'
) {
  const projectPath = path.resolve(process.cwd(), projectName);
 
  try {
    logger.info(`使用 ${packageManager} 安装依赖...`);
 
    const commands = {
      npm: 'npm install',
      yarn: 'yarn install',
      pnpm: 'pnpm install'
    };
 
    execSync(commands[packageManager], {
      cwd: projectPath,
      stdio: 'inherit'
    });
 
    logger.success('依赖安装完成');
  } catch (error) {
    logger.error(`依赖安装失败: ${error.message}`);
    throw error;
  }
}
 
export async function initGit(projectPath: string) {
  try {
    logger.info('初始化 Git 仓库...');
    execSync('git init', { cwd: projectPath, stdio: 'inherit' });
    logger.success('Git 仓库初始化完成');
  } catch (error) {
    logger.warn(`Git 初始化失败: ${error.message}`);
  }
}

6. 错误处理和日志系统

6.1 日志工具

// src/utils/logger.ts
import chalk from 'chalk';
 
export const logger = {
  info: (message: string) => {
    console.log(chalk.blue('ℹ'), message);
  },
  success: (message: string) => {
    console.log(chalk.green('✓'), message);
  },
  error: (message: string) => {
    console.log(chalk.red('✗'), message);
  },
  warn: (message: string) => {
    console.log(chalk.yellow('⚠'), message);
  },
  step: (message: string) => {
    console.log(chalk.cyan('→'), message);
  }
};

6.2 错误处理

// src/utils/error.ts
import { logger } from './logger';
 
export class ScaffoldError extends Error {
  constructor(message: string, public code?: string) {
    super(message);
    this.name = 'ScaffoldError';
  }
}
 
export function handleError(error: unknown) {
  if (error instanceof ScaffoldError) {
    logger.error(error.message);
    process.exit(1);
  } else if (error instanceof Error) {
    logger.error(error.message);
    process.exit(1);
  } else {
    logger.error('未知错误');
    process.exit(1);
  }
}

7. 完整代码示例

7.1 项目结构

my-cli/
├── package.json
├── tsconfig.json
├── bin/
│   └── cli.js
├── src/
│   ├── index.ts
│   ├── commands/
│   │   └── create.ts
│   ├── utils/
│   │   ├── logger.ts
│   │   ├── file.ts
│   │   └── package.ts
│   └── templates/
│       └── basic-js/
│           ├── package.json.ejs
│           └── index.js
└── dist/

7.2 使用示例

# 开发模式运行
npm run dev create my-app
 
# 构建
npm run build
 
# 链接到全局(开发测试)
npm link
 
# 使用
my-cli create my-app
my-cli create my-app --template react-ts
my-cli create my-app --template vue --package-manager pnpm

7.3 运行效果

$ my-cli create my-app
 
? 选择项目模板: (Use arrow keys)
 Basic JavaScript
    Basic TypeScript
    React
    React + TypeScript
    Vue
    Vue + TypeScript
 
? 选择包管理器: (Use arrow keys)
 npm
    yarn
    pnpm
 
? 是否立即安装依赖? (Y/n) Y
? 是否初始化 Git 仓库? (Y/n) Y
 
 正在创建项目: my-app
 项目创建成功
 正在安装依赖...
 依赖安装完成
 Git 仓库初始化完成
 项目 my-app 创建成功!

📝 总结

核心功能

  1. ✅ CLI 命令处理(Commander)
  2. ✅ 用户交互(Inquirer)
  3. ✅ 文件生成(fs-extra + EJS)
  4. ✅ 包管理器兼容(npm/yarn/pnpm)
  5. ✅ 错误处理和日志(Chalk)

下一步


脚手架 前端工程化 CLI工具 Node.js TypeScript