从零构建一个现代化的 Node.js 脚手架工具:不只是 `create-react-app`

3 阅读1分钟

在当今的前端开发中,脚手架工具已经成为项目启动的标配。从 create-react-appvue-cli,这些工具极大地提升了开发效率。但你是否曾想过,这些工具背后是如何工作的?本文将带你从零开始,构建一个功能完整的现代化 Node.js 脚手架工具,深入探讨其核心原理和最佳实践。

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

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

  1. 项目特定需求:公司内部项目有特定的技术栈、目录结构和配置规范
  2. 统一开发规范:确保团队所有项目遵循相同的代码规范和工程化标准
  3. 学习价值:深入理解现代前端工程化工具链的工作原理
  4. 灵活定制:根据业务需求快速调整模板和配置

核心架构设计

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

├── bin/                    # CLI入口
├── lib/
│   ├── commands/          # 命令实现
│   ├── templates/         # 项目模板
│   ├── utils/             # 工具函数
│   └── prompts/           # 交互式问题
├── package.json
└── README.md

实现步骤详解

1. 初始化项目结构

首先创建项目目录并初始化 package.json:

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

编辑 package.json,添加必要的配置:

{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "A modern project scaffolding tool",
  "bin": {
    "my-cli": "./bin/cli.js"
  },
  "files": ["bin", "lib"],
  "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"
  }
}

2. 创建 CLI 入口文件

创建 bin/cli.js 作为命令行入口:

#!/usr/bin/env node

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

// 设置基本信息
program
  .name('my-cli')
  .description('A modern project scaffolding tool')
  .version(version, '-v, --version');

// 创建项目命令
program
  .command('create <project-name>')
  .description('Create a new project')
  .option('-t, --template <template>', 'Specify project template')
  .option('-f, --force', 'Force overwrite existing directory')
  .action(async (projectName, options) => {
    const create = require('../lib/commands/create');
    await create(projectName, options);
  });

// 列出可用模板命令
program
  .command('list')
  .description('List all available templates')
  .action(() => {
    const list = require('../lib/commands/list');
    list();
  });

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

3. 实现项目创建命令

创建 lib/commands/create.js

const path = require('path');
const fs = require('fs-extra');
const inquirer = require('inquirer');
const chalk = require('chalk');
const ora = require('ora');
const { downloadTemplate } = require('../utils/download');
const { installDependencies } = require('../utils/install');

module.exports = async function create(projectName, options) {
  const cwd = process.cwd();
  const targetDir = path.join(cwd, projectName);
  
  // 检查目录是否已存在
  if (fs.existsSync(targetDir)) {
    if (options.force) {
      await fs.remove(targetDir);
    } else {
      const { action } = await inquirer.prompt([
        {
          name: 'action',
          type: 'list',
          message: `Directory ${chalk.cyan(projectName)} already exists. Choose an action:`,
          choices: [
            { name: 'Overwrite', value: 'overwrite' },
            { name: 'Merge', value: 'merge' },
            { name: 'Cancel', value: false }
          ]
        }
      ]);
      
      if (!action) {
        return;
      } else if (action === 'overwrite') {
        console.log(`\nRemoving ${chalk.cyan(projectName)}...`);
        await fs.remove(targetDir);
      }
    }
  }
  
  // 收集项目信息
  const answers = await inquirer.prompt([
    {
      name: 'projectName',
      type: 'input',
      message: 'Project name:',
      default: projectName,
      validate: (input) => {
        if (/^[a-z0-9-]+$/.test(input)) return true;
        return 'Project name must be lowercase letters, numbers, and hyphens only';
      }
    },
    {
      name: 'description',
      type: 'input',
      message: 'Project description:',
      default: 'A new project created with my-cli'
    },
    {
      name: 'author',
      type: 'input',
      message: 'Author:'
    },
    {
      name: 'template',
      type: 'list',
      message: 'Select a template:',
      choices: [
        { name: 'React + TypeScript', value: 'react-ts' },
        { name: 'Vue 3 + TypeScript', value: 'vue3-ts' },
        { name: 'Node.js + TypeScript', value: 'node-ts' },
        { name: 'Vanilla JavaScript', value: 'vanilla' }
      ],
      default: options.template || 'react-ts'
    },
    {
      name: 'packageManager',
      type: 'list',
      message: 'Select package manager:',
      choices: [
        { name: 'npm', value: 'npm' },
        { name: 'yarn', value: 'yarn' },
        { name: 'pnpm', value: 'pnpm' }
      ],
      default: 'npm'
    }
  ]);
  
  // 创建项目目录
  console.log(`\nCreating project in ${chalk.green(targetDir)}...`);
  await fs.ensureDir(targetDir);
  
  // 下载模板
  const spinner = ora('Downloading template...').start();
  try {
    await downloadTemplate(answers.template, targetDir);
    spinner.succeed('Template downloaded successfully!');
  } catch (error) {
    spinner.fail('Failed to download template');
    console.error(chalk.red(error.message));
    process.exit(1);
  }
  
  // 更新 package.json
  const packageJsonPath = path.join(targetDir, 'package.json');
  if (fs.existsSync(packageJsonPath)) {
    const packageJson = await fs.readJson(packageJsonPath);
    Object.assign(packageJson, {
      name: answers.projectName,
      description: answers.description,
      author: answers.author,
      version: '1.0.0'
    });
    await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
  }
  
  // 安装依赖
  const installSpinner = ora('Installing dependencies...').start();
  try {
    await installDependencies(targetDir, answers.packageManager);
    installSpinner.succeed('Dependencies installed successfully!');
  } catch (error) {
    installSpinner.fail('Failed to install dependencies');
    console.log(chalk.yellow('You can install dependencies manually later.'));
  }
  
  // 显示成功信息
  console.log(`\n${chalk.green('✓')} Project ${chalk.cyan(answers.projectName)} created successfully!\n`);
  console.log(chalk.bold('Next steps:'));
  console.log(`  cd ${projectName}`);
  if (!answers.packageManager || answers.packageManager === 'npm') {
    console.log('  npm run dev');
  } else {
    console.log(`  ${answers.packageManager} run dev`);
  }
  console.log('\nHappy coding! 🎉\n');
};

4. 实现模板下载工具

创建 lib/utils/download.js

const { promisify } = require('util');
const download = promisify(require('download-git-repo'));
const chalk = require('chalk');

// 模板仓库映射
const templateRepos = {
  'react-ts': 'github:facebook/create-react-app#main',
  'vue3-ts': 'github:vuejs/create-vue#main',
  'node-ts': 'github:tsconfig/bases#main',
  'vanilla': 'github:vercel/vanilla-template#main'
};

module.exports.downloadTemplate = async function(template, targetDir) {
  const repo = templateRepos[template];
  
  if (!repo) {
    throw new Error