Skip to main content

前端脚手架:从零搭建 CLI 工具

本文以自研 CLI jupiter 为例,介绍如何从零搭建一个前端脚手架。代码部分可作为学习参考,现代新项目建议直接用 create-vite / create-next-app / create-nuxt 等官方脚手架;自研 CLI 适合多项目统一内部约定封装的场景。

一、避坑指南

  1. 90% 项目不需要自研脚手架

    • 单项目 / 标准技术栈 → 直接用官方脚手架(pnpm create vite
    • 多项目 / 内部统一约定 → 才有自研价值
    • 早期自研 + 维护不动 = 比直接抄模板还糟糕
  2. 库选型跟紧 ESM 时代

    状态处理
    chalk@4维护中(CJS)仍可用,但新项目用 v5+(ESM)
    chalk@5+ESM only"type": "module"
    download-git-repo已停更(最后更新 2018)改用 giget
    inquirer维护中但功能陈旧改用 @inquirer/prompts(现代化、TypeScript 原生)
    picocolors轻量替代 chalk13x 体积小,CLI 工具首选
    commander持续维护✅ 仍然推荐
  3. 必须 npm link 本地调试

    # 项目目录下
    npm link # 创建全局软链接
    jupiter -v # 终端直接验证
    npm unlink jupiter # 不需要时断开

    #!/usr/bin/env node 这一行必加,系统靠它找到 node 解释器。

  4. 版本号必须从 package.json

    // ❌ 硬编码,每次发布要改
    program.version("1.0.0");

    // ✅ 从 package.json 读,永远不脱节
    program.version(require("../package.json").version);
  5. 下载模板要支持「覆盖 / 重试 / 失败兜底」

    // ❌ 直接下,不做错误处理
    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);
    }
  6. 检查更新用 update-notifier:通过 npm 仓库的 name + version 自动对比,不要自己 fetch npm registry

二、自研 CLI 的价值判断

2.1 什么时候该自研?

场景自研价值理由
多项目共用模板(10+ 项目)⭐⭐⭐⭐⭐抽公共部分,一次维护
内部私有协议(特殊打包、私有仓库)⭐⭐⭐⭐⭐必须封装
强业务约定(强制目录结构、命名规范)⭐⭐⭐⭐团队 SOP 落地
微前端统一(基座 + 子应用模板)⭐⭐⭐⭐子应用模板统一
单项目 / 标准技术栈直接用 create-vite 即可
想「学一下脚手架怎么搭」⭐⭐看完本文,不要真上线

2.2 自研 vs 现成脚手架

维度自研 CLIcreate-vite / create-next-app
启动成本高(设计 + 开发 + 维护)(一行命令)
灵活性完全可控受限于官方模板
维护成本长期(跟着 TS / 框架升级)(官方维护)
内部集成✅ 强(私有包、内网模板)弱(要 hack 模板)
适用团队10 人以上 / 多业务线任何规模

决策原则先穷尽官方 / 社区方案,再考虑自研。自研的 ROI 临界点一般在「5+ 项目共用」。

三、技术选型(2025+ 推荐)

职责首推备选/场景已被淘汰
命令解析commandercac(轻量)
交互@inquirer/prompts(v8+,TS 原生)inquirer(兼容老项目)
终端颜色picocolors13x 小于 chalkchalk@5+(ESM)chalk@4(CJS)
进度动画oralistr2(多任务编排)
模板下载gigetdownload-git-repo 替代直接用 degitdownload-git-repo(停更)
更新检查update-notifier自己 fetch npm
日志符号log-symbols直接用 picocolors
模板引擎handlebarsejs / mustache字符串拼接
文件系统fs-extraNode 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-notifierpackage.jsonname 去 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 node shebang 没加。

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入口文件(库场景)库时
filespublish 时包含的目录推荐
type"module" 启用 ESMESM 时
enginesNode 版本要求推荐
privatetrue 阻止意外发布内网时
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 版本
commander12.x12.x
inquirer8.x9.x(ESM)
@inquirer/prompts—(首推7.x
chalk4.x5.x
picocolors1.x1.x
ora5.x8.x(ESM)
fs-extra11.x11.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 等官方脚手架。

八、相关文档