26 · 脚手架原理与实战:自研 CLI
本篇解决什么问题: 团队有 10 个项目时,"复制老项目改一改"会产生 10 份跑偏的配置。 脚手架把规范固化成代码,让新项目 30 秒起步且天然合规。 本篇从 0 带你写出一个能发布到 npm 的 CLI 脚手架。
📌 与 A27 的分工:本篇讲原理与自研,A27 讲选型对比(什么时候别自研)。
开篇速览
| 环节 | 用什么 | 作用 |
|---|---|---|
| 命令注册 | bin 字段 + npx | 让命令可执行 |
| 参数解析 | commander / yargs | --template vue、-f |
| 交互问答 | inquirer | 选模板、填项目名 |
| 模板渲染 | ejs / handlebars | 把变量注入模板文件 |
| 模板获取 | download-git-repo / npm 包 | 本地 or 远程模板 |
| 文件操作 | fs-extra | 拷贝、删除、递归 |
| 子进程 | execa | 装依赖、git init |
| 视觉反馈 | chalk + ora | 彩色输出 + loading |
| 扩展 | preset 预设 / 插件 | 可插拔能力 |
一、最小可用:让命令跑起来
1.1 package.json 的 bin 字段
json
{
"name": "my-cli",
"version": "1.0.0",
"type": "module",
"bin": { "my-cli": "./bin/cli.js" }
}1.2 入口文件必须有 shebang
js
#!/usr/bin/env node
// bin/cli.js
console.log('hello my-cli')1.3 本地调试与发布
bash
npm link # 本地全局链接,直接敲 my-cli 测试
npm publish # 发布到 npm(或私有源)
npx my-cli # 用户免安装直接跑💡
npx的便利:不用全局安装,临时下载执行,天然用最新版。
二、参数解析:commander
js
import { program } from 'commander'
program
.name('my-cli')
.version('1.0.0')
.command('create <project-name>')
.description('创建一个新项目')
.option('-t, --template <name>', '模板名称', 'vue3')
.option('-f, --force', '覆盖已存在的目录', false)
.action(async (name, opts) => {
await create(name, opts)
})
program.parse() // ✅ 必须调用要点:
<project-name>是必填参数,[xxx]是选填-t, --template <name>短名 + 长名 + 取值.action()里做真正的事
三、交互问答:inquirer
js
import inquirer from 'inquirer'
const answers = await inquirer.prompt([
{ type: 'input', name: 'name', message: '项目名称', default: 'my-app',
validate: v => /^[a-z][a-z0-9-]*$/.test(v) || '只能小写字母、数字、连字符' },
{ type: 'list', name: 'template', message: '选择模板', choices: ['vue3', 'react18', 'lib'] },
{ type: 'checkbox',name: 'features', message: '需要的能力', choices: ['ESLint', 'Prettier', 'Tailwind', 'Vitest'] },
{ type: 'confirm', name: 'install', message: '立即安装依赖?', default: true },
])常用题型与注意点:
| type | 用途 | 坑 |
|---|---|---|
input | 文本 | 一定要 validate(项目名可能被 npm 拒绝) |
list | 单选 | choices 可用 {name, value} 分离显示与实际值 |
checkbox | 多选 | 返回数组 |
confirm | 是否 | 返回 boolean |
⚠️ inquirer v9+ 是 ESM,CommonJS 项目要装
inquirer@8。
四、模板:脚手架的核心
4.1 模板放哪(三种方案)
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 内置在 npm 包里 | templates/ 目录随包发布 | 离线可用、版本一致 | 更新模板要发包 |
| 远程 Git 仓库 | download-git-repo | 模板独立演进 | 依赖网络、版本控制靠 tag/branch |
| 独立 npm 包 | npm i template-vue3 | 版本化、可组合 | 包多了难管理 |
推荐:中小团队用 内置(简单可靠);模板要被多团队共用时用 远程 Git + tag。
4.2 渲染:ejs
模板文件里写占位符:
// templates/vue3/package.json
{
"name": "<%= name %>",
"dependencies": {
"vue": "^3.4.0"<% if (features.includes('Tailwind')) { %>,
"tailwindcss": "^3.4.0"<% } %>
}
}js
import ejs from 'ejs'
const content = await ejs.renderFile(tplPath, answers, {})
await fs.writeFile(destPath, content)4.3 递归渲染整个模板目录(含条件文件)
js
import path from 'node:path'
import fs from 'fs-extra'
import glob from 'fast-glob'
async function renderTemplate(srcDir, destDir, data) {
const files = await glob('**/*', { cwd: srcDir, dot: true, onlyFiles: true })
for (const file of files) {
// ✅ 特殊处理:以 _ 开头的文件视为"模板元文件",渲染后去掉下划线
// 例:_gitignore → .gitignore(避免发布 npm 时被忽略)
const outName = path.basename(file).startsWith('_')
? path.join(path.dirname(file), path.basename(file).slice(1))
: file
const src = path.join(srcDir, file)
const dest = path.join(destDir, outName)
await fs.ensureDir(path.dirname(dest))
// 二进制文件(图片/字体)直接拷贝,文本类走 ejs
if (/\.(png|jpg|ico|woff2?|ttf)$/.test(file)) {
await fs.copy(src, dest)
} else {
const rendered = await ejs.renderFile(src, data)
await fs.writeFile(dest, rendered)
}
}
}💡
_前缀技巧很关键:npm 发布时会忽略.gitignore, 所以模板里要放_gitignore,渲染时再改名。
五、落地动作:拷贝之后的收尾
js
import execa from 'execa'
import ora from 'ora'
import chalk from 'chalk'
export async function create(name, { template, install }) {
const target = path.resolve(process.cwd(), name)
// ① 目录冲突检查
if (fs.existsSync(target)) {
const { ok } = await inquirer.prompt([{
type: 'confirm', name: 'ok', message: `目录 ${name} 已存在,覆盖?`,
}])
if (!ok) return
await fs.remove(target)
}
// ② 渲染模板
const spinner = ora('正在生成项目…').start()
await renderTemplate(path.join(__dirname, `../templates/${template}`), target, { name })
spinner.succeed('项目生成完成')
// ③ git init
await execa('git', ['init'], { cwd: target })
// ④ 安装依赖(可选)
if (install) {
const s = ora('正在安装依赖…').start()
await execa('npm', ['install'], { cwd: target, stdio: 'ignore' })
s.succeed('依赖安装完成')
}
// ⑤ 友好收尾
console.log(chalk.green(`\n✅ 项目已创建:${name}`))
console.log(` cd ${name}\n npm run dev`)
}体验要点(决定脚手架好不好用):
| 要点 | 做法 |
|---|---|
| 始终有反馈 | 用 ora 显示进度,别让用户对着黑屏等 |
| 幂等与安全 | 覆盖前必须确认,别直接 rm -rf |
| 失败可恢复 | 出错时清理半成品目录,或明确提示位置 |
| 下一步指引 | 结束时打印 cd 与启动命令 |
六、preset 与插件机制:让脚手架可扩展
问题:模板写死后,"Vue3 + Tailwind"、"Vue3 + Vitest" 要各存一份模板 → 组合爆炸。
解法:模板 + 插件(generator),按需叠加。
js
// generators/tailwind.js
export default {
name: 'tailwind',
// ① 追加依赖
extendPackage(pkg) {
pkg.devDependencies = { ...pkg.devDependencies, tailwindcss: '^3.4.0', postcss: '^8.4.0' }
},
// ② 追加文件
async render(dest) {
await renderTemplate(path.join(__dirname, 'template'), dest, {})
},
// ③ 追加问答(可选)
prompts: [{ type: 'confirm', name: 'tailwind', message: '使用 Tailwind?' }],
}js
// 主流程:按用户选择依次 apply
for (const g of selectedGenerators) {
g.extendPackage?.(pkg)
await g.render?.(target)
}
await fs.writeJson(path.join(target, 'package.json'), pkg, { spaces: 2 })💡 这就是 Vue CLI 的 plugin、Nx 的 generator、Plop 的 action 的共同骨架: 问答 → 修改 package.json → 渲染文件 → 可选的安装/钩子。
七、发布与维护
bash
# 发布前检查
npm run build
npm pack --dry-run # ✅ 确认发布内容(别把 node_modules 打包进去)
npm publish --access public.npmignore / files 字段(推荐用 files 白名单):
json
{ "files": ["bin", "dist", "templates"] }⚠️ 模板目录必须进包:忘了加
templates会导致"生成时空目录"——最常见的发布事故。
自检清单
- [ ] 能说出
bin字段 + shebang +npm link让命令跑起来的完整链路 - [ ] 能用 commander 定义命令与选项,用 inquirer 做带校验的问答
- [ ] 能用 ejs 渲染模板目录,并知道
_gitignore改名与二进制文件直拷的处理 - [ ] 能说出模板的三种存放方案及各自的取舍
- [ ] 能设计 generator 插件机制,避免模板组合爆炸
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "脚手架就是把文件拷一遍" | 核心是规范固化 + 可扩展,拷贝只是第一步 |
"模板直接放 .gitignore" | npm 发布会忽略它 → 要用 _gitignore 再改名 |
| "覆盖目录直接删" | 必须二次确认,否则可能删掉用户已有代码 |
| "一个模板一种技术栈组合" | 会组合爆炸 → 用 generator 插件按需叠加 |
动手练习
- 从 0 写一个
my-cli,支持create+ 两个模板 + 覆盖确认,npm link后本地跑通。 - 给脚手架加一个
tailwindgenerator,用extendPackage注入依赖并渲染配置文件。 npm pack --dry-run检查产物,确认templates/被正确打进包里。
相关文档
- 什么时候别自研 → A27 · 主流脚手架与工程化平台对比
- 生成后的规范与门禁 → A28 · 代码生成与规范化工具链
- 工程化选型 → A02 · 前端工程化
- 发布流程 → A16 · CI/CD 与部署运维