在当今的前端开发中,脚手架工具已经成为项目启动的标配。从 create-react-app 到 vue-cli,这些工具极大地提升了开发效率。但你是否曾想过,这些工具是如何工作的?今天,我们将从零开始构建一个现代化的 Node.js 脚手架工具,深入探讨其核心原理和最佳实践。
为什么需要自定义脚手架?
你可能会有疑问:已经有那么多成熟的脚手架工具,为什么还要自己造轮子?
- 项目特定需求:每个团队都有自己的技术栈和项目结构
- 统一规范:确保所有项目遵循相同的代码规范和最佳实践
- 效率提升:自动化重复的配置工作,减少人为错误
- 知识沉淀:将团队的最佳实践固化到工具中
项目规划
我们的脚手架工具 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$/, '