从零构建一个现代前端脚手架:不只是`create-react-app`

5 阅读3分钟

在当今快节奏的前端开发环境中,脚手架工具已经成为每个开发者工作流中不可或缺的一部分。从create-react-appVite,这些工具极大地简化了项目初始化的过程。但你是否曾想过,这些脚手架工具背后是如何工作的?更重要的是,如何构建一个符合自己团队需求的定制化脚手架?

本文将带你深入探索前端脚手架的核心原理,并一步步构建一个功能完整的现代化脚手架工具。

为什么需要自定义脚手架?

你可能会有疑问:已经有那么多优秀的脚手架工具,为什么还要自己造轮子?

  1. 团队规范统一:每个团队都有自己的技术栈偏好和代码规范
  2. 项目特定需求:某些项目可能有特殊的目录结构或配置要求
  3. 效率提升:预置常用模板和工具链,减少重复配置时间
  4. 知识沉淀:将最佳实践固化到工具中,降低新人上手成本

脚手架的核心架构

一个完整的脚手架通常包含以下几个核心模块:

// 脚手架基本架构示例
class ScaffoldCLI {
  constructor() {
    this.prompts = [];      // 用户交互问题
    this.templates = {};    // 模板文件
    this.generators = {};   // 文件生成器
    this.postActions = [];  // 后置操作
  }
  
  async init() {
    await this.greet();     // 欢迎信息
    await this.ask();       // 收集用户输入
    await this.generate();  // 生成文件
    await this.install();   // 安装依赖
    await this.cleanup();   // 清理工作
  }
}

实战:构建一个React+TypeScript+Vite脚手架

让我们从零开始构建一个名为create-my-app的脚手架工具。

第一步:初始化项目结构

mkdir create-my-app
cd create-my-app
npm init -y

第二步:核心依赖安装

// package.json
{
  "name": "create-my-app",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "create-my-app": "./bin/cli.js"
  },
  "dependencies": {
    "commander": "^11.0.0",
    "inquirer": "^9.2.10",
    "chalk": "^5.2.0",
    "ora": "^7.0.1",
    "fs-extra": "^11.1.1",
    "download-git-repo": "^3.0.2",
    "cross-spawn": "^7.0.3"
  }
}

第三步:实现命令行接口

// bin/cli.js
#!/usr/bin/env node

import { Command } from 'commander';
import { createRequire } from 'module';
import { createProject } from '../lib/create.js';

const require = createRequire(import.meta.url);
const pkg = require('../package.json');

const program = new Command();

program
  .name('create-my-app')
  .description('快速创建现代化的React+TypeScript项目')
  .version(pkg.version)
  .argument('[project-name]', '项目名称')
  .option('-t, --template <template>', '选择模板 (react-ts, vue-ts, vanilla)')
  .option('-f, --force', '强制覆盖已存在的目录')
  .action(async (name, options) => {
    await createProject({
      name: name || 'my-app',
      template: options.template || 'react-ts',
      force: options.force || false
    });
  });

program.parse();

第四步:实现交互式问答

// lib/prompts.js
import inquirer from 'inquirer';
import chalk from 'chalk';

export async function askQuestions(defaultOptions) {
  const questions = [
    {
      type: 'input',
      name: 'projectName',
      message: '请输入项目名称:',
      default: defaultOptions.name,
      validate: (input) => {
        if (!input.trim()) {
          return '项目名称不能为空';
        }
        if (!/^[a-z0-9-]+$/.test(input)) {
          return '项目名称只能包含小写字母、数字和连字符';
        }
        return true;
      }
    },
    {
      type: 'list',
      name: 'template',
      message: '请选择项目模板:',
      choices: [
        { name: 'React + TypeScript + Vite', value: 'react-ts' },
        { name: 'Vue 3 + TypeScript + Vite', value: 'vue-ts' },
        { name: '纯JavaScript项目', value: 'vanilla' }
      ],
      default: defaultOptions.template
    },
    {
      type: 'checkbox',
      name: 'features',
      message: '选择需要集成的功能:',
      choices: [
        { name: 'ESLint代码检查', value: 'eslint', checked: true },
        { name: 'Prettier代码格式化', value: 'prettier', checked: true },
        { name: 'Husky Git钩子', value: 'husky', checked: true },
        { name: 'Tailwind CSS', value: 'tailwind' },
        { name: '状态管理 (Zustand)', value: 'zustand' },
        { name: '路由配置', value: 'router' }
      ]
    },
    {
      type: 'confirm',
      name: 'installDeps',
      message: '是否立即安装依赖?',
      default: true
    },
    {
      type: 'list',
      name: 'packageManager',
      message: '选择包管理器:',
      choices: ['npm', 'yarn', 'pnpm'],
      default: 'pnpm',
      when: (answers) => answers.installDeps
    }
  ];

  return inquirer.prompt(questions);
}

第五步:模板生成引擎

// lib/generator.js
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import ejs from 'ejs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export class TemplateGenerator {
  constructor(options) {
    this.options = options;
    this.targetDir = path.resolve(process.cwd(), options.projectName);
    this.templateDir = path.join(__dirname, '../templates', options.template);
  }

  async generate() {
    // 检查目标目录是否存在
    if (await fs.pathExists(this.targetDir)) {
      if (!this.options.force) {
        throw new Error(`目录 ${this.targetDir} 已存在,使用 -f 参数强制覆盖`);
      }
      await fs.remove(this.targetDir);
    }

    // 创建项目目录
    await fs.ensureDir(this.targetDir);

    // 复制模板文件
    await this.copyTemplateFiles();
    
    // 渲染模板文件
    await this.renderTemplateFiles();
    
    // 生成配置文件
    await this.generateConfigFiles();
    
    // 更新package.json
    await this.updatePackageJson();
  }

  async copyTemplateFiles() {
    const files = await this.getTemplateFiles();
    
    for (const file of files) {
      const sourcePath = path.join(this.templateDir, file);
      const targetPath = path.join(this.targetDir, file);
      
      const stat = await fs.stat(sourcePath);
      
      if (stat.isDirectory()) {
        await fs.ensureDir(targetPath);
      } else {
        await fs.copy(sourcePath, targetPath);
      }
    }
  }

  async renderTemplateFiles() {
    const ejsFiles = await this.findFilesByExt('.ejs');
    
    for (const file of ejsFiles) {
      const sourcePath = path.join(this.targetDir, file);
      const content = await fs.readFile(sourcePath, 'utf-8');
      
      // 渲染EJS模板
      const rendered = ejs.render(content, {
        project: this.options,
        features: this.options.features
      });
      
      // 写入渲染后的文件(移除.ejs扩展名)
      const targetPath = sourcePath.replace(/\.ejs$/, '');
      await fs.writeFile(targetPath, rendered);
      
      // 删除原始.ejs文件
      await fs.remove(sourcePath);
    }
  }

  async generateConfigFiles() {
    const configs = {
      eslint: {
        files: ['.eslintrc.js', '.eslintignore'],
        condition: this.options.features.includes('eslint')
      },
      prettier: {
        files: ['.prettierrc', '.prettierignore'],
        condition: this.options.features.includes('prettier')
      },
      husky: {
        files: ['.husky/pre-commit'],
        condition: this.options.features.includes('husky')
      }
    };

    for (const [tool, config] of Object.entries(configs)) {
      if (config.condition) {
        for (const file of config.files) {
          const sourcePath = path.join(__dirname, '../config