@zjy79866/zjy-cli
v1.0.1
Published
A modern frontend CLI scaffolding tool
Readme
从零搭建前端 CLI 脚手架
一、为什么要搭建自己的脚手架
在企业级前端开发中,团队通常会面临以下痛点:
- 项目初始化重复劳动:每个新项目都要手动搭建目录结构、配置构建工具、编写基础组件,耗时且容易出错。
- 规范难以统一:不同成员对目录结构、代码规范、构建工具的理解不一致,导致项目之间差异大,维护成本高。
- 公共配置散落各处:Vite/Webpack 配置、ESLint、Prettier 等工具的配置每个项目各自维护,升级或修改时需要逐个调整。
- 新人上手成本高:新成员需要了解大量重复的基础设施配置,而非专注于业务开发。
CLI 脚手架正是解决这些问题的关键基础设施——它将项目初始化、构建、开发等流程标准化、自动化,各前端服务能够专注于自己系统的业务逻辑。
二、架构设计
zjy-cli/
├── package.json # 包配置,定义 bin 入口
├── bin/
│ └── index.js # CLI 可执行入口(系统命令入口)
├── src/ # 源码(ESM 格式,开发阶段)
│ ├── index.js # 应用入口:node版本检查、环境初始化
│ ├── cli.js # 命令注册中心:Commander 初始化 + 动态加载子命令
│ ├── utils/ # 公共工具模块
│ └── commands/ # 每个命令对应一个单独文件夹
│ ├── create/ # 项目初始化命令
│ ├── serve/ # 开发服务器命令
│ └── build/ # 构建命令
├── templates/ # 内置模板目录(随包发布)
│ └── vue3-vite/
├── dist/ # 构建产物(Rollup 打包)
│ └── bundle.js
└── rollup.config.js # 打包配置核心设计原则:
- 命令独立:每个命令对应
commands/下的独立目录,结构清晰,职责单一。 - 懒加载:子命令模块通过
await import()动态加载,减少 CLI 启动时间。 - 公共与私有分离:
utils/只放公共工具,命令逻辑全部在commands/下。 - 配置分层:脚手架默认配置 < 目标项目自定义配置 < CLI 命令行参数,优先级逐层覆盖。
三、技术栈选型
| 技术 | 用途 | 选型理由 |
|------|------|----------|
| commander | CLI 命令解析、参数定义 | 最成熟的 Node.js CLI 框架,支持链式注册、自动帮助信息生成 |
| prompts | 交互式命令行问答 | 轻量、灵活,支持文本、选择、确认等多种交互类型 |
| lodash.template | 模板变量渲染 | 使用 <% %> 语法,轻量且稳定,适合文件内容替换 |
| fs-extra | 文件/目录操作 | 封装 Node.js fs 模块,提供 ensureDir、remove 等便捷 API |
| vite | 构建工具 API 调用 | 通过动态 import 加载,保持 external,不打入产物 |
| signale | 彩色日志输出 | 开箱即用的美观日志,支持 info/success/error/warn 等级别 |
| rollup | CLI 代码打包 | 擅长打包 ESM 格式,输出体积小,适合单文件 CLI 场景 |
四、搭建步骤
步骤 1:初始化脚手架项目
先搭建脚手架目录结构
在 package.json 中声明:
{
"name": "zjy-cli",
"version": "1.0.0",
"type": "module", // 项目使用 ESM 模块格式
"bin": {
"zjy": "./bin/index.js" // 声明全局命令名 `zjy` 对应的入口文件
},
"files": [ // 发布到 npm 时只打包这些目录
"bin",
"dist",
"templates"
]
}步骤 2:创建 CLI 入口 bin/index.js
bin/index.js 是 CLI 的"系统入口层",解决了开发/生产双模式切换:
- 有 -tp → 加载 src/ 源码(开发)
- 无 -tp → 加载 dist/bundle.js(生产)
#!/usr/bin/env node
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const cliDir = resolve(__dirname, '..');
const tpIndex = process.argv.indexOf('-tp');
const isDev = tpIndex !== -1;
if (isDev) {
console.log('[zjy-cli] 本地调试,走src源码...');
process.argv.splice(tpIndex, 1);
await import(resolve(cliDir, 'src', 'index.js'));
} else {
console.log('[zjy-cli] 生产模式,走打包产物...');
await import(new URL('../dist/bundle.js', import.meta.url));
}#!/usr/bin/env node(shebang)的作用:
让系统识别这个文件是一个 Node.js 脚本。当用户在全局安装 CLI 后,可以直接通过命令名(如 zjy)执行,而不需要显式写 node。
为什么需要 bin/index.js 而不是直接用 src/index.js 作为入口?
bin/是 npm 社区惯例,表示"可执行入口",src/表示源代码。混在一起会让package.json的意图变得不清晰- 如果去掉
bin/index.js,这个切换逻辑就要搬到src/index.js里,但打包后src/index.js会变成bundle.js,动态import('src/...')的路径解析会变得别扭
步骤 3:创建应用入口 src/index.js
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createCli } from './cli.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = resolve(dirname(__filename), '..');
// Node.js 版本校验
const nodeVersion = parseInt(process.version.split('.')[0]);
if (nodeVersion < 18) {
console.error('[zjy-cli] Node.js version must be >= 18.0.0');
process.exit(1);
}
// 挂载项目根目录到全局,供子模块使用
globalThis.__MY_CLI_DIR__ = __dirname;
// 启动 CLI
createCli();职责非常纯粹:校验运行环境、挂载全局路径、启动命令解析。
步骤 4:注册命令 — src/cli.js
import { Command } from 'commander';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
export async function createCli() {
const program = new Command();
program
.name('zjy')
.description('自定义前端脚手架')
.version(pkg.version);
// 动态加载各命令模块(懒加载,减少启动开销)
const createModule = await import('./commands/create/index.js');
const buildModule = await import('./commands/build/index.js');
const serveModule = await import('./commands/serve/index.js');
// 注册子命令
createModule.register(program);
buildModule.register(program);
serveModule.register(program);
// 解析命令行参数,触发对应命令执行
program.parse();
}注册命令的核心流程:
- 用
Commander创建 CLI 实例,设置名称、描述、版本号。 await import()动态导入各命令模块(而非顶部静态 import),只在需要时加载对应代码。- 调用各模块导出的
register(program)方法,将子命令挂载到program上。 program.parse()解析用户输入,匹配到对应命令后执行。
步骤 5:实现 create 命令(项目初始化)
实现原理:
- 通过
command注册命令 - 通过
prompts交互式问答(当没传必要options时) - 获取模板
- 递归渲染模板文件,通过
lodash.template变量替换渲染,复制到目标目录
模板初始化方案对比:
| 方案 | 说明 | 优点 | 缺点 |
|------|------|------|------|
| 模板内置(本文采用) | 模板随 CLI 发布,放在 templates/ 目录 | 开箱即用,无需额外网络请求,维护简单 | 更新模板需升级 CLI 版本,包体积较大 |
| 模板独立发布 | 模板是独立 npm 包,运行时按需下载安装 | 模板独立迭代,不同团队可维护各自模板 | 依赖 npm registry,增加复杂度 |
步骤 6:实现 serve 和 build 命令
zjy serve 和 zjy build 通过 CLI 内嵌的 Vite API 启动,目标项目无需安装 vite 或 @vitejs/plugin-vue,但开发环境和 CI 流水线需先 npm install -g zjy-cli
根据目标项目的 package.json 配置 ,npm run dev、npm run build 实际执行的是 zjy,然后通过脚手架里的 vite 运行:
"scripts": {
"dev": "zjy serve",
"build": "zjy build"
}补充:如果你希望别人拿到你的项目可以直接 npm i + npm run dev 跑起来,需要:
{
"scripts": {
"dev": "npx zjy serve", // 2. 前面加npx(如果没有npx,只会在全局找zjy命令)
"build": "npx zjy build"
},
"dependencies": {
"zjy-cli": "^1.0.0", // 1. 加入依赖
"pinia": "^2.1.7",
"vue": "^3.4.0"
}
}配置管理体系
serve 和 build 共用同一套配置加载逻辑:
CLI flag(通过命令选项传入) > zjy.config.js(目标项目根目录) > 脚手架默认配置(zjy-cli的vite.common.config.js)- 未找到
zjy.config.js→ 使用脚手架默认配置 - 找到
zjy.config.js→ 合并覆盖脚手架默认配置 - CLI flag 优先级最高 → 如
zjy serve -p 1333覆盖zjy.config.js中的端口
这种设计的优势:脚手架提供一套默认公共配置保证团队规范统一,目标项目可以通过配置文件覆盖特定配置,CLI 参数拥有最高优先级提供灵活调整能力。
步骤 7:rollup 打包发布
// rollup.config.js
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es',
banner: '#!/usr/bin/env node', // 产物添加 shebang
inlineDynamicImports: true // 内联动态 import
},
external: ['vite', '@vitejs/*'], // 排除构建工具,保持 external
plugins: [nodeResolve(), commonjs(), json()]
};npm run build # 打包
npm publish # 发布到 npm最终发布到npm的是:
- bin/ — CLI 入口
- dist/ — Rollup 打包产物
- templates/ — 内置模板
- package.json、README.md 等(npm 默认包含)
src/、node_modules/、rollup.config.js 等不会发布
本地调试
-tp是 CLI 全局开发标记,不是 任何脚手架命令的参数。它的作用是让bin/index.js加载源码而非打包产物。npm link是 建立全局命令与本地目录的符号链接。核心价值是开发过程中无需反复npm publish,修改源码后直接用zjy xxx -tp命令测试(如果不-tp,就是走dist,脚手架要先npm build再zjy xxx)
npm link # 建立软连接
npm ls -g --depth 0 # 查看软连接
npm unlink -g zjy-clinpm link(推荐)
# 在 zjy-cli 项目根目录下(此时全局命令 zjy 实际指向 ./bin/index.js 的符号链接)
npm link
# 切换到其他目录(目标项目所在目录)
cd /path/to/target-project
zjy create zjy-app -tp # -tp 走源码,不走打包产物或
npm link
npm run build
# 切换到其他目录(目标项目所在目录)
cd /path/to/target-project
zjy create my-app # 非-tp 走dist,需要脚手架先重新打包直接运行源码
# 在 zjy-cli 项目根目录下
node src/index.js create my-app断点调试
1、在源码中打断点
2、在脚手架项目中启动 Debug 终端,cd到目标项目,执行 zjy xxx -tp
