从零构建一个现代化的 Node.js 脚手架工具:不只是生成文件

3 阅读1分钟

在当今的前端开发中,脚手架工具已经成为项目启动的标配。从 create-react-appVue CLI,这些工具极大地提升了开发效率。但你是否曾想过,这些工具背后的原理是什么?如何构建一个适合自己团队需求的脚手架工具?

本文将带你从零开始,构建一个功能完整的现代化 Node.js 脚手架工具。我们将不仅实现基本的文件生成功能,还会加入模板系统、用户交互、依赖安装等高级特性。

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

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

  1. 团队规范统一:统一的目录结构、代码规范和开发流程
  2. 技术栈定制:根据团队技术栈定制模板,避免每次手动配置
  3. 效率提升:一键生成项目基础结构,节省重复劳动时间
  4. 知识沉淀:将最佳实践固化到工具中,降低新人上手成本

项目规划

我们的脚手架工具将具备以下功能:

  • 命令行交互(选择模板、配置项目)
  • 模板系统(支持本地和远程模板)
  • 文件生成与变量替换
  • 自动安装依赖
  • Git 初始化

1. 项目初始化

首先创建我们的脚手架项目:

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

修改 package.json

{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "A modern CLI tool for project scaffolding",
  "bin": {
    "my-cli": "./bin/cli.js"
  },
  "scripts": {
    "start": "node ./bin/cli.js"
  },
  "keywords": ["cli", "scaffold", "boilerplate"],
  "author": "Your Name",
  "license": "MIT",
  "dependencies": {
    "chalk": "^4.1.2",
    "commander": "^9.4.1",
    "inquirer": "^8.2.5",
    "ora": "^5.4.1",
    "fs-extra": "^10.1.0",
    "download-git-repo": "^3.0.2"
  }
}

2. 核心架构设计

我们的脚手架将采用以下架构:

my-cli/
├── bin/
│   └── cli.js          # 命令行入口
├── lib/
│   ├── commands/       # 命令模块
│   ├── templates/      # 模板管理
│   ├── utils/         # 工具函数
│   └── generators/     # 生成器
├── templates/          # 本地模板
└── package.json

3. 实现命令行入口

创建 bin/cli.js

#!/usr/bin/env node

const { program } = require('commander');
const chalk = require('chalk');
const createCommand = require('../lib/commands/create');

// 设置版本信息
program
  .version('1.0.0')
  .description('A modern CLI tool for project scaffolding');

// 创建项目命令
program
  .command('create <project-name>')
  .description('Create a new project')
  .option('-t, --template <template>', 'Specify template name')
  .option('-f, --force', 'Overwrite target directory if it exists')
  .action(async (projectName, options) => {
    try {
      await createCommand(projectName, options);
    } catch (error) {
      console.error(chalk.red('Error:', error.message));
      process.exit(1);
    }
  });

// 模板列表命令
program
  .command('list')
  .description('List all available templates')
  .action(() => {
    console.log(chalk.blue('Available templates:'));
    console.log('  - react-template');
    console.log('  - vue-template');
    console.log('  - node-template');
  });

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

4. 实现创建命令

创建 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 Generator = require('../generators/project-generator');

module.exports = async function createCommand(projectName, options) {
  const cwd = process.cwd();
  const targetDir = path.resolve(cwd, projectName);
  
  // 检查目标目录是否存在
  if (fs.existsSync(targetDir)) {
    if (options.force) {
      await fs.remove(targetDir);
    } else {
      const { action } = await inquirer.prompt([
        {
          name: 'action',
          type: 'list',
          message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick 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(targetDir)}...`);
        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 may only include lowercase letters, numbers, and hyphens.';
      }
    },
    {
      name: 'description',
      type: 'input',
      message: 'Project description:',
      default: 'A new project created with my-cli'
    },
    {
      name: 'author',
      type: 'input',
      message: 'Author:',
      default: ''
    },
    {
      name: 'template',
      type: 'list',
      message: 'Select a template:',
      choices: [
        { name: 'React + TypeScript', value: 'react-ts' },
        { name: 'Vue 3 + Vite', value: 'vue3-vite' },
        { name: 'Node.js + Express', value: 'node-express' }
      ],
      when: !options.template
    }
  ]);
  
  // 合并选项
  const projectOptions = {
    ...answers,
    template: options.template || answers.template,
    targetDir
  };
  
  // 创建项目
  const generator = new Generator(projectOptions);
  await generator.generate();
  
  console.log(chalk.green('\n✅ Project created successfully!'));
  console.log(chalk.blue('\nNext steps:'));
  console.log(`  cd ${projectName}`);
  console.log('  npm install');
  console.log('  npm start\n');
};

5. 实现项目生成器

创建 lib/generators/project-generator.js

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

class ProjectGenerator {
  constructor(options) {
    this.options = options;
    this.templateDir = this.getTemplateDir(options.template);
  }
  
  getTemplateDir(templateName) {
    // 这里可以扩展为从远程仓库下载模板
    const localTemplates = {
      'react-ts': 'templates/react-typescript',
      'vue3-vite': 'templates/vue3-vite',
      'node-express': 'templates/node-express'
    };
    
    const templatePath = localTemplates[templateName];
    if (!templatePath) {
      throw new Error(`Template ${templateName} not found`);
    }
    
    return path.resolve(__dirname, '../../', templatePath);
  }
  
  async generate() {
    const spinner = ora('Creating project...').start();
    
    try {
      // 1. 复制模板文件
      await this.copyTemplate();
      
      // 2. 处理模板变量
      await this.processTemplateVariables();
      
      // 3. 初始化 package.json
      await this.initPackageJson();
      
      // 4. 初始化 Git
      await this.initGit();
      
      spinner.succeed('Project created successfully');
      
      // 5. 安装依赖(可选)
      const { install } = await inquirer.prompt([
        {
          name: 'install',
          type: 'confirm',
          message: 'Install dependencies now?',
          default: true
        }
      ]);
      
      if (install) {
        await this.installDependencies();
      }
      
    } catch (error) {
      spinner.fail('Failed to create project');
      throw error;
    }
  }
  
  async copyTemplate() {
    const { targetDir } = this.options