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

在当今的前端开发中,脚手架工具已经成为项目启动的标配。从 create-react-appVue CLI,再到 Next.jscreate-next-app,这些工具极大地提升了开发效率。但你是否想过,这些工具是如何工作的?今天,我们将从零开始构建一个现代化的 Node.js 脚手架工具,不仅生成文件,还要实现模板管理、用户交互和自动化配置等高级功能。

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

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

  1. 项目特定需求:每个团队的技术栈和规范不同,通用工具无法完全满足
  2. 学习价值:理解脚手架的工作原理能提升你的 Node.js 和工程化能力
  3. 灵活控制:自定义脚手架可以集成团队特有的工作流和最佳实践

项目架构设计

我们的脚手架工具将包含以下核心模块:

bash 复制代码
modern-cli/
├── bin/cli.js          # CLI入口文件
├── src/
│   ├── commands/       # 命令模块
│   ├── templates/      # 模板系统
│   ├── prompts/        # 用户交互
│   ├── generators/     # 代码生成器
│   └── utils/          # 工具函数
├── templates/          # 项目模板
└── package.json

第一步:搭建基础 CLI 框架

首先创建项目并初始化:

bash 复制代码
mkdir modern-cli && cd modern-cli
npm init -y

创建 CLI 入口文件 bin/cli.js

javascript 复制代码
#!/usr/bin/env node

const { Command } = require('commander');
const pkg = require('../package.json');

const program = new Command();

program
  .name('modern-cli')
  .description('现代化项目脚手架工具')
  .version(pkg.version);

// 创建项目命令
program
  .command('create <project-name>')
  .description('创建一个新项目')
  .option('-t, --template <template>', '指定项目模板')
  .option('-f, --force', '强制覆盖已存在目录')
  .action(async (projectName, options) => {
    const create = require('../src/commands/create');
    await create(projectName, options);
  });

// 模板管理命令
program
  .command('template')
  .description('管理项目模板')
  .addCommand(
    new Command('list')
      .description('列出所有可用模板')
      .action(() => {
        const template = require('../src/commands/template');
        template.list();
      })
  )
  .addCommand(
    new Command('add <template-name> <template-url>')
      .description('添加新模板')
      .action((templateName, templateUrl) => {
        const template = require('../src/commands/template');
        template.add(templateName, templateUrl);
      })
  );

program.parse(process.argv);

package.json 中添加 bin 配置:

json 复制代码
{
  "name": "modern-cli",
  "version": "1.0.0",
  "bin": {
    "modern-cli": "./bin/cli.js"
  },
  "dependencies": {
    "commander": "^11.0.0",
    "inquirer": "^9.2.7",
    "chalk": "^5.2.0",
    "ora": "^7.0.1",
    "fs-extra": "^11.1.1"
  }
}

第二步:实现用户交互系统

用户交互是脚手架的重要部分。我们使用 inquirer 来创建丰富的交互体验:

javascript 复制代码
// src/prompts/projectPrompts.js
const inquirer = require('inquirer');
const chalk = require('chalk');

async function promptForProjectOptions(options = {}) {
  const defaultTemplate = 'react-ts';
  
  const questions = [
    {
      type: 'list',
      name: 'template',
      message: '请选择项目模板',
      choices: [
        { name: 'React + TypeScript', value: 'react-ts' },
        { name: 'Vue 3 + TypeScript', value: 'vue3-ts' },
        { name: 'Next.js + TypeScript', value: 'nextjs-ts' },
        { name: 'Node.js + TypeScript', value: 'nodejs-ts' },
      ],
      default: options.template || defaultTemplate,
      when: !options.template,
    },
    {
      type: 'input',
      name: 'projectName',
      message: '项目名称',
      default: options.projectName || 'my-project',
      validate: (input) => {
        if (/^[a-z][a-z0-9-]*$/.test(input)) {
          return true;
        }
        return '项目名称只能包含小写字母、数字和连字符,且必须以字母开头';
      },
    },
    {
      type: 'input',
      name: 'description',
      message: '项目描述',
      default: 'A modern web project',
    },
    {
      type: 'confirm',
      name: 'useEslint',
      message: '是否启用 ESLint?',
      default: true,
    },
    {
      type: 'confirm',
      name: 'usePrettier',
      message: '是否启用 Prettier?',
      default: true,
      when: (answers) => answers.useEslint,
    },
    {
      type: 'checkbox',
      name: 'features',
      message: '选择额外功能',
      choices: [
        { name: '状态管理 (Zustand)', value: 'zustand' },
        { name: '路由管理', value: 'router' },
        { name: 'HTTP 客户端 (Axios)', value: 'axios' },
        { name: 'UI 组件库', value: 'ui-library' },
        { name: '单元测试 (Vitest)', value: 'testing' },
      ],
    },
  ];

  return await inquirer.prompt(questions);
}

