在当今的前端开发中,脚手架工具已经成为项目启动的标配。从 create-react-app 到 vue-cli,这些工具极大地提升了开发效率。但你是否曾想过,这些工具背后是如何工作的?本文将带你从零开始,构建一个功能完整的现代化 Node.js 脚手架工具,深入探讨其核心原理和最佳实践。
为什么需要自定义脚手架?
你可能会有疑问:已经有那么多成熟的脚手架工具,为什么还要自己造轮子?
- 项目特定需求:公司内部项目有特定的技术栈、目录结构和配置规范
- 统一开发规范:确保团队所有项目遵循相同的代码规范和工程化标准
- 学习价值:深入理解现代前端工程化工具链的工作原理
- 灵活定制:根据业务需求快速调整模板和配置
核心架构设计
一个完整的脚手架工具通常包含以下几个核心模块:
├── bin/ # CLI入口
├── lib/
│ ├── commands/ # 命令实现
│ ├── templates/ # 项目模板
│ ├── utils/ # 工具函数
│ └── prompts/ # 交互式问题
├── package.json
└── README.md
实现步骤详解
1. 初始化项目结构
首先创建项目目录并初始化 package.json:
mkdir my-cli && cd my-cli
npm init -y
编辑 package.json,添加必要的配置:
{
"name": "my-cli",
"version": "1.0.0",
"description": "A modern project scaffolding tool",
"bin": {
"my-cli": "./bin/cli.js"
},
"files": ["bin", "lib"],
"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"
}
}
2. 创建 CLI 入口文件
创建 bin/cli.js 作为命令行入口:
#!/usr/bin/env node
const { program } = require('commander');
const { version } = require('../package.json');
// 设置基本信息
program
.name('my-cli')
.description('A modern project scaffolding tool')
.version(version, '-v, --version');
// 创建项目命令
program
.command('create <project-name>')
.description('Create a new project')
.option('-t, --template <template>', 'Specify project template')
.option('-f, --force', 'Force overwrite existing directory')
.action(async (projectName, options) => {
const create = require('../lib/commands/create');
await create(projectName, options);
});
// 列出可用模板命令
program
.command('list')
.description('List all available templates')
.action(() => {
const list = require('../lib/commands/list');
list();
});
// 解析命令行参数
program.parse(process.argv);
3. 实现项目创建命令
创建 lib/commands/create.js:
const path = require('path');
const fs = require('fs-extra');
const inquirer = require('inquirer');
const chalk = require('chalk');
const ora = require('ora');
const { downloadTemplate } = require('../utils/download');
const { installDependencies } = require('../utils/install');
module.exports = 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: `Directory ${chalk.cyan(projectName)} already exists. Choose an action:`,
choices: [
{ name: 'Overwrite', value: 'overwrite' },
{ name: 'Merge', value: 'merge' },
{ name: 'Cancel', value: false }
]
}
]);
if (!action) {
return;
} else if (action === 'overwrite') {
console.log(`\nRemoving ${chalk.cyan(projectName)}...`);
await fs.remove(targetDir);
}
}
}
// 收集项目信息
const answers = await inquirer.prompt([
{
name: 'projectName',
type: 'input',
message: 'Project name:',
default: projectName,
validate: (input) => {
if (/^[a-z0-9-]+$/.test(input)) return true;
return 'Project name must be lowercase letters, numbers, and hyphens only';
}
},
{
name: 'description',
type: 'input',
message: 'Project description:',
default: 'A new project created with my-cli'
},
{
name: 'author',
type: 'input',
message: 'Author:'
},
{
name: 'template',
type: 'list',
message: 'Select a template:',
choices: [
{ name: 'React + TypeScript', value: 'react-ts' },
{ name: 'Vue 3 + TypeScript', value: 'vue3-ts' },
{ name: 'Node.js + TypeScript', value: 'node-ts' },
{ name: 'Vanilla JavaScript', value: 'vanilla' }
],
default: options.template || 'react-ts'
},
{
name: 'packageManager',
type: 'list',
message: 'Select package manager:',
choices: [
{ name: 'npm', value: 'npm' },
{ name: 'yarn', value: 'yarn' },
{ name: 'pnpm', value: 'pnpm' }
],
default: 'npm'
}
]);
// 创建项目目录
console.log(`\nCreating project in ${chalk.green(targetDir)}...`);
await fs.ensureDir(targetDir);
// 下载模板
const spinner = ora('Downloading template...').start();
try {
await downloadTemplate(answers.template, targetDir);
spinner.succeed('Template downloaded successfully!');
} catch (error) {
spinner.fail('Failed to download template');
console.error(chalk.red(error.message));
process.exit(1);
}
// 更新 package.json
const packageJsonPath = path.join(targetDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
Object.assign(packageJson, {
name: answers.projectName,
description: answers.description,
author: answers.author,
version: '1.0.0'
});
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
// 安装依赖
const installSpinner = ora('Installing dependencies...').start();
try {
await installDependencies(targetDir, answers.packageManager);
installSpinner.succeed('Dependencies installed successfully!');
} catch (error) {
installSpinner.fail('Failed to install dependencies');
console.log(chalk.yellow('You can install dependencies manually later.'));
}
// 显示成功信息
console.log(`\n${chalk.green('✓')} Project ${chalk.cyan(answers.projectName)} created successfully!\n`);
console.log(chalk.bold('Next steps:'));
console.log(` cd ${projectName}`);
if (!answers.packageManager || answers.packageManager === 'npm') {
console.log(' npm run dev');
} else {
console.log(` ${answers.packageManager} run dev`);
}
console.log('\nHappy coding! 🎉\n');
};
4. 实现模板下载工具
创建 lib/utils/download.js:
const { promisify } = require('util');
const download = promisify(require('download-git-repo'));
const chalk = require('chalk');
// 模板仓库映射
const templateRepos = {
'react-ts': 'github:facebook/create-react-app#main',
'vue3-ts': 'github:vuejs/create-vue#main',
'node-ts': 'github:tsconfig/bases#main',
'vanilla': 'github:vercel/vanilla-template#main'
};
module.exports.downloadTemplate = async function(template, targetDir) {
const repo = templateRepos[template];
if (!repo) {
throw new Error