引言
在当今的前端开发中,脚手架工具已经成为项目启动的标准配置。无论是 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