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

3 阅读1分钟

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

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

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

  1. 项目特定需求:每个团队都有自己的技术栈和项目结构
  2. 统一规范:确保所有项目遵循相同的代码规范和最佳实践
  3. 效率提升:自动化重复的配置工作,减少人为错误
  4. 知识沉淀:将团队的最佳实践固化到工具中

项目规划

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

  • 交互式命令行界面
  • 模板管理功能
  • 动态文件生成
  • 依赖自动安装
  • Git 仓库初始化

技术栈选择

# 核心依赖
commander - 命令行参数解析
inquirer - 交互式命令行界面
chalk - 终端字符串样式
ora - 优雅的加载动画
download-git-repo - Git 仓库下载
handlebars - 模板引擎

项目结构

让我们先创建项目的基本结构:

modern-cli/
├── bin/
│   └── cli.js          # 命令行入口
├── lib/
│   ├── core/
│   │   ├── create.js   # 创建项目逻辑
│   │   └── init.js     # 初始化逻辑
│   ├── utils/
│   │   ├── log.js      # 日志工具
│   │   └── file.js     # 文件操作工具
│   └── templates/      # 模板目录
├── package.json
└── README.md

核心实现

1. 命令行入口

首先,让我们创建命令行入口文件:

#!/usr/bin/env node
// bin/cli.js

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

// 设置版本信息
program.version(pkg.version, '-v, --version', '显示版本号');

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

// 初始化命令
program
  .command('init')
  .description('在当前目录初始化项目')
  .action(async () => {
    const init = require('../lib/core/init');
    await init();
  });

// 模板列表命令
program
  .command('list')
  .description('查看可用模板列表')
  .action(() => {
    const templates = require('../lib/config/templates');
    console.log('可用模板:');
    templates.forEach(tpl => {
      console.log(`  ${tpl.name} - ${tpl.description}`);
    });
  });

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

2. 交互式命令行界面

使用 inquirer 创建友好的交互界面:

// lib/core/create.js
const inquirer = require('inquirer');
const chalk = require('chalk');
const path = require('path');
const fs = require('fs-extra');
const { downloadTemplate } = require('../utils/download');
const { compileTemplate } = require('../utils/template');

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: `目录 ${chalk.cyan(projectName)} 已存在,请选择操作:`,
          choices: [
            { name: '覆盖', value: 'overwrite' },
            { name: '合并', value: 'merge' },
            { name: '取消', value: false }
          ]
        }
      ]);
      
      if (!action) {
        return;
      } else if (action === 'overwrite') {
        console.log(`\n正在删除 ${chalk.cyan(targetDir)}...`);
        await fs.remove(targetDir);
      }
    }
  }
  
  // 收集项目信息
  const answers = await inquirer.prompt([
    {
      type: 'list',
      name: 'template',
      message: '请选择项目模板:',
      choices: [
        { name: 'React + TypeScript', value: 'react-ts' },
        { name: 'Vue 3 + TypeScript', value: 'vue3-ts' },
        { name: 'Node.js + TypeScript', value: 'node-ts' }
      ]
    },
    {
      type: 'input',
      name: 'description',
      message: '项目描述:',
      default: 'A modern web application'
    },
    {
      type: 'input',
      name: 'author',
      message: '作者:',
      default: ''
    },
    {
      type: 'list',
      name: 'packageManager',
      message: '包管理器:',
      choices: ['npm', 'yarn', 'pnpm']
    }
  ]);
  
  // 创建项目目录
  await fs.ensureDir(targetDir);
  
  // 下载模板
  console.log(`\n正在下载模板 ${chalk.cyan(answers.template)}...`);
  await downloadTemplate(answers.template, targetDir);
  
  // 编译模板
  console.log('正在生成项目文件...');
  await compileTemplate(targetDir, {
    projectName,
    ...answers
  });
  
  // 安装依赖
  console.log('正在安装依赖...');
  await installDependencies(targetDir, answers.packageManager);
  
  // 初始化 Git
  console.log('正在初始化 Git 仓库...');
  await initGit(targetDir);
  
  console.log(chalk.green(`\n✅ 项目 ${projectName} 创建成功!`));
  console.log('\n接下来可以:');
  console.log(chalk.cyan(`  cd ${projectName}`));
  console.log(chalk.cyan(`  ${answers.packageManager} run dev`));
}

module.exports = create;

3. 模板下载与编译

// lib/utils/download.js
const download = require('download-git-repo');
const { promisify } = require('util');
const ora = require('ora');

const downloadGitRepo = promisify(download);

// 模板仓库映射
const templateRepos = {
  'react-ts': 'github:facebook/create-react-app#main',
  'vue3-ts': 'github:vuejs/vue-next#master',
  'node-ts': 'github:nodejs/node#main'
};

async function downloadTemplate(templateName, targetDir) {
  const spinner = ora('下载模板中...').start();
  
  try {
    const repoUrl = templateRepos[templateName];
    if (!repoUrl) {
      throw new Error(`模板 ${templateName} 不存在`);
    }
    
    await downloadGitRepo(repoUrl, targetDir, { clone: true });
    spinner.succeed('模板下载完成');
  } catch (error) {
    spinner.fail('模板下载失败');
    throw error;
  }
}

module.exports = { downloadTemplate };

4. 模板编译引擎

// lib/utils/template.js
const fs = require('fs-extra');
const path = require('path');
const handlebars = require('handlebars');

// 注册自定义 helper
handlebars.registerHelper('if_eq', function(a, b, opts) {
  if (a === b) {
    return opts.fn(this);
  } else {
    return opts.inverse(this);
  }
});

handlebars.registerHelper('to_lower', function(str) {
  return str.toLowerCase();
});

async function compileTemplate(targetDir, data) {
  const files = await getAllFiles(targetDir);
  
  for (const file of files) {
    // 跳过 node_modules 和 .git 目录
    if (file.includes('node_modules') || file.includes('.git')) {
      continue;
    }
    
    const content = await fs.readFile(file, 'utf8');
    
    // 检查是否是模板文件
    if (isTemplateFile(file, content)) {
      const template = handlebars.compile(content);
      const result = template(data);
      await fs.writeFile(file, result, 'utf8');
      
      // 重命名 .hbs 文件
      if (file.endsWith('.hbs')) {
        const newPath = file.replace(/\.hbs$/, '