前言
- 每次起新项目,都要复制一整套「祖传」配置、改包名、删示例,稍不留神就漏掉埋点或兼容文件?
- 团队里 Vue 和 React 混用,项目结构五花八门,新人上手全靠口口相传?
- 你和我说这就是在开发脚手架?No,No,No,你这是在扒拉项目结构。
脚手架不是框架,而是用命令行把「创建项目、统一规范」自动化 的工具。我们天天在用的 npm、vue create、create-react-app,背后都是同一套思路:用 Node.js 写一个 CLI,把最佳实践固化成一条命令。
一、脚手架是什么?一条命令里藏着四样东西
回想一下你敲的每一条脚手架命令,无非四部分:
bash
vue create vue-test-app --force -r https://registry.npmmirror.com
| 部分 | 示例 | 说明 |
|---|---|---|
| 主命令 | vue |
对应一个可执行文件 |
| 子命令 | create |
具体做什么事 |
| 参数 | vue-test-app |
子命令的输入 |
| 选项 | --force、-r <url> |
开关或带值的配置 |
所以脚手架就是一个「主命令 + 子命令 + 参数 + 选项」的命令行客户端。
执行时发生了什么? 终端先根据主命令在 PATH 里找到可执行文件(例如全局的 vue.js),再用 Node 执行它(因为文件头有 #!/usr/bin/env node),脚本里解析子命令和选项后执行对应的逻辑,结束退出。
一句话:脚手架本质是操作系统的客户端,只不过这个「客户端」是一段用 Node 跑的 JS,通过命令行和你交互罢了。
二、为什么值得花费成本自己做一套?
vue-cli、create-react-app 解决的是「从零搭一个标准项目」。但日常团队中会沉淀出一堆自家的东西,比如H5 兼容、接口封装、埋点、公共组件、登录/权限等,甚至整块业务都会被复用。每次起项目都从零复制,既费时又容易出错。
依我看,自己做项目创建脚手架 ,最起码能带来三件收益:模板沉淀 (把「我们团队该怎么起项目」固化成可选模板)、标准化 (类型、名称、框架通过交互选择,减少人为差异)、可复用(新人一条命令就和团队站在同一起跑线)。
三、原理:三个问题搞懂脚手架执行的过程
回答三个问题,原理就通了:
-
为什么装的是
@vue/cli,敲的却是vue?package.json 里有个
bin字段,例如"bin": { "vue": "bin/vue.js" }。全局安装时,npm 会在可执行路径下创建一个叫vue的软链接,指向这个 js 文件,所以命令名可以和包名不一样。 -
全局安装时到底干了啥?
把包下到全局
node_modules,再按bin配置在系统 PATH 能搜到的地方建好软链接,这样你在任意目录敲vue都能找到对应脚本。 -
为什么一个 .js 文件能直接当命令执行?
因为第一行写了 shebang :
#!/usr/bin/env node。系统看到#!就知道要用后面的解释器来跑这个文件,于是用当前环境的 node 去执行。用env node而不是写死/usr/bin/node,换机器、换环境也能用。
四、给我一首歌的时间,从 0 跑通一个最小 的CLI
四步:建项目、写入口、配 bin、本地 link。跑通后你就有了一条「真」命令,再往上加 init、install 只是扩展。
1. 初始化项目
bash
mkdir my-cli && cd my-cli
npm init -y
2. 写入口并加上 shebang
创建 bin/cli.js。第一行 #!/usr/bin/env node 叫 shebang(希棒) :以 #! 开头,告诉系统「用谁」来执行这个文件。这里用当前环境的 node,所以终端里直接敲 my-cli 就会用 Node 跑这段脚本,不用再写 node bin/cli.js。
javascript
#!/usr/bin/env node
import { program } from 'commander';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
readFileSync(join(__dirname, '../package.json'), 'utf-8')
);
program
.name('my-cli')
.description('最小 CLI 示例')
.version(pkg.version);
program
.command('hello [name]')
.description('打个招呼')
.action((name) => {
console.log('Hello,', name || 'World');
});
program.parse();
3. 配置 package.json
补上 bin 和 type: "module"(推荐用 ESM,大势所趋):
json
{
"name": "my-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"my-cli": "bin/cli.js"
},
"dependencies": {
"commander": "^11.0.0"
}
}
4. 本地调试
bash
npm install
npm link
在任意目录执行 my-cli --version、my-cli hello 张三,能输出版本、能打招呼,就说明最小 CLI 已经跑通。后面要支持 init、install 等多条命令,无非是把脚手架拆成多包、抽象出命令基类,在入口里按「一条命令一个子类」挂上去,是不是很简单?
五、来看一个真实项目
拿我们组脚手架为例,采用 commander框架,实现了组内自定义Vue/React模板框架的生成功能。
目录
bash
your-cli/
├── package.json
├── packages/
│ ├── cli/ # 入口、createCLI、注册命令
│ ├── command/ # 命令基类
│ ├── utils/ # 日志、inquirer、npm、Git
│ ├── init/ # 命令 init:模板 → 下载 → 安装
│ └── install/ # 命令 install:搜索 → 选 tag → clone → 装依赖 → 运行
Command 基类说明 :项目中,子命令不直接调 Commander,而是继承基类,从而实现 command、description、options、action,基类在构造函数里统一完成了「注册命令 + 绑定 action」。这样新增命令 = 新子类 + 入口挂一行。核心逻辑如下:
javascript
// packages/command/lib/index.js(思路示例,已脱敏)
class Command {
constructor(program) {
if (!program) throw new Error('command instance must not be null!');
this.program = program;
const cmd = this.program.command(this.command);
cmd.description(this.description);
if (this.options?.length > 0) {
this.options.forEach(opt => cmd.option(...opt));
}
cmd.action((...params) => this.action(...params));
}
get command() {
throw new Error('command must be implemented');
}
get description() {
throw new Error('description must be implemented');
}
get options() {
return [];
}
async action() {
throw new Error('action must be implemented');
}
}
export default Command;
入口里只需:通过createCLI() 得到 program,
再 createInitCommand(program)、createInstallCommand(program),
最后 program.parse(process.argv)。
通常createCLI() 里需要包含 name、version、--debug、Node 版本检查、未知命令提示等功能。
六、来看看init 命令干了啥
init 只做一件事:从模板创建项目 。在项目里,InitCommand 的 action 拆成三步,对应三个文件:
| 步骤 | 做的事 | 对应模块 |
|---|---|---|
| 1 | 选择模板,生成安装信息 | createTemplate.js |
| 2 | 下载模板到缓存目录 | downloadTemplate.js |
| 3 | 拷贝到项目目录并渲染 | installTemplate.js |
第一步:createTemplate
入参是项目名 name 和命令行 opts(--type、--template、--force)。
没传 type/template 就通过交互收集。
选定后用 getLatestVersion(template.npmName) 从 npm 拉最新版本,并返回 { type, name, template, targetPath },其中targetPath 即缓存目录(如 ~/.your-cli/addTemplate)。
第二步:downloadTemplate
在缓存目录下执行 npm install ${npmName}@${version},把模板包装进 node_modules。示例:
javascript
// 思路示例,已脱敏
import { execa } from 'execa';
import ora from 'ora';
async function downloadAddTemplate(targetPath, template) {
const { npmName, version } = template;
await execa('npm', ['install', `${npmName}@${version}`], { cwd: targetPath });
}
export default async function downloadTemplate(selectedTemplate) {
const { targetPath, template } = selectedTemplate;
ensureDirSync(targetPath);
const spinner = ora('正在下载模板...').start();
try {
await downloadAddTemplate(targetPath, template);
spinner.stop();
log.success('下载模板成功');
} catch (e) {
spinner.stop();
printErrorLog(e);
}
}
第三步:installTemplate
目标目录是当前目录下的 name 文件夹;
从缓存的 node_modules/<npmName>/template 拷贝到目标目录,之后用 ejs 注入 name 后写回。
代码示例如下:
javascript
// 思路示例,已脱敏
import fse from 'fs-extra';
import { pathExistsSync } from 'path-exists';
import ejs from 'ejs';
import glob from 'glob';
export default async function installTemplate(selectedTemplate, opts) {
const { force = false } = opts;
const { targetPath, name, template } = selectedTemplate;
const rootDir = process.cwd();
const installDir = path.resolve(rootDir, name);
if (pathExistsSync(installDir)) {
if (!force) {
log.error(`当前目录下已存在 ${installDir}`);
return;
}
fse.removeSync(installDir);
}
fse.ensureDirSync(installDir);
const originFile = path.resolve(targetPath, 'node_modules', template.npmName, 'template');
const fileList = fse.readdirSync(originFile);
fileList.forEach((file) => {
fse.copySync(path.join(originFile, file), path.join(installDir, file));
});
const ejsData = { name, ...customData };
glob('**', { cwd: installDir, nodir: true, ignore: template.ignore }, (err, files) => {
files.forEach((file) => {
const filePath = path.join(installDir, file);
ejs.renderFile(filePath, ejsData, (err, result) => {
if (!err) fse.writeFileSync(filePath, result);
});
});
});
}
最终,InitCommand 的 action 就是三步串联:
javascript
// 思路示例,已脱敏
async action([name, opts]) {
const selectedTemplate = await createTemplate(name, opts);
await downloadTemplate(selectedTemplate);
await installTemplate(selectedTemplate, opts);
}
ps:为何用 npm 管理模板? 不占服务器、自带版本、用 registry API 查 dist-tags.latest 即可拿到最新版的包。我们内部使用了自己部署的Verdaccio,也推荐给大家!
模板包约定:模板统一放在 template/下,支持多框架(React/Vue)。
七、React/Vue 模板来源有哪些?
「从模板创建项目」时,React/Vue 通常有两种来源,可以同时提供:
| 来源 | 命令 | 场景 |
|---|---|---|
| npm 模板 | init | 团队标准化:把 React/Vue 模板打成 npm 包,init 时选模板一步到位 |
| Git 仓库 | install | 选取目标仓库和 tag 后 clone、装依赖、可选运行 |
init 做「内部」创建,install 做「任意仓库」拉取,两条能力互补。
八、总结
- 架构 :commander框架。
- 项目模板:npm 托管 + registry API 查版本。
- 交互:Inquirer进行选择与输入,validate 做必填;ora 做 loading,log做成功/失败统一提示日志 ,debug模式采用log.verbose等。