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

3 阅读1分钟

引言

在当今的前端开发中,脚手架工具已经成为项目启动的标准配置。无论是 Vue CLI、Create React App 还是 Angular CLI,它们都极大地提升了开发效率。但你是否曾想过,这些工具是如何工作的?当现有脚手架无法满足你的特定需求时,如何构建一个属于自己的脚手架工具?

本文将带你深入探索脚手架工具的核心原理,并一步步构建一个功能完整的现代化 Node.js 脚手架工具。我们将不仅实现基础的模板生成功能,还会加入依赖管理、Git 初始化、插件系统等高级特性。

一、脚手架工具的核心架构

1.1 什么是脚手架工具?

脚手架工具本质上是一个项目生成器,它通过预设的模板和配置,快速生成标准化的项目结构。一个优秀的脚手架工具应该具备以下特性:

  • 模板管理:支持多种模板和版本控制
  • 交互式配置:通过问答方式收集项目信息
  • 智能文件处理:根据配置动态生成文件内容
  • 依赖管理:自动安装项目依赖
  • 扩展性:支持插件和自定义配置

1.2 技术选型

我们将使用以下技术栈构建我们的脚手架工具:

  • Commander.js:命令行参数解析
  • Inquirer.js:交互式命令行界面
  • Chalk:终端样式美化
  • Ora:加载动画
  • download-git-repo:Git 仓库模板下载
  • handlebars:模板引擎
  • execa:子进程执行

二、项目初始化与基础配置

2.1 创建项目结构

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

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

安装基础依赖:

npm install commander inquirer chalk ora download-git-repo handlebars execa
npm install -D @types/node typescript ts-node

2.2 配置 TypeScript

创建 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

2.3 创建入口文件

创建 src/cli.ts 作为我们的入口文件:

#!/usr/bin/env node

import { Command } from 'commander';
import { createProject } from './commands/create';
import { listTemplates } from './commands/list';
import { addTemplate } from './commands/add';

const program = new Command();

program
  .name('my-cli')
  .description('一个现代化的项目脚手架工具')
  .version('1.0.0');

program
  .command('create <project-name>')
  .description('创建新项目')
  .option('-t, --template <template>', '指定模板名称')
  .option('-f, --force', '强制覆盖已存在的目录')
  .action(createProject);

program
  .command('list')
  .description('列出所有可用模板')
  .action(listTemplates);

program
  .command('add <template-name> <git-repo>')
  .description('添加新模板')
  .action(addTemplate);

program.parse(process.argv);

package.json 中添加 bin 配置:

{
  "bin": {
    "my-cli": "./dist/cli.js"
  }
}

三、核心功能实现

3.1 模板管理模块

创建 src/core/template.ts

import path from 'path';
import fs from 'fs-extra';
import download from 'download-git-repo';
import { promisify } from 'util';
import handlebars from 'handlebars';

const downloadRepo = promisify(download);

export interface TemplateConfig {
  name: string;
  description: string;
  repo: string;
  branch?: string;
  prompts?: Array<{
    type: string;
    name: string;
    message: string;
    default?: any;
    choices?: Array<{ name: string; value: any }>;
  }>;
  filters?: Record<string, (answers: any) => boolean>;
  helpers?: Record<string, (...args: any[]) => any>;
}

export class TemplateManager {
  private templates: Map<string, TemplateConfig> = new Map();
  private configPath: string;

  constructor() {
    this.configPath = path.join(process.env.HOME || process.env.USERPROFILE || '', '.my-cli', 'templates.json');
    this.loadTemplates();
  }

  private loadTemplates() {
    if (fs.existsSync(this.configPath)) {
      const data = fs.readJsonSync(this.configPath);
      this.templates = new Map(Object.entries(data));
    }
  }

  private saveTemplates() {
    const dir = path.dirname(this.configPath);
    fs.ensureDirSync(dir);
    fs.writeJsonSync(this.configPath, Object.fromEntries(this.templates));
  }

  addTemplate(name: string, config: TemplateConfig) {
    this.templates.set(name, config);
    this.saveTemplates();
    return true;
  }

  getTemplate(name: string): TemplateConfig | undefined {
    return this.templates.get(name);
  }

  getAllTemplates(): Array<{ name: string; config: TemplateConfig }> {
    return Array.from(this.templates.entries()).map(([name, config]) => ({
      name,
      config
    }));
  }

  async downloadTemplate(repo: string, dest: string, branch?: string): Promise<void> {
    return new Promise((resolve, reject) => {
      const repoPath = branch ? `${repo}#${branch}` : repo;
      
      download(repoPath, dest, (err: Error) => {
        if (err) {
          reject(err);
        } else {
          resolve();
        }
      });
    });
  }

  async renderTemplate(source: string, data: any): Promise<void> {
    const files = await this.walkDirectory(source);
    
    for (const file of files) {
      if (file.endsWith('.hbs')) {
        await this.renderFile(file, data);
        await fs.rename(file, file.replace('.hbs', ''));
      } else if (this.shouldRenderFile(file)) {
        await this.renderFile(file, data);
      }
    }
  }

  private async walkDirectory(dir: string): Promise<string[]> {
    const results: string[] = [];
    const list = await fs.readdir(dir);
    
    for (const file of list) {
      const fullPath = path.join(dir, file);
      const stat = await fs.stat(fullPath);
      
      if (stat.isDirectory()) {
        results.push(...await this.walkDirectory(fullPath));
      } else {
        results.push(fullPath);
      }
    }
    
    return results;
  }

  private async renderFile(filePath: string, data: any): Promise<void> {
    const content = await fs.readFile(filePath, 'utf-8');
    const template = handlebars.compile(content);
    const result = template(data);
    await fs.writeFile(filePath, result, 'utf-8');
  }

  private shouldRenderFile(filePath: string): boolean {
    const ext = path.extname(filePath);
    const renderableExtensions = ['.js', '.ts', '.json', '.md', '.txt', '.yml', '.yaml'];
    return renderableExtensions.includes(ext);
  }
}

3.2 项目创建命令

创建 src/commands/create.ts

import path from 'path';
import fs from 'fs-extra';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import { TemplateManager } from '../core/template';
import { installDependencies, initGit } from '../utils/project';

export async function createProject(projectName: string, options: any) {
  const targetDir = path.resolve(process.cwd(), projectName);
  
  // 检查目录是否存在
  if (fs.existsSync(targetDir)) {
    if (options.force) {
      await fs.remove(targetDir);
    } else {
      console.log(chalk.red(`目录 ${projectName} 已存在,请使用 -f 参数强制覆盖`));
      process.exit(1);
    }
  }

  const templateManager = new TemplateManager();
  let templateName = options.template;

  // 如果没有指定模板,让用户选择
  if (!templateName) {
    const templates = templateManager.getAllTemplates();
    
    if (templates.length === 0) {
      console.log(chalk.yellow('没有可用的模板,请先添加模板'));
      process.exit(1);
    }

    const { selectedTemplate } = await inquirer.prompt([
      {
        type