如何搭建一个脚手架

36 阅读4分钟

背景

近期组长布置了一个技术任务,要标准化各工程的 readme 文件。定义好模板,具体值由使用方填充,比如该工程的产品经理主要是谁?谁是主要负责人?有什么注意事项等等。我想了下,也许可以用脚手架来帮我实现这个功能,使用方只要在原有项目跑我定义的命令,就能生成一份覆盖原工程 readme 的新定义好的 readme 文件,下面让我们来了解一下这样的脚手架该如何搭建。

一、什么是脚手架(Scaffold)

在工程领域,脚手架是:

👉 用于快速生成项目初始结构和基础配置的工具

它解决的问题是:

  • 不用每次手动搭目录
  • 不用重复配 webpack / vite / tsconfig
  • 不用重复写 README、eslint、gitignore
  • 保证项目风格统一、可维护

本质能力

脚手架 = 模板 + 参数化 + 自动生成 + 可扩展逻辑


二、一个标准脚手架应该具备什么能力

能力说明
项目模板vue/react/node/低代码引擎/组件库等
交互式创建通过命令选择类型、语言、包管理器
自动生成 README按项目类型输出不同 README
环境初始化自动装依赖、初始化 git
可扩展插件化 / 多模板
团队规范内置eslint/prettier/commitlint

三、脚手架技术选型

核心技术栈

功能推荐库
CLI 构建commander / cac
交互式问答inquirer / prompts
文件生成ejs / handlebars
拷贝模板fs-extra
下载模板download-git-repo
README 模板渲染ejs + markdown

四、一个“生成 README.md 的脚手架”完整实现示例

1️⃣ 初始化 CLI 项目

mkdir my-cli
cd my-cli
pnpm init
pnpm add commander inquirer fs-extra
目录结构:
my-cli/
├── bin/
│   └── index.js      # CLI 入口
├── lib/
│   └── readme.js
│   └── readmeGenerator.js
├── package.json

2️⃣ CLI 入口(bin/index.js)

 #!/usr/bin/env node

const program = require('commander');

// readme 相关脚本指令
program
  .command('readme')
  .description(`生成或更新项目的 README.md 文档`)
  .option('-d, --dir <dir>', '指定项目目录路径, 默认当前目录<projectRoot>/.')
  .option('-f, --force', '强制覆盖已存在的 README.md 文件')
  .option('--debug', '开启debug模式, 控制台会输出日志')
  .action((options) => {
    require('./lib/readme')(options);
  });

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

3️⃣ 生成 README 文件

// lib/readme

const path = require('path');
const debug = require('debug');
const chalk = require('chalk');
const fs = require('fs-extra');
const { log, error, done, warn } = require('../utils/logger'); // 日志相关
const { stopSpinner, logWithSpinner } = require('../utils/spinner'); // spinner 相关
const { resolvePkg } = require('../utils/pkg');
const ReadmeGenerator = require('./readmeGenerator');

async function readme(options) {
  // 当前命令执行所在目录
  const cwd = options.cwd || process.cwd();
  
  // 命令行选项默认值设置
  options.dir = options.dir || '.';
  options.force = options.force || false;
  
  debug('debug:readme:options')(options);
  
  const { dir } = { ...options };
  const inCurrent = dir === '.';
  const name = inCurrent ? path.basename(cwd) : dir;
  const targetDir = path.resolve(cwd, dir || '.');
  
  // 检查目录是否存在
  debug('debug:readme:targetDir')(targetDir);
  
  if (!fs.existsSync(targetDir)) {
    return error(`项目目录 ${chalk.cyan(targetDir)} 不存在!`);
  }
  
  // 读取项目 package.json
  const pkg = resolvePkg(targetDir);
  if (!pkg || !pkg.name) {
    return error(`项目目录 ${chalk.cyan(targetDir)} 中未找到 package.json 或 package.json 无效!`);
  }
  
  // 检查 README.md 是否已存在
  const readmePath = path.resolve(targetDir, 'README.md');
  const readmeExists = fs.existsSync(readmePath);
  
  if (readmeExists && !options.force) {
    warn(`README.md 文件已存在。使用 ${chalk.cyan('--force')} 选项可以强制覆盖。`);
    return;
  }
  
  if (readmeExists && options.force) {
    warn(`将覆盖已存在的 README.md 文件。`);
  }
  
  logWithSpinner('📝', `正在为项目 ${chalk.cyan(name)} 生成 README.md...`);
  
  try {
    const generator = new ReadmeGenerator(name, targetDir, pkg, options);
    await generator.generate();
    stopSpinner();
    done(`项目 ${chalk.cyan(name)} 的 README.md 生成成功!`);
    log(`文件路径: ${chalk.gray(readmePath)}`);
  } catch (e) {
    stopSpinner();
    error(`生成 README.md 失败: ${e.message}`);
    if (options.debug) {
      console.error(e);
    }
  }
}