module.exports = { promptForProjectOptions };

第三步:实现模板引擎系统

模板引擎负责将模板文件转换为实际项目文件。我们支持动态变量替换和条件渲染:

javascript 复制代码
// src/generators/templateEngine.js
const fs = require('fs-extra');
const path = require('path');
const ejs = require('ejs');
const { glob } = require('glob');

class TemplateEngine {
  constructor(templateDir, targetDir, variables) {
    this.templateDir = templateDir;
    this.targetDir = targetDir;
    this.variables = variables;
  }

  async render() {
    // 获取所有模板文件
    const files = await glob('**/*', {
      cwd: this.templateDir,
      dot: true,
      ignore: ['**/node_modules/**', '**/.git/**'],
    });

    for (const file of files) {
      const sourcePath = path.join(this.templateDir, file);
      const targetPath = path.join(this.targetDir, this.renderFileName(file));
      
      // 确保目标目录存在
      await fs.ensureDir(path.dirname(targetPath));

      const stats = await fs.stat(sourcePath);
      
      if (stats.isDirectory()) {
        await fs.ensureDir(targetPath);
        continue;
      }

      // 处理模板文件
      if (this.isTemplateFile(file)) {
        await this.renderTemplateFile(sourcePath, targetPath);
      } else {
        await fs.copy(sourcePath, targetPath);
      }
    }
  }

  renderFileName(filename) {
    // 处理文件名中的模板变量
    return ejs.render(filename, this.variables, {
      openDelimiter: '<%',
      closeDelimiter: '%>',
    });
  }

  isTemplateFile(filename) {
    return filename.endsWith('.ejs') || filename.includes('<%');
  }

  async renderTemplateFile(sourcePath, targetPath) {
    const content = await fs.readFile(sourcePath, 'utf-8');
    
    // 移除 .ejs 扩展名
    const finalTargetPath = targetPath.replace(/\.ejs$/, '');
    
    try {
      const rendered = ejs.render(content, this.variables, {
        openDelimiter: '<%',
        closeDelimiter: '%>',
      });
      
      await fs.writeFile(finalTargetPath, rendered, 'utf-8');
    } catch (error) {
      console.error(`渲染模板失败: ${sourcePath}`, error);
      throw error;
    }
  }
}

module.exports = TemplateEngine;

第四步:实现项目创建命令

现在我们将所有部分组合起来,实现完整的项目创建流程:

javascript 复制代码
// src/commands/create.js
const path = require('path');
const fs = require('fs-extra');
const chalk = require('chalk');
const ora = require('ora');
const { promptForProjectOptions } = require('../prompts/projectPrompts');
const TemplateEngine = require('../generators/templateEngine');
const { downloadTemplate } = require('../utils/download');

async function create(projectName, options) {
  const spinner = ora('正在初始化项目
相关推荐
用户020742201752 小时前
从零到一:构建一个现代化的 React 组件库
后端
用户020742201752 小时前
从零到一:用 Rust 和 WebAssembly 构建高性能前端应用
后端
用户020742201752 小时前
从零到一:构建你的第一个智能合约并部署到以太坊测试网
后端
掘金者阿豪2 小时前
数据库的第一道防线:从金仓KES看企业级身份验证体系的设计逻辑
后端
颜酱2 小时前
从0到1实现LFU缓存:思路拆解+代码落地
javascript·后端·算法
武子康2 小时前
大数据-241 离线数仓 - 实战:电商核心交易数据模型与 MySQL 源表设计(订单/商品/品类/店铺/支付)
大数据·后端·mysql
SimonKing2 小时前
JetBrains 用户狂喜!这个 AI 插件让 IDE 原地进化成「智能编码助手」
java·后端·程序员
茶杯梦轩2 小时前
从零起步学习RabbitMQ || 第三章:RabbitMQ的生产者、Broker、消费者如何保证消息不丢失(可靠性)详解
分布式·后端·面试
小码哥_常2 小时前
别再乱加exclusion了!Maven依赖冲突有妙解
后端