在当今的前端开发中,脚手架工具已经成为项目启动的标配。从 create-react-app 到 Vue CLI,再到 Next.js 的 create-next-app,这些工具极大地提升了开发效率。但你是否想过,这些工具是如何工作的?今天,我们将从零开始构建一个现代化的 Node.js 脚手架工具,不仅生成文件,还要实现模板管理、用户交互和自动化配置等高级功能。
为什么需要自定义脚手架?
你可能会有疑问:已经有那么多成熟的脚手架工具了,为什么还要自己造轮子?原因有三:
- 项目特定需求:每个团队的技术栈和规范不同,通用工具无法完全满足
- 学习价值:理解脚手架的工作原理能提升你的 Node.js 和工程化能力
- 灵活控制:自定义脚手架可以集成团队特有的工作流和最佳实践
项目架构设计
我们的脚手架工具将包含以下核心模块:
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('正在初始化项目