搭建一个脚手架,本质上是在做一件很实用的事:把项目里那些重复、标准化、容易出错的初始化工作,提前封装成一套可复用的流程。
当一个团队反复创建同类项目时,脚手架能统一目录结构、开发规范、初始化逻辑和模板来源,让“新建项目”这件事变成一个简单命令。
这篇文章会带你从零实现一个基础版的 ben-cli,支持:
本文会用到这些依赖:
axios
chalk
commander
download-git-repo
fs-extra
figlet
inquirer
ora
commander:解析命令行参数inquirer:交互式询问用户axios:请求模板仓库接口download-git-repo:下载 Git 仓库模板fs-extra:文件与目录操作ora:命令行 loading 效果chalk:控制台彩色输出figlet:输出 ASCII 文字,增强启动效果先创建一个基础目录结构:
mkdir ben-cli
cd ben-cli
npm init -y
mkdir bin lib
touch bin/cli.js
touch lib/create.js
touch lib/creator.js
touch lib/request.js
touch lib/util.js
结构如下:
ben-cli
├─ bin
│ └─ cli.js
├─ lib
│ ├─ create.js
│ ├─ creator.js
│ ├─ request.js
│ └─ util.js
├─ package.json
└─ README.md
在 package.json 中声明 bin,这样安装到全局后就能直接执行命令。
{
"name": "ben-cli",
"version": "1.0.0",
"description": "A simple scaffold CLI",
"main": "index.js",
"bin": {
"ben": "./bin/cli.js"
},
"files": [
"bin",
"lib"
],
"keywords": [
"cli",
"scaffold",
"ben-cli"
],
"author": "jsben",
"license": "MIT",
"dependencies": {
"axios": "^0.24.0",
"chalk": "^4.1.1",
"commander": "^8.3.0",
"download-git-repo": "^3.0.2",
"figlet": "^1.8.1",
"fs-extra": "^10.0.0",
"inquirer": "^8.2.0",
"ora": "^5.4.0"
}
}
然后执行:
npm link
这样你就可以在命令行里直接输入 ben 来调试了。
bin/cli.js 是整个脚手架的入口。
#! /usr/bin/env node
const program = require('commander');
const chalk = require('chalk');
const figlet = require('figlet');
program
.name('ben')
.usage('<command> [options]');
program
.version(`ben-cli@${require('../package.json').version}`)
.option('-v, --version', 'output the current version');
program
.command('create <app-name>')
.description('create a new project')
.option('-f, --force', 'overwrite target directory if it exists')
.action((name, options) => {
require('../lib/create')(name, options);
});
program
.command('config [value]')
.description('inspect and modify the config')
.option('-g, --get <path>', 'get value from option')
.option('-s, --set <path> <value>', 'set option value')
.option('-d, --delete <path>', 'delete option value')
.action((value, options) => {
console.log(value, options);
});
program.on('--help', () => {
console.log('');
console.log(`Run ${chalk.cyan('ben <command> --help')} for detailed usage.`);
});
if (!process.argv.slice(2).length) {
console.log(
figlet.textSync('BEN CLI', {
horizontalLayout: 'default',
verticalLayout: 'default',
width: 80,
whitespaceBreak: true,
})
);
}
program.parse(process.argv);
这里定义了两个命令:
ben create <app-name>:创建项目ben config:查看或修改配置当用户执行 ben create my-app,我们先判断目标目录是否已存在。
lib/create.js:
const path = require('path');
const fs = require('fs-extra');
const inquirer = require('inquirer');
const Creator = require('./creator');
module.exports = async function (name, options) {
const cwd = process.cwd();
const targetDir = path.join(cwd, name);
if (fs.existsSync(targetDir)) {
if (options.force) {
await fs.remove(targetDir);
} else {
const { action } = await inquirer.prompt([
{
name: 'action',
type: 'list',
message: 'Target directory already exists. Pick an action:',
choices: [
{ name: 'Overwrite', value: 'overwrite' },
{ name: 'Cancel', value: false }
]
}
]);
if (!action) return;
if (action === 'overwrite') {
console.log('\r\nRemoving...');
await fs.remove(targetDir);
}
}
}
const creator = new Creator(name, targetDir);
creator.create();
};
这一步的核心是:
避免覆盖用户已有文件,同时给出清晰的交互选择。
脚手架不应该把模板写死,而是从远程仓库动态获取,这样后续维护更灵活。
lib/request.js:
const axios = require('axios');
axios.interceptors.response.use((res) => res.data);
async function fetchRepoList() {
return axios.get('https://gitee.com/api/v5/orgs/ben-cli/repos');
}
async function fetchTagList(repo) {
return axios.get(`https://gitee.com/api/v5/repos/ben-cli/${repo}/tags`);
}
module.exports = {
fetchRepoList,
fetchTagList,
};
这里假设模板仓库托管在 Gitee 的组织下。实际项目里,你也可以替换成 GitHub、GitLab 或公司私有仓库。
网络请求和下载模板都比较耗时,最好加上 loading 提示。
lib/util.js:
const ora = require('ora');
async function wrapLoading(fn, message, ...args) {
const spinner = ora(message);
spinner.start();
try {
const result = await fn(...args);
spinner.succeed();
return result;
} catch (error) {
spinner.fail('Operation failed');
throw error;
}
}
module.exports = {
wrapLoading,
};
lib/creator.js 负责整个模板选择、下载和生成过程。
const Inquirer = require('inquirer');
const { fetchRepoList, fetchTagList } = require('./request');
const { wrapLoading } = require('./util');
const downloadGitRepo = require('download-git-repo');
const util = require('util');
const path = require('path');
const chalk = require('chalk');
class Creator {
constructor(name, targetDir) {
this.name = name;
this.targetDir = targetDir;
this.downloadGitRepo = util.promisify(downloadGitRepo);
}
async fetchRepo() {
let repos = await wrapLoading(fetchRepoList, 'waiting fetch template');
if (!repos || !repos.length) return;
repos = repos.map((item) => item.name);
const { repo } = await Inquirer.prompt({
name: 'repo',
type: 'list',
choices: repos,
message: 'Please choose a template to create project'
});
return repo;
}
async fetchTag(repo) {
let tags = await wrapLoading(fetchTagList, 'waiting fetch tag', repo);
if (!tags || !tags.length) return;
tags = tags.map((item) => item.name);
const { tag } = await Inquirer.prompt({
name: 'tag',
type: 'list',
choices: tags,
message: 'Please choose a tag to create project'
});
return tag;
}
async download(repo, tag) {
const requestUrl = `ben-cli/${repo}${tag ? '#' + tag : ''}`;
await wrapLoading(
this.downloadGitRepo,
'waiting download template',
requestUrl,
path.resolve(process.cwd(), this.targetDir)
);
}
async create() {
const repo = await this.fetchRepo();
if (!repo) return;
const tag = await this.fetchTag(repo);
await this.download(repo, tag);
console.log(`\r\nSuccessfully created project ${chalk.cyan(this.name)}`);
console.log(`\r\n cd ${chalk.cyan(this.name)}`);
console.log(' npm install');
console.log(' npm run dev\r\n');
}
}
module.exports = Creator;
这一层把整个流程串起来了:
用户执行命令后,脚手架的执行链路大致如下:
ben create my-app
-> cli.js 解析命令
-> create.js 检测目录
-> creator.js 获取仓库列表
-> 用户选择模板
-> 用户选择版本
-> 下载模板
-> 生成完成提示
这就是一个最小可用脚手架的核心骨架。
如果你想把这个脚手架继续做得更像一个成熟工具,还可以继续补这些能力:
init、add、config这篇文章里的实现是按“职责拆分”的思路来组织的:
bin/cli.js 只负责命令入口和参数解析lib/create.js 只负责创建前的目录检查和用户确认lib/creator.js 只负责模板选择、下载和生成lib/request.js 只负责接口请求lib/util.js 只负责通用工具这样做的好处是,后面如果你要替换模板源、加缓存、加更多命令,改动会很集中,不会把逻辑全挤在一个文件里。
脚手架真正好不好用,往往不在正常流程,而在异常流程。
如果仓库接口返回空数组,应该直接给出提示,而不是继续让用户选择。
接口超时、仓库地址错误、网络波动都可能导致请求失败。这里最好给出明确错误信息,告诉用户是“获取模板失败”还是“下载失败”。
如果本地目标路径不可写,应该提前退出,并提示用户检查目录权限。
交互式脚手架经常会遇到用户取消操作的情况,这时候要优雅退出,不要留下半成品目录。
一个脚手架不仅要能跑,还要能发、能装、能用。
npm link
ben create demo-app
npm login
npm publish
npm install -g ben-cli
ben create demo-app
如果后面你给脚手架加了新功能,建议用语义化版本管理:
patch:修复问题minor:增加功能major:破坏性更新如果你希望它更接近生产可用,还可以继续补这些细节:
.gitignore、.env 等初始化配置脚手架项目的价值,不只是在于“能不能初始化项目”,更在于它是否能把团队的约定沉淀下来,把重复劳动自动化。
从目录检测、命令解析,到模板拉取、版本选择,这些步骤串起来之后,一个真正实用的 CLI 就成型了。