Skip to content

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 插件按需叠加

动手练习 ​

  1. 从 0 写一个 my-cli,支持 create + 两个模板 + 覆盖确认,npm link 后本地跑通。
  2. 给脚手架加一个 tailwind generator,用 extendPackage 注入依赖并渲染配置文件。
  3. npm pack --dry-run 检查产物,确认 templates/ 被正确打进包里。

相关文档 ​