从零构建一个现代化的 Node.js 脚手架工具:原理、设计与最佳实践

6 阅读1分钟

在当今的前端开发中,脚手架工具已经成为项目启动的标配。无论是 Vue CLI、Create React App 还是 Vite,这些工具都极大地提升了开发效率。但你是否曾好奇这些工具背后的工作原理?本文将带你从零开始,构建一个现代化的 Node.js 脚手架工具,深入探讨其核心原理、架构设计和最佳实践。

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

在开始之前,我们先思考一个问题:为什么我们需要自己构建脚手架工具?

  1. 统一项目规范:确保团队所有项目遵循相同的目录结构、代码规范和工具配置
  2. 提升开发效率:自动化重复的初始化工作,让开发者专注于业务逻辑
  3. 技术栈标准化:固化最佳实践,避免每次项目都要重新配置
  4. 知识沉淀:将团队经验转化为可复用的工具

脚手架的核心架构

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

├── 命令行接口 (CLI)
├── 模板系统
├── 交互式问答
├── 文件操作
├── 依赖管理
└── 发布与更新

让我们一步步实现这些模块。

1. 项目初始化与 CLI 设计

首先,我们创建一个新的 Node.js 项目:

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

1.1 配置 package.json

{
  "name": "@myorg/create-app",
  "version": "1.0.0",
  "description": "Modern project scaffolding tool",
  "bin": {
    "create-app": "bin/cli.js"
  },
  "files": [
    "bin",
    "lib",
    "templates"
  ],
  "scripts": {
    "dev": "node bin/cli.js",
    "lint": "eslint .",
    "test": "jest"
  },
  "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",
    "update-notifier": "^6.0.2"
  },
  "engines": {
    "node": ">=14.0.0"
  }
}

1.2 实现 CLI 入口文件

创建 bin/cli.js

#!/usr/bin/env node

const { program } = require('commander');
const { version } = require('../package.json');
const createProject = require('../lib/create');

// 设置 CLI 基本信息
program
  .name('create-app')
  .description('A modern scaffolding tool for web applications')
  .version(version, '-v, --version')
  .usage('<project-name> [options]');

// 添加项目名称参数
program
  .argument('<project-name>', 'name of the project to create')
  .option('-t, --template <template>', 'specify a template (react, vue, node)')
  .option('-f, --force', 'overwrite target directory if it exists')
  .option('-g, --git', 'initialize git repository')
  .option('-s, --skip-install', 'skip package installation')
  .action(async (projectName, options) => {
    try {
      await createProject(projectName, options);
    } catch (error) {
      console.error('Error:', error.message);
      process.exit(1);
    }
  });

// 添加模板列表命令
program
  .command('list')
  .description('list all available templates')
  .action(() => {
    console.log('Available templates:');
    console.log('  react    - React + TypeScript + Vite');
    console.log('  vue      - Vue 3 + TypeScript + Vite');
    console.log('  node     - Node.js + TypeScript + Express');
    console.log('  library  - TypeScript library template');
  });

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

2. 核心创建逻辑实现

创建 lib/create.js

const path = require('path');
const fs = require('fs-extra');
const chalk = require('chalk');
const ora = require('ora');
const inquirer = require('inquirer');
const { execSync } = require('child_process');

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

  async run() {
    // 1. 检查目标目录
    await this.checkTargetDir();
    
    // 2. 收集用户输入
    await this.promptForOptions();
    
    // 3. 创建项目
    await this.createProject();
    
    // 4. 安装依赖
    if (!this.options.skipInstall) {
      await this.installDependencies();
    }
    
    // 5. 初始化 Git
    if (this.options.git) {
      await this.initGit();
    }
    
    // 6. 显示完成信息
    this.showSuccessMessage();
  }

  async checkTargetDir() {
    if (fs.existsSync(this.targetDir)) {
      if (this.options.force) {
        const spinner = ora('Removing existing directory...').start();
        await fs.remove(this.targetDir);
        spinner.succeed('Directory removed');
      } else {
        const { action } = await inquirer.prompt([
          {
            name: 'action',
            type: 'list',
            message: `Target directory ${chalk.cyan(this.targetDir)} already exists. Pick an action:`,
            choices: [
              { name: 'Overwrite', value: 'overwrite' },
              { name: 'Merge', value: 'merge' },
              { name: 'Cancel', value: false }
            ]
          }
        ]);

        if (!action) {
          throw new Error('Operation cancelled');
        } else if (action === 'overwrite') {
          const spinner = ora('Removing existing directory...').start();
          await fs.remove(this.targetDir);
          spinner.succeed('Directory removed');
        }
      }
    }
  }

  async promptForOptions() {
    // 如果命令行没有指定模板,则询问用户
    if (!this.options.template) {
      const answers = await inquirer.prompt([
        {
          name: 'template',
          type: 'list',
          message: 'Please choose a project template:',
          choices: [
            { name: 'React + TypeScript + Vite', value: 'react' },
            { name: 'Vue 3 + TypeScript + Vite', value: 'vue' },
            { name: 'Node.js + TypeScript + Express', value: 'node' },
            { name: 'TypeScript Library', value: 'library' }
          ]
        },
        {
          name: 'packageManager',
          type: 'list',
          message: 'Select package manager:',
          choices: [
            { name: 'npm', value: 'npm' },
            { name: 'yarn', value: 'yarn' },
            { name: 'pnpm', value: 'pnpm' }
          ]
        }
      ]);
      
      this.options.template = answers.template;
      this.options.packageManager = answers.packageManager;
    }
  }

  async createProject() {
    const spinner = ora('Creating project...').start();
    
    try {
      // 创建项目目录
      await fs.ensureDir(this.targetDir);
      
      // 复制模板文件
      const templatePath = path.join(this.templateDir, this.options.template);
      if (!fs.existsSync(templatePath)) {
        throw new Error(`Template ${this.options.template} not found`);
      }
      
      await fs.copy(templatePath, this.targetDir);
      
      // 读取 package.json 并更新项目名称
      const packageJsonPath = path.join(this.targetDir, 'package.json');
      if (fs.existsSync(packageJsonPath)) {
        const packageJson = await fs.readJson(packageJsonPath);
        packageJson.name = this.projectName;
        await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
      }
      
      spinner.succeed('Project created successfully');
    } catch (error) {
      spinner.fail('Failed to create project');
      throw error;
    }
  }

  async installDependencies() {
    const spinner = ora('Installing dependencies...').start();
    
    try {
      const commands = {
        npm: 'npm install',
        yarn: 'yarn install',
        pnpm: 'pnpm install'
      };
      
      execSync(commands[this.options.packageManager || 'npm'], {
        cwd: this.targetDir,
        stdio: 'inherit'
      });
      
      spinner.succeed('Dependencies installed');
    } catch (error) {
      spinner.fail('Failed to install dependencies');
      // 不抛出错误,让用户手动安装
    }
  }

  async initGit