前端脚手架:从零搭建 CLI 工具
本文以自研 CLI
jupiter为例,介绍如何从零搭建一个前端脚手架。代码部分可作为学习参考,现代新项目建议直接用create-vite/create-next-app/create-nuxt等官方脚手架;自研 CLI 适合多项目统一和内部约定封装的场景。
一、避坑指南
90% 项目不需要自研脚手架:
- 单项目 / 标准技术栈 → 直接用官方脚手架(
pnpm create vite) - 多项目 / 内部统一约定 → 才有自研价值
- 早期自研 + 维护不动 = 比直接抄模板还糟糕
- 单项目 / 标准技术栈 → 直接用官方脚手架(
库选型跟紧 ESM 时代:
库 状态 处理 chalk@4维护中(CJS) 仍可用,但新项目用 v5+(ESM) chalk@5+ESM only 配 "type": "module"download-git-repo已停更(最后更新 2018) 改用 gigetinquirer维护中但功能陈旧 改用 @inquirer/prompts(现代化、TypeScript 原生)picocolors轻量替代 chalk 13x 体积小,CLI 工具首选 commander持续维护 ✅ 仍然推荐 必须
npm link本地调试:# 项目目录下
npm link # 创建全局软链接
jupiter -v # 终端直接验证
npm unlink jupiter # 不需要时断开#!/usr/bin/env node这一行必加,系统靠它找到 node 解释器。版本号必须从
package.json读:// ❌ 硬编码,每次发布要改
program.version("1.0.0");
// ✅ 从 package.json 读,永远不脱节
program.version(require("../package.json").version);下载模板要支持「覆盖 / 重试 / 失败兜底」:
// ❌ 直接下,不做错误处理
download(template, path, () => {});
// ✅ 清空目录 → 异步下载 → 失败退出 + spinner 提示
await fse.remove(tplPath);
const spinner = ora("Downloading...").start();
try {
await download(template, path, { clone: true });
spinner.succeed("Done");
} catch (err) {
spinner.fail(err.message);
process.exit(1);
}检查更新用
update-notifier:通过 npm 仓库的name+version自动对比,不要自己 fetch npm registry。
二、自研 CLI 的价值判断
2.1 什么时候该自研?
| 场景 | 自研价值 | 理由 |
|---|---|---|
| 多项目共用模板(10+ 项目) | ⭐⭐⭐⭐⭐ | 抽公共部分,一次维护 |
| 内部私有协议(特殊打包、私有仓库) | ⭐⭐⭐⭐⭐ | 必须封装 |
| 强业务约定(强制目录结构、命名规范) | ⭐⭐⭐⭐ | 团队 SOP 落地 |
| 微前端统一(基座 + 子应用模板) | ⭐⭐⭐⭐ | 子应用模板统一 |
| 单项目 / 标准技术栈 | ⭐ | 直接用 create-vite 即可 |
| 想「学一下脚手架怎么搭」 | ⭐⭐ | 看完本文,不要真上线 |
2.2 自研 vs 现成脚手架
| 维度 | 自研 CLI | create-vite / create-next-app |
|---|---|---|
| 启动成本 | 高(设计 + 开发 + 维护) | 零(一行命令) |
| 灵活性 | 完全可控 | 受限于官方模板 |
| 维护成本 | 长期(跟着 TS / 框架升级) | 零(官方维护) |
| 内部集成 | ✅ 强(私有包、内网模板) | 弱(要 hack 模板) |
| 适用团队 | 10 人以上 / 多业务线 | 任何规模 |
决策原则:先穷尽官方 / 社区方案,再考虑自研。自研的 ROI 临界点一般在「5+ 项目共用」。
三、技术选型(2025+ 推荐)
| 职责 | 首推 | 备选/场景 | 已被淘汰 |
|---|---|---|---|
| 命令解析 | commander | cac(轻量) | — |
| 交互 | @inquirer/prompts(v8+,TS 原生) | inquirer(兼容老项目) | — |
| 终端颜色 | picocolors(13x 小于 chalk) | chalk@5+(ESM) | chalk@4(CJS) |
| 进度动画 | ora | listr2(多任务编排) | — |
| 模板下载 | giget(download-git-repo 替代) | 直接用 degit | download-git-repo(停更) |
| 更新检查 | update-notifier | — | 自己 fetch npm |
| 日志符号 | log-symbols | 直接用 picocolors | — |
| 模板引擎 | handlebars | ejs / mustache | 字符串拼接 |
| 文件系统 | fs-extra | Node 18+ 内置 fs.promises | 原生 fs |
CLI 体积敏感场景(npx 拉取):选
picocolors+giget+cac,全包 < 50KB。
四、实战:搭建 Jupiter CLI
4.1 项目结构
jupiter/
├─ bin/
│ └─ index.js # CLI 入口(带 shebang)
├─ lib/
│ ├─ init.js # init 命令
│ ├─ download.js # 模板下载
│ └─ update.js # 检查更新
├─ package.json
└─ README.md
4.2 package.json
{
"name": "jupiter-cli",
"version": "1.0.0",
"description": "Front-end scaffolding",
"type": "commonjs", // 用 CJS 还是 ESM 看团队
"main": "./bin/index.js",
"bin": {
"jupiter": "./bin/index.js" // 暴露 jupiter 命令
},
"files": ["bin", "lib"], // publish 时只包含这些
"scripts": {
"test": "node bin/index.js -v" // 冒烟测试
},
"dependencies": {
"chalk": "^4.1.2", // v4 兼容 CJS
"commander": "^12.0.0",
"inquirer": "^8.2.6", // 8.x 兼容 CJS
"ora": "^5.4.1",
"log-symbols": "^4.1.0",
"update-notifier": "^6.0.0",
"fs-extra": "^11.2.0",
"handlebars": "^4.7.8",
"download-git-repo": "^3.0.1" // ⚠️ 停更新,新项目用 giget
},
"engines": {
"node": ">=18"
}
}
CJS vs ESM:CLI 入口要兼容所有 Node 版本,建议先用 CJS;全 ESM 项目需要
"type": "module"+ 入口用.mjs或动态 import。
4.3 入口文件 bin/index.js
#!/usr/bin/env node
const program = require("commander");
const updateChk = require("../lib/update");
const initProject = require("../lib/init");
// 版本号
program.version(
`jupiter ${require("../package.json").version}`,
"-v, --version",
);
// init
program
.name("jupiter")
.usage("<commands> [options]")
.command("init <project_name>")
.description("Create a new project from template")
.action((projectName) => {
initProject(projectName);
});
// upgrade
program
.command("upgrade")
.description("Check the jupiter version")
.action(() => {
updateChk();
});
// help 提示
program.on("--help", () => {
console.log("\nRun " + "jupiter <command> --help".cyan + " for details");
});
program.parse(process.argv);
4.4 核心命令实现
① 版本号 + help(已在入口完成)
② 检查更新 lib/update.js
const updateNotifier = require("update-notifier");
const chalk = require("chalk");
const pkg = require("../package.json");
const notifier = updateNotifier({ pkg });
function updateChk() {
if (notifier.update) {
console.log(
chalk.cyan(
`New version available: ${notifier.update.latest}, run 'npm i -g jupiter-cli' to update.`,
),
);
notifier.notify();
} else {
console.log("You're on the latest version. ✓");
}
}
module.exports = updateChk;
原理:
update-notifier用package.json的name去 npm 查最新版本,跟version对比。本地测试可以把name改成一个真实的库名(如react)来看到提示。
③ 下载模板 lib/download.js
const download = require("download-git-repo");
const ora = require("ora");
const chalk = require("chalk");
const fse = require("fs-extra");
const path = require("path");
const tplPath = path.resolve(__dirname, "../template");
const asyncDownload = (template, dest) =>
new Promise((resolve, reject) => {
download(template, dest, { clone: true }, (err) =>
err ? reject(err) : resolve(),
);
});
async function dlTemplate(answers) {
// 1. 清空模板目录
await fse.remove(tplPath).catch((err) => {
console.error(chalk.red(`Clean template dir failed: ${err}`));
process.exit(1);
});
// 2. 模板映射
const templateMap = {
react: "github:GitHubJackson/react-spa-template#main",
vue: "github:GitHubJackson/vue-spa-template#main",
"vite-vue": "github:GitHubJackson/vite-vue-template#main",
koa: "github:GitHubJackson/koa2-template-lite#main",
};
// 3. 下载 + spinner
const spinner = ora(chalk.cyan("Downloading template...")).start();
try {
await asyncDownload(templateMap[answers.type], tplPath);
spinner.succeed(chalk.green("Template downloaded."));
} catch (err) {
spinner.fail(chalk.red(`Download failed: ${err}`));
process.exit(1);
}
}
module.exports = dlTemplate;
④ init lib/init.js
const fse = require("fs-extra");
const ora = require("ora");
const chalk = require("chalk");
const inquirer = require("inquirer");
const symbols = require("log-symbols");
const path = require("path");
const dlTemplate = require("./download");
const tplPath = path.resolve(__dirname, "../template");
async function initProject(projectName) {
const processPath = process.cwd();
const targetPath = `${processPath}/${projectName}`;
// 1. 检查目录是否已存在
if (await fse.pathExists(targetPath)) {
console.log(
symbols.error,
chalk.red(`Project "${projectName}" already exists.`),
);
return;
}
// 2. 交互选择模板
const { type } = await inquirer.prompt([
{
type: "list",
name: "type",
message: "Select template:",
default: "react",
choices: ["react", "vue", "vite-vue", "koa"],
},
]);
// 3. 下载模板
await dlTemplate({ type });
// 4. 复制到目标目录
const spinner = ora(chalk.cyan("Copying to project...")).start();
try {
await fse.copy(tplPath, targetPath);
spinner.succeed(chalk.green("Project created successfully!"));
console.log(`\nNext: cd ${projectName} && pnpm install\n`);
} catch (err) {
spinner.fail(chalk.red(`Copy failed: ${err}`));
process.exit(1);
}
}
module.exports = initProject;
4.5 本地调试
# 在 jupiter 项目目录下
npm link # 创建全局软链接
jupiter -v # 验证版本
jupiter init my-app # 验证 init
# 不需要时
npm unlink jupiter # 解除软链接
常见坑:
npm link后命令不生效,90% 是bin字段没配对,或#!/usr/bin/env nodeshebang 没加。
4.6 升级到 ESM(可选,2025+ 推荐)
新建项目建议直接用 ESM:
// package.json
{
"type": "module",
"bin": { "jupiter": "./bin/index.mjs" }
}
// bin/index.mjs
#!/usr/bin/env node
import { Command } from "commander";
import { createRequire } from "node:module";
import updateChk from "../lib/update.js";
import initProject from "../lib/init.js";
const require = createRequire(import.meta.url);
const pkg = require("../package.json");
const program = new Command();
program.version(`jupiter ${pkg.version}`, "-v, --version");
program
.name("jupiter")
.command("init <project_name>")
.action(initProject);
program.command("upgrade").action(updateChk);
program.parse(process.argv);
ESM 优势:① 跟现代生态对齐;② 跟 Node 18+ 内置 API 配合好;③ 给 package 加
"type": "module"后,库使用者也能享受 ESM tree-shaking。
五、发布到 npm
5.1 发布前准备
# 1. 注册 npm 账号(如未注册)
npm adduser
# 2. 确认 registry
npm config get registry # 应该是 https://registry.npmjs.org
# 3. 检查包名是否被占用
npm view jupiter-cli # 报错就是没被占用
5.2 发布
# 1. 更新版本号(自动 commit + tag)
npm version patch # 1.0.0 → 1.0.1
npm version minor # 1.0.0 → 1.1.0
npm version major # 1.0.0 → 2.0.0
# 2. 发布
npm publish
# 首次发布公共包
npm publish --access public
5.3 验证
# 在其他机器上
npx jupiter-cli init my-app
# 或全局安装
npm i -g jupiter-cli
jupiter -v
详细发布流程、scoped 包、CI 自动发布见 发布 npm 包。
六、字段速查(字典式参考)
6.1 package.json 关键字段
| 字段 | 用途 | 必填 |
|---|---|---|
name | 包名(全局唯一) | ✅ |
version | 版本号(SemVer) | ✅ |
bin | 注册 CLI 命令 | ✅ |
main | 入口文件(库场景) | 库时 |
files | publish 时包含的目录 | 推荐 |
type | "module" 启用 ESM | ESM 时 |
engines | Node 版本要求 | 推荐 |
private | true 阻止意外发布 | 内网时 |
publishConfig | 私有 registry 配置 | 内网时 |
6.2 CLI 常用命令
# 开发
npm link # 本地全局软链
node bin/index.js # 直接跑入口调试
# 版本
npm version patch|minor|major # 自动 bump + git tag
npm version 1.2.3 # 指定版本
# 发布
npm publish # 发布到 npm
npm publish --access public # scoped 包首次发布
npm publish --dry-run # 模拟发布(不上传)
npm unpublish <pkg>@<version> # 72h 内可撤回
# 弃用
npm deprecate <pkg> "msg" # 标记包已弃用
6.3 推荐依赖版本(2026 稳定)
| 库 | CJS 兼容版本 | ESM 版本 |
|---|---|---|
commander | 12.x | 12.x |
inquirer | 8.x | 9.x(ESM) |
@inquirer/prompts | —(首推) | 7.x |
chalk | 4.x | 5.x |
picocolors | 1.x | 1.x |
ora | 5.x | 8.x(ESM) |
fs-extra | 11.x | 11.x |
giget | — | 最新 |
七、面试高频
Q1:脚手架的核心流程是什么?
A:① 解析命令行参数(commander);② 交互式收集信息(inquirer);③ 下载/渲染模板(giget + handlebars);④ 写入目标目录(fs-extra);⑤ 收尾(提示、清理)。
Q2:如何本地调试 CLI?
A:npm link 创建全局软链 → 全局执行 jupiter -v 验证。调试完毕 npm unlink 断开。
Q3:模板引擎 handlebars 解决了什么?
A:模板里有变量(如项目名、作者),handlebars 能在运行时替换占位符,避免字符串拼接的脆弱性。
Q4:如何做 CLI 的版本管理?
A:① 通过 update-notifier 检测本地 vs npm 最新;② 用 npm version 自动 bump + 打 tag;③ 用 Changesets / Semantic Release 自动化(见 版本管理)。
Q5:什么时候该自研脚手架?
A:5+ 项目共用 / 内部私有协议 / 强业务约定时。其余场景直接用 create-vite 等官方脚手架。