在当今的前端开发中,脚手架工具已经成为项目启动的标配。无论是 Vue CLI、Create React App 还是 Vite,这些工具都极大地提升了开发效率。但你是否曾好奇这些工具背后的工作原理?本文将带你从零开始,构建一个现代化的 Node.js 脚手架工具,深入探讨其核心原理、架构设计和最佳实践。
为什么需要自定义脚手架?
在开始之前,我们先思考一个问题:为什么我们需要自己构建脚手架工具?
- 统一项目规范:确保团队所有项目遵循相同的目录结构、代码规范和工具配置
- 提升开发效率:自动化重复的初始化工作,让开发者专注于业务逻辑
- 技术栈标准化:固化最佳实践,避免每次项目都要重新配置
- 知识沉淀:将团队经验转化为可复用的工具
脚手架的核心架构
一个完整的脚手架工具通常包含以下几个核心模块:
├── 命令行接口 (CLI)
├── 模板系统
├── 交互式问答
├── 文件操作
├── 依赖管理
└── 发布与更新
让我们一步步实现这些模块。
1. 项目初始化与 CLI 设计
首先,我们创建一个新的 Node.js 项目:
mkdir my-cli && cd my-cli
npm init -y
1.1 配置 package.json
{
"name": "@myorg/create-app",
"version": "1.0.0",
"description": "Modern project scaffolding tool",
"bin": {
"create-app": "bin/cli.js"
},
"files": [
"bin",
"lib",
"templates"
],
"scripts": {
"dev": "node bin/cli.js",
"lint": "eslint .",
"test": "jest"
},
"dependencies": {
"commander": "^11.0.0",
"inquirer": "^9.2.10",
"chalk": "^5.2.0",
"ora": "^7.0.1",
"fs-extra": "^11.1.1",
"download-git-repo": "^3.0.2",
"update-notifier": "^6.0.2"
},
"engines": {
"node": ">=14.0.0"
}
}
1.2 实现 CLI 入口文件
创建 bin/cli.js:
#!/usr/bin/env node
const { program } = require('commander');
const { version } = require('../package.json');
const createProject = require('../lib/create');
// 设置 CLI 基本信息
program
.name('create-app')
.description('A modern scaffolding tool for web applications')
.version(version, '-v, --version')
.usage('<project-name> [options]');
// 添加项目名称参数
program
.argument('<project-name>', 'name of the project to create')
.option('-t, --template <template>', 'specify a template (react, vue, node)')
.option('-f, --force', 'overwrite target directory if it exists')
.option('-g, --git', 'initialize git repository')
.option('-s, --skip-install', 'skip package installation')
.action(async (projectName, options) => {
try {
await createProject(projectName, options);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
});
// 添加模板列表命令
program
.command('list')
.description('list all available templates')
.action(() => {
console.log('Available templates:');
console.log(' react - React + TypeScript + Vite');
console.log(' vue - Vue 3 + TypeScript + Vite');
console.log(' node - Node.js + TypeScript + Express');
console.log(' library - TypeScript library template');
});
// 解析命令行参数
program.parse(process.argv);
2. 核心创建逻辑实现
创建 lib/create.js:
const path = require('path');
const fs = require('fs-extra');
const chalk = require('chalk');
const ora = require('ora');
const inquirer = require('inquirer');
const { execSync } = require('child_process');
class ProjectCreator {
constructor(projectName, options) {
this.projectName = projectName;
this.options = options;
this.targetDir = path.resolve(process.cwd(), projectName);
this.templateDir = path.resolve(__dirname, '../templates');
}
async run() {
// 1. 检查目标目录
await this.checkTargetDir();
// 2. 收集用户输入
await this.promptForOptions();
// 3. 创建项目
await this.createProject();
// 4. 安装依赖
if (!this.options.skipInstall) {
await this.installDependencies();
}
// 5. 初始化 Git
if (this.options.git) {
await this.initGit();
}
// 6. 显示完成信息
this.showSuccessMessage();
}
async checkTargetDir() {
if (fs.existsSync(this.targetDir)) {
if (this.options.force) {
const spinner = ora('Removing existing directory...').start();
await fs.remove(this.targetDir);
spinner.succeed('Directory removed');
} else {
const { action } = await inquirer.prompt([
{
name: 'action',
type: 'list',
message: `Target directory ${chalk.cyan(this.targetDir)} already exists. Pick an action:`,
choices: [
{ name: 'Overwrite', value: 'overwrite' },
{ name: 'Merge', value: 'merge' },
{ name: 'Cancel', value: false }
]
}
]);
if (!action) {
throw new Error('Operation cancelled');
} else if (action === 'overwrite') {
const spinner = ora('Removing existing directory...').start();
await fs.remove(this.targetDir);
spinner.succeed('Directory removed');
}
}
}
}
async promptForOptions() {
// 如果命令行没有指定模板,则询问用户
if (!this.options.template) {
const answers = await inquirer.prompt([
{
name: 'template',
type: 'list',
message: 'Please choose a project template:',
choices: [
{ name: 'React + TypeScript + Vite', value: 'react' },
{ name: 'Vue 3 + TypeScript + Vite', value: 'vue' },
{ name: 'Node.js + TypeScript + Express', value: 'node' },
{ name: 'TypeScript Library', value: 'library' }
]
},
{
name: 'packageManager',
type: 'list',
message: 'Select package manager:',
choices: [
{ name: 'npm', value: 'npm' },
{ name: 'yarn', value: 'yarn' },
{ name: 'pnpm', value: 'pnpm' }
]
}
]);
this.options.template = answers.template;
this.options.packageManager = answers.packageManager;
}
}
async createProject() {
const spinner = ora('Creating project...').start();
try {
// 创建项目目录
await fs.ensureDir(this.targetDir);
// 复制模板文件
const templatePath = path.join(this.templateDir, this.options.template);
if (!fs.existsSync(templatePath)) {
throw new Error(`Template ${this.options.template} not found`);
}
await fs.copy(templatePath, this.targetDir);
// 读取 package.json 并更新项目名称
const packageJsonPath = path.join(this.targetDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
packageJson.name = this.projectName;
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
spinner.succeed('Project created successfully');
} catch (error) {
spinner.fail('Failed to create project');
throw error;
}
}
async installDependencies() {
const spinner = ora('Installing dependencies...').start();
try {
const commands = {
npm: 'npm install',
yarn: 'yarn install',
pnpm: 'pnpm install'
};
execSync(commands[this.options.packageManager || 'npm'], {
cwd: this.targetDir,
stdio: 'inherit'
});
spinner.succeed('Dependencies installed');
} catch (error) {
spinner.fail('Failed to install dependencies');
// 不抛出错误,让用户手动安装
}
}
async initGit