module.exports = readme;

4️⃣ README 模板

// readmeGenerator.js
const path = require('path');
const fs = require('fs-extra');
const PackageManager = require('../utils/projectPackageManager');

/**
* README 生成器
*/
module.exports = class ReadmeGenerator {
  constructor(name, targetDir, pkg, options = {}) {
    this.name = name;
    this.targetDir = targetDir;
    this.pkg = pkg;
    this.options = options;
    this.packageManager = new PackageManager({ context: targetDir });
  }

  async generate() {
    const readmeContent = await this.generateReadmeContent();
    const readmePath = path.resolve(this.targetDir, 'README.md');
    await fs.writeFile(readmePath, readmeContent, 'utf-8');
  }

  async generateReadmeContent() {
    const sections = [];
    
    // 标题和描述
    sections.push(this.generateTitle());
    sections.push(this.generateDescription());
    sections.push(this.generateBadges());
    
    // 目录(可选)
    sections.push(this.generateTableOfContents());
    
    // 安装
    sections.push(this.generateInstallation());
    
    // 使用/快速开始
    sections.push(this.generateUsage());
    
    // 项目结构
    sections.push(this.generateProjectStructure());
    
    // 脚本命令
    sections.push(this.generateScripts());
    
    // 依赖
    sections.push(this.generateDependencies());
    
    // 开发
    sections.push(this.generateDevelopment());
    
    // 额外信息(产品负责人等)
    sections.push(this.generateExtra());
    
    // 贡献
    sections.push(this.generateContributing());
    
    // 许可证
    sections.push(this.generateLicense());
    
    // 作者信息
    sections.push(this.generateAuthor());
    
    return sections.filter(Boolean).join('\n\n');
  }

  generateTitle() {
    const name = this.pkg.name || this.name;
    return `# ${name}`;
  }

  generateDescription() {
    const description = this.pkg.description || '';
    if (description) {
      return description;
    }
    return '';
  }

  generateBadges() {
    const badges = [];
    const pkg = this.pkg;
    
    // npm 版本
    if (pkg.version) {
      badges.push(`![npm version](https://img.shields.io/npm/v/${pkg.name}.svg)`);
    }
    
    // license
    if (pkg.license) {
      badges.push(`![license](https://img.shields.io/npm/l/${pkg.name}.svg)`);
    }
    
    // node 版本
    if (pkg.engines && pkg.engines.node) {
      badges.push(`![node version](https://img.shields.io/node/v/${pkg.name}.svg)`);
    }
    
    if (badges.length > 0) {
      return badges.join(' ');
    }
    
    return '';
  }

  generateTableOfContents() {
    // 简单返回一个目录,用户可以根据需要自行调整
    return `## 📑 目录

- [安装](#安装)
- [快速开始](#快速开始)
- [项目结构](#项目结构)
- [可用脚本](#可用脚本)
- [开发指南](#开发指南)`;
  }

  generateInstallation() {
    const pkgManager = this.packageManager.bin || 'npm';
    const installCommand = pkgManager === 'yarn' ? 'yarn' : `${pkgManager} install`;
    
    return `## 📦 安装

```bash
${installCommand}
````;
  }

  generateUsage() {
    const pkg = this.pkg;
    const pkgManager = this.packageManager.bin || 'npm';
    
    // 如果有 bin 字段,说明是可执行文件
    if (pkg.bin) {
      let usage = '## 🚀 快速开始\n\n';
      let binName;
      if (typeof pkg.bin === 'string') {
        binName = pkg.name;
      } else if (typeof pkg.bin === 'object') {
        binName = Object.keys(pkg.bin)[0] || pkg.name;
      } else {
        binName = pkg.name;
      }
      usage += `安装后,你可以直接使用:\n\n`;
      usage += ````bash\n${binName}\n```\n`;
      return usage;
    }
    
    // 普通项目
    return `## 🚀 快速开始

```bash
# 开发模式
${pkgManager} ${pkgManager !== 'yarn' ? 'run ' : ''}serve

# 构建生产版本
${pkgManager} ${pkgManager !== 'yarn' ? 'run ' : ''}build
````;
  }

  generateProjectStructure() {
    // 尝试读取常见的目录结构
    const commonDirs = ['src', 'lib', 'dist', 'test', 'tests', 'docs', 'public', 'assets'];
    const existingDirs = commonDirs.filter(dir => {
      const dirPath = path.resolve(this.targetDir, dir);
      return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
    });
    
    if (existingDirs.length === 0) {
      return '';
    }
    
    let structure = '## 📁 项目结构\n\n';
    structure += '```\n';
    structure += `${this.name}/\n`;
    
    // 列出主要目录
    existingDirs.forEach(dir => {
      structure += `├── ${dir}/\n`;
    });
    
    structure += '└── package.json\n';
    structure += '```\n';
    
    structure += '\n主要目录说明:\n';
    existingDirs.forEach(dir => {
      const descriptions = {
        'src': '源代码目录',
        'lib': '库文件目录',
        'dist': '构建输出目录',
        'test': '测试文件目录',
        'tests': '测试文件目录',
        'docs': '文档目录',
        'public': '公共资源目录',
        'assets': '静态资源目录'
      };
      structure += `- `${dir}/`: ${descriptions[dir] || '相关文件目录'}\n`;
    });
    
    return structure;
  }

  generateScripts() {
    const scripts = this.pkg.scripts || {};
    if (Object.keys(scripts).length === 0) {
      return '';
    }
    
    const pkgManager = this.packageManager.bin || 'npm';
    const scriptDescriptions = {
      'serve': '启动开发服务器',
      'dev': '启动开发服务器',
      'start': '启动项目',
      'build': '构建生产版本',
      'build:daily': '构建日常环境版本',
      'build:beta': '构建测试环境版本',
      'build:prod': '构建生产环境版本',
      'test': '运行测试',
      'test:unit': '运行单元测试',
      'test:e2e': '运行端到端测试',
      'lint': '代码检查和格式化',
      'format': '格式化代码',
      'prepublishOnly': '发布前准备'
    };
    
    let scriptsSection = '## 📜 可用脚本\n\n';
    
    Object.keys(scripts).forEach(key => {
      const description = scriptDescriptions[key] || '执行脚本命令';
      scriptsSection += `### ${description}\n\n`;
      scriptsSection += ````bash\n`;
      scriptsSection += `${pkgManager} ${pkgManager !== 'yarn' ? 'run ' : ''}${key}\n`;
      scriptsSection += ````\n\n`;
    });
    
    return scriptsSection.trim();
  }

  generateDependencies() {
    const deps = this.pkg.dependencies || {};
    const devDeps = this.pkg.devDependencies || {};
    
    if (Object.keys(deps).length === 0 && Object.keys(devDeps).length === 0) {
      return '';
    }
    
    let depsSection = '## 📦 依赖\n\n';
    
    if (Object.keys(deps).length > 0) {
      depsSection += '### 生产依赖\n\n';
      depsSection += '| 依赖 | 版本 |\n';
      depsSection += '|------|------|\n';
      Object.entries(deps).slice(0, 10).forEach(([name, version]) => {
        depsSection += `| ${name} | ${version} |\n`;
      });
      if (Object.keys(deps).length > 10) {
        depsSection += `| ... | 共 ${Object.keys(deps).length} 个依赖 |\n`;
      }
      depsSection += '\n';
    }
    
    if (Object.keys(devDeps).length > 0) {
      depsSection += '### 开发依赖\n\n';
      depsSection += '| 依赖 | 版本 |\n';
      depsSection += '|------|------|\n';
      Object.entries(devDeps).slice(0, 10).forEach(([name, version]) => {
        depsSection += `| ${name} | ${version} |\n`;
      });
      if (Object.keys(devDeps).length > 10) {
        depsSection += `| ... | 共 ${Object.keys(devDeps).length} 个依赖 |\n`;
      }
    }
    
    return depsSection;
  }

  generateDevelopment() {
    return `## 🔧 开发指南

### 环境要求

${this.pkg.engines ? `- Node.js: ${this.pkg.engines.node || '>= 12.0.0'}` : '- Node.js: >= 12.0.0'}
- ${this.packageManager.bin === 'yarn' ? 'Yarn' : this.packageManager.bin === 'pnpm' ? 'pnpm' : 'npm'}

### 开发流程

1. 克隆项目
2. 安装依赖
3. 启动开发服务器
4. 开始开发`;
  }

  generateContributing() {
    // 检查是否有 CONTRIBUTING.md 文件
    const contributingPath = path.resolve(this.targetDir, 'CONTRIBUTING.md');
    if (fs.existsSync(contributingPath)) {
      return `## 🤝 贡献

请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解如何为项目做贡献。`;
    }
    
    return `## 🤝 贡献

欢迎提交 Issue 和 Pull Request!`;
  }

  generateLicense() {
    const license = this.pkg.license || '';
    if (license) {
      return `## 📄 许可证

本项目基于 [${license}](LICENSE) 许可证开源。`;
    }
    return '';
  }

  generateAuthor() {
    const pkgAuthor = this.pkg.author || '';
    const repository = this.pkg.repository || {};
    const homepage = this.pkg.homepage || '';
    
    // 处理 author 字段,可能是字符串或对象
    let author = '';
    if (typeof pkgAuthor === 'string') {
      author = pkgAuthor;
    } else if (typeof pkgAuthor === 'object' && pkgAuthor !== null) {
      if (pkgAuthor.name) {
        author = pkgAuthor.name;
        if (pkgAuthor.email) {
          author += ` <${pkgAuthor.email}>`;
        }
        if (pkgAuthor.url) {
          author += ` (${pkgAuthor.url})`;
        }
      }
    }
    
    // 处理 repository.url,可能是字符串或对象
    let repoUrl = '';
    if (typeof repository === 'string') {
      repoUrl = repository;
    } else if (typeof repository === 'object' && repository !== null && repository.url) {
      repoUrl = repository.url;
    }
    
    let authorSection = '';
    
    if (author || repoUrl || homepage) {
      authorSection = '## 👤 作者\n\n';
      
      if (author) {
        authorSection += `- ${author}\n`;
      }
      
      if (repoUrl) {
        authorSection += `- 仓库: ${repoUrl}\n`;
      }
      
      if (homepage) {
        authorSection += `- 主页: ${homepage}\n`;
      }
    }
    
    return authorSection;
  }

  generateExtra() {
    // 定义可配置的字段模板
    const extraFields = {
      productOwner: {
        label: '主要产品负责人',
        key: 'productOwner'
      },
      techLead: {
        label: '技术负责人',
        key: 'techLead'
      },
      projectStartDate: {
        label: '项目开始时间',
        key: 'projectStartDate'
      },
      projectDescription: {
        label: '项目详细描述',
        key: 'projectDescription'
      },
      contactEmail: {
        label: '联系邮箱',
        key: 'contactEmail'
      }
    };

    // 从 package.json 的 hs.readme 字段或 options 中读取值
    const hsReadme = this.pkg.hs && this.pkg.hs.readme ? this.pkg.hs.readme : {};
    const optionsReadme = this.options.readme || {};
    const extraData = { ...hsReadme, ...optionsReadme };

    // 收集有值的字段
    const filledFields = [];
    Object.keys(extraFields).forEach(fieldKey => {
      const field = extraFields[fieldKey];
      const value = extraData[field.key];
      filledFields.push({
        label: field.label,
        value: value || ''
      });
    });
    
    // 生成额外信息部分
    let extraSection = '## 📋 项目信息\n\n';
    
    filledFields.forEach(field => {
      extraSection += `- **${field.label}**: ${field.value}\n`;
    });

    // 添加提示信息,告诉用户如何配置这些字段
    extraSection += '\n> 💡 提示:你可以在 `package.json` 的 `hs.readme` 字段中配置这些信息,例如:\n';
    extraSection += '> ```json\n';
    extraSection += '> {\n';
    extraSection += '>   "hs": {\n';
    extraSection += '>     "readme": {\n';
    extraSection += '>       "productOwner": "产品负责人姓名",\n';
    extraSection += '>       "techLead": "技术负责人姓名",\n';
    extraSection += '>       "projectStartDate": "2024-01-01",\n';
    extraSection += '>       "projectDescription": "项目详细描述",\n';
    extraSection += '>       "contactEmail": "contact@example.com"\n';
    extraSection += '>     }\n';
    extraSection += '>   }\n';
    extraSection += '> }\n';
    extraSection += '> ```\n';

    return extraSection;
  }
};