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

4 阅读1分钟

引言

在当今的前端开发中,脚手架工具已经成为项目启动的标配。从 create-react-appVue CLI,这些工具极大地提升了开发效率。但你是否曾想过,这些工具是如何工作的?更重要的是,当现有工具无法满足你的特定需求时,如何构建一个属于自己的脚手架工具?

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

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

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

  1. 项目特定需求:公司内部项目可能有特殊的目录结构或配置要求
  2. 技术栈定制:需要整合特定的技术栈组合
  3. 流程自动化:集成公司内部的代码规范、CI/CD 配置等
  4. 学习价值:深入理解脚手架工具的工作原理

项目规划

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

  • 命令行交互(询问用户配置选项)
  • 模板文件渲染(支持变量替换)
  • 插件系统(可扩展功能)
  • Git 仓库初始化
  • 依赖安装自动化

技术选型

# 我们将使用以下核心依赖
mkdir my-cli && cd my-cli
npm init -y
npm install commander inquirer ejs chalk ora fs-extra download-git-repo
  • commander:命令行参数解析
  • inquirer:交互式命令行界面
  • ejs:模板引擎
  • chalk:终端字符串样式
  • ora:优雅的终端加载动画
  • fs-extra:增强的 fs 模块
  • download-git-repo:从 Git 仓库下载模板

核心架构设计

让我们先来看看项目的整体架构:

my-cli/
├── bin/
│   └── cli.js          # 命令行入口
├── lib/
│   ├── core/
│   │   ├── Creator.js  # 项目创建器
│   │   ├── Generator.js # 代码生成器
│   │   └── Plugin.js   # 插件管理器
│   ├── utils/
│   │   ├── logger.js   # 日志工具
│   │   └── file.js     # 文件操作工具
│   └── templates/      # 模板目录
├── plugins/            # 插件目录
└── package.json

实现步骤

1. 创建命令行入口

首先,创建 bin/cli.js

#!/usr/bin/env node

const { program } = require('commander');
const pkg = require('../package.json');
const Creator = require('../lib/core/Creator');

program
  .version(pkg.version)
  .description('一个现代化的项目脚手架工具')
  .option('-d, --debug', '开启调试模式')
  .option('-t, --template <template>', '指定模板名称');

program
  .command('create <project-name>')
  .description('创建一个新项目')
  .action(async (projectName, options) => {
    const creator = new Creator(projectName, options);
    await creator.create();
  });

program
  .command('add <plugin-name>')
  .description('添加插件')
  .action(async (pluginName) => {
    console.log(`添加插件: ${pluginName}`);
    // 插件安装逻辑
  });

program.parse(process.argv);

package.json 中添加 bin 配置:

{
  "name": "my-cli",
  "version": "1.0.0",
  "bin": {
    "my-cli": "./bin/cli.js"
  }
}

2. 实现项目创建器

创建 lib/core/Creator.js

const inquirer = require('inquirer');
const path = require('path');
const fs = require('fs-extra');
const chalk = require('chalk');
const Generator = require('./Generator');
const { installDependencies } = require('../utils/install');
const { initGitRepo } = require('../utils/git');

class Creator {
  constructor(projectName, options) {
    this.projectName = projectName;
    this.options = options;
    this.targetDir = path.resolve(process.cwd(), projectName);
    this.answers = {};
  }

  async create() {
    // 检查目录是否存在
    if (fs.existsSync(this.targetDir)) {
      const { overwrite } = await inquirer.prompt([
        {
          type: 'confirm',
          name: 'overwrite',
          message: '目录已存在,是否覆盖?',
          default: false
        }
      ]);
      
      if (!overwrite) {
        console.log(chalk.yellow('操作已取消'));
        process.exit(1);
      }
      
      await fs.remove(this.targetDir);
    }

    // 收集用户输入
    await this.promptUser();

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

    // 生成项目文件
    const generator = new Generator(this.projectName, this.targetDir, this.answers);
    await generator.generate();

    // 初始化 Git 仓库
    if (this.answers.initGit) {
      await initGitRepo(this.targetDir);
    }

    // 安装依赖
    if (this.answers.autoInstall) {
      await installDependencies(this.targetDir, this.answers.packageManager);
    }

    // 显示成功信息
    this.showSuccessMessage();
  }

  async promptUser() {
    const prompts = [
      {
        type: 'input',
        name: 'projectDescription',
        message: '项目描述:',
        default: 'A new project'
      },
      {
        type: 'list',
        name: 'template',
        message: '请选择模板:',
        choices: [
          { name: 'React + TypeScript', value: 'react-ts' },
          { name: 'Vue 3 + TypeScript', value: 'vue3-ts' },
          { name: 'Node.js API', value: 'node-api' }
        ],
        default: 'react-ts'
      },
      {
        type: 'list',
        name: 'packageManager',
        message: '包管理器:',
        choices: ['npm', 'yarn', 'pnpm'],
        default: 'npm'
      },
      {
        type: 'confirm',
        name: 'initGit',
        message: '是否初始化 Git 仓库?',
        default: true
      },
      {
        type: 'confirm',
        name: 'autoInstall',
        message: '是否自动安装依赖?',
        default: true
      }
    ];

    this.answers = await inquirer.prompt(prompts);
  }

  showSuccessMessage() {
    console.log();
    console.log(chalk.green('✅ 项目创建成功!'));
    console.log();
    console.log(chalk.cyan('接下来可以执行以下命令:'));
    console.log();
    console.log(`  cd ${this.projectName}`);
    
    if (!this.answers.autoInstall) {
      console.log(`  ${this.answers.packageManager} install`);
    }
    
    console.log(`  ${this.answers.packageManager === 'npm' ? 'npm run' : this.answers.packageManager} dev`);
    console.log();
  }
}

module.exports = Creator;

3. 实现代码生成器

创建 lib/core/Generator.js

const fs = require('fs-extra');
const path = require('path');
const ejs = require('ejs');
const chalk = require('chalk');
const ora = require('ora');

class Generator {
  constructor(projectName, targetDir, answers) {
    this.projectName = projectName;
    this.targetDir = targetDir;
    this.answers = answers;
    this.templateDir = this.getTemplateDir();
  }

  getTemplateDir() {
    // 根据模板类型返回对应的模板目录
    const templateMap = {
      'react-ts': path.join(__dirname, '../templates/react-ts'),
      'vue3-ts': path.join(__dirname, '../templates/vue3-ts'),
      'node-api': path.join(__dirname, '../templates/node-api')
    };
    
    return templateMap[this.answers.template] || templateMap['react-ts'];
  }

  async generate() {
    const spinner = ora('正在生成项目文件...').start();
    
    try {
      // 复制模板文件
      await this.copyTemplate();
      
      // 渲染模板文件
      await this.renderFiles();
      
      // 生成 package.json
      await this.generatePackageJson();
      
      spinner.succeed(chalk.green('项目文件生成完成!'));
    } catch (error) {
      spinner.fail(chalk.red('文件生成失败'));
      console.error(error);
      process.exit(1);
    }
  }

  async copyTemplate() {
    if (!fs.existsSync(this.templateDir)) {
      throw new Error(`模板目录不存在: ${this.templateDir}`);
    }
    
    await fs.copy(this.templateDir