整理「祖传」代码,就是在开发脚手架?

前言

  • 每次起新项目,都要复制一整套「祖传」配置、改包名、删示例,稍不留神就漏掉埋点或兼容文件?
  • 团队里 Vue 和 React 混用,项目结构五花八门,新人上手全靠口口相传?
  • 你和我说这就是在开发脚手架?No,No,No,你这是在扒拉项目结构。

脚手架不是框架,而是用命令行把「创建项目、统一规范」自动化 的工具。我们天天在用的 npmvue createcreate-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 兼容、接口封装、埋点、公共组件、登录/权限等,甚至整块业务都会被复用。每次起项目都从零复制,既费时又容易出错。

依我看,自己做项目创建脚手架 ,最起码能带来三件收益:模板沉淀 (把「我们团队该怎么起项目」固化成可选模板)、标准化 (类型、名称、框架通过交互选择,减少人为差异)、可复用(新人一条命令就和团队站在同一起跑线)。


三、原理:三个问题搞懂脚手架执行的过程

回答三个问题,原理就通了:

  1. 为什么装的是 @vue/cli,敲的却是 vue

    package.json 里有个 bin 字段,例如 "bin": { "vue": "bin/vue.js" }。全局安装时,npm 会在可执行路径下创建一个叫 vue软链接,指向这个 js 文件,所以命令名可以和包名不一样。

  2. 全局安装时到底干了啥?

    把包下到全局 node_modules,再按 bin 配置在系统 PATH 能搜到的地方建好软链接,这样你在任意目录敲 vue 都能找到对应脚本。

  3. 为什么一个 .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 nodeshebang(希棒) :以 #! 开头,告诉系统「用谁」来执行这个文件。这里用当前环境的 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

补上 bintype: "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 --versionmy-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,而是继承基类,从而实现 commanddescriptionoptionsaction,基类在构造函数里统一完成了「注册命令 + 绑定 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等。
相关推荐
码路飞1 小时前
写了个 AI 聊天页面,被 5 种流式格式折腾了一整天 😭
javascript·python
臣妾没空1 小时前
里程碑5:完成框架npm包抽象封装并发布
前端·npm
Lee川1 小时前
优雅进化的JavaScript:从ES6+新特性看现代前端开发范式
javascript·面试
Wect1 小时前
LeetCode 210. 课程表 II 题解:Kahn算法+DFS 双解法精讲
前端·算法·typescript
cxxcode1 小时前
搞懂 JS 异步的底层真相:从 V8 源码看微任务与宏任务
前端
欧阳的棉花糖1 小时前
React 小误区:派生值 vs useEffect
前端
马可菠萝2 小时前
从零开始,用 Tauri + Vue 3 打造轻量级桌面应用
前端
陆枫Larry2 小时前
JavaScript 字符串处理实战:从 `startsWith` 到链式 `replace` 的避坑指南
前端
ServBay2 小时前
Node.js、Bun 与 Deno,2026 年后端运行时选择指南
node.js·deno·bun