因为写了一个前端脚手架,这个月的KPI 打满了!!!

14,657 阅读10分钟

年底了,大家都在冲最后的绩效了,为了 我的💰,满心欢喜(迫不得已)利用自己宝贵的时间,帮公司写了一个通用项目框架 cli,希望大家用好,给一个好的反馈,让我钱包🥁🥁,过好年

背景

在现代前端开发中,脚手架工具(CLI)在团队项目中扮演着重要角色。我们的团队在项目开发中面临以下挑战:

  • 开发效率低:项目初始化、代码生成和重复性操作耗费了大量时间。
  • 缺乏统一规范:不同成员的代码风格不一致,提交记录混乱,影响项目质量。
  • 构建部署复杂:手动构建和部署容易出错,影响交付效率。

为了提升团队开发效率、规范化流程和减少重复性工作,主人公主导了前端 CLI 脚手架工具的设计与开发。通过这个工具,我们实现了项目初始化、代码生成、规范化操作以及自动化 CI/CD 流程。

最终效果

命令行交互工具

image.png

CI / CD

image.png

技术要点

Node.js 开发

整个 CLI 工具基于 Node.js 开发。核心功能通过处理命令行参数实现,灵活满足不同开发场景。

依赖库

  1. 命令参数解析commander 用于处理命令行参数和选项。
  2. 人机交互prompts 实现与用户的动态交互,支持多选、确认等操作(inquirer/input / select)。
  3. 控制台高亮chalkpicocolors 用于美化控制台输出,提高可读性。
  4. ora: 终端loading美化工具
  5. figlet: 终端生成艺术字
  6. git-clone: 下载项目模版工具
  7. 模板处理handlebars 作为模板引擎,用于生成动态代码片段。
  8. 私有 NPM 仓库verdaccio / nexus 用于搭建团队的 NPM 私有仓库,实现包管理的自主化。
  9. fs-etra: 用来操作文件目录 瑞士军刀

概念引入

cli 大家都熟悉,大家熟悉的vue,在 终端 cv这个命令,然后 一个项目就 clone 到你的本地了

image.png

再比如 vite 提供的cli

image.png

cli,其实也很简单,就两个核心的点

  1. 通过读取本地文件 / clone 的方式 去拉取已经存在的项目base 模版

  2. 为了提供可选择/可配置性,需要通过命令行界面(CLI),来实现读取 / clone 已经存在的文件模版

整体开发流程

  1. 项目组 定制好 项目模版,规定存放的目录位置(直接放到cli 目录 / 存放在单独的私有仓库)
  2. 协定使用方式,采用全局 安装 / npm 安装

定制开发模版

可以是基本架子,比如代码规范化 / eslint / prettier / / styleLint / commintlint / ci/cd / 基本配置...

也可以是一个完整的成熟的项目,比如包括基本组件(404/login/layout) / 路由 / store 配置 等等

根据情况定制即可

初始化项目

mkdir vp-cli-tools
cd vp-cli-tools
npm init -y

目录结构

vp-cli-tools/
    |- src/ # 项目资源
        |- command/  # 命令逻辑
        |- utils/   # 公共方法
        |- index.ts  # 命令入口文件
    |- rollup.config.js  # rollip 配置文件

package.json

{
  "name": "vp-cli-tools",
  "version": "0.3.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "rollup -c rollup.config.js --bundleConfigAsCjs"
  },
  "keywords": [
    "Vite",
    "VUE3",
    "Typescript",
    "Element-Plus"
  ],
  "author": "we",
  "bin": {
    "vp-cli-tools": "bin/index.js"
  },
  "files": [
    "dist",
    "bin",
    "README"
  ],
  "license": "ISC",
  "description": "",
  "devDependencies": {
    "@inquirer/prompts": "^3.3.0",
    "@rollup/plugin-commonjs": "^25.0.3",
    "@rollup/plugin-json": "^6.0.1",
    "@rollup/plugin-node-resolve": "^15.1.0",
    "@rollup/plugin-terser": "^0.4.3",
    "@types/figlet": "^1.7.0",
    "@types/fs-extra": "^11.0.2",
    "@types/lodash": "^4.14.202",
    "@types/node": "^20.10.4",
    "axios": "^1.6.2",
    "chalk": "^4.1.2",
    "commander": "^11.1.0",
    "figlet": "^1.7.0",
    "fs-extra": "^11.1.1",
    "lodash": "^4.17.21",
    "log-symbols": "4.1.0",
    "ora": "5",
    "progress-estimator": "^0.3.1",
    "rollup": "^4.6.1",
    "rollup-plugin-node-externals": "^5.1.2",
    "rollup-plugin-typescript2": "^0.36.0",
    "simple-git": "^3.21.0",
    "tslib": "^2.6.2",
    "typescript": "^5.3.3"
  }
}

package.json 中build / bin / files / name / version / 配置

"build": "rollup -c rollup.config.js --bundleConfigAsCjs",
"name": "vp-cli-tools",
"bin": {
    "vp-cli-tools": "bin/index.js"
  },
  
 "files": [
    "dist",
    "bin",
    "README"
  ],
  

输出格式

  • 使用 rollup 通过 -c 制定打包的 文件rollup.config.js,最后通过bundleConfigAsCjs输出为commonjs

bin 配置

  • bin中的配置是一个对象,需要有 "key" 和 "value"。

    • key 会被放置在 node_modules 的 .bin 目录中,value 是 key 对应需要执行的文件。
    • 我们使用 vp-cli-tools 就会调用我们的 bin/index.js。
    • 当我们全局安装对应包的时候会放在全局的 node_modules 的 .bin 目录中,相当于添加了系统环境变量,这样我们就可以直接在终端中调用。

#!/usr/bin/env node 通常称为 shebang 机制,有以下优点

  • 通过 env 查找 Node.js,确保你写的脚本能够在各种环境下运行
  • 简化脚本的开发和调试工作,让脚本 成为可执行文件,不需要始终使用 node 来执行
  • node xxx => xxx

rollup.config.js 入口编写

import { defineConfig } from 'rollup';
import nodeResolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import externals from 'rollup-plugin-node-externals';
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';
import typescript from 'rollup-plugin-typescript2';

export default defineConfig([
  {
    input: {
      index: 'src/index.ts', // 打包入口文件
    },
    output: [
      {
        dir: 'dist', // 输出目标文件夹
        format: 'cjs', // 输出 commonjs 文件
      },
    ],
    // 这些依赖的作用上文提到过
    plugins: [
      nodeResolve(),
      externals({
        devDeps: false, // 可以识别我们 package.json 中的依赖当作外部依赖处理 不会直接将其中引用的方法打包出来
      }),
      typescript(),
      json(),
      commonjs(),
      terser(),
    ],
  },
]);

实现场景

实现的命令

vp-create-tool create 自动初始化项目,提供多种模板选择(如 Vue 2、Vue 3、TypeScript 支持),并通过交互式配置生成项目结构。

vp-create-tool update 更新命令行工具

create 编写

commander()
  1. 首先初始化一个Command对象,传入的参数作为我们的指令名称。
  2. 通过program可以执行cli的一些提示,比如-v,--version, create,description 等
  3. 在 action 可以放要处理的的逻辑
// index.ts
import { Command } from "commander";
import { version } from "../package.json";
import create from "./command/create";
import update from "./command/update";
// 命令行中使用 vp-cli-tools xxx 即可触发
const program = new Command("vp-cli-tools");
program.version(version, "-v , --version");

program
  .command("create")
  .description("创建一个新项目")
  .argument("[name]", "项目名称")
  .action(async (name?: string) => {
    await create(name);
  });

program
  .command("update")
  .description("更新vp-cli-tools")
  .action(() => {
    update();
  });
program.parse();

执行完 npm install vp-cli-tools -g 之后,在任意目录执行 vp-cli-tools,就会输出我们的通过 commander 配置的属性和方法

image.png

create(name) 方法的具体实现

  1. 执行vp-cli-tools create 时,检查是否传入项目名
// command/create.ts
import { select, input } from "@inquirer/prompts"; // 交互工具库:input 代表直接输入,select 代表选择
import fs from "fs-extra"; //fs-extra
import path from "path";
import { clone } from "../utils/clone";
import { log } from "../utils/log";
import axios, { AxiosResponse } from "axios";
import lodash from "lodash";
import chalk from "chalk";
import { name, version } from "../../package.json";

export default async function create(prjName?: string) {
  // 文件名称未传入需要输入
  if (!prjName) {
    prjName = await input({ message: "请输入项目名称" });
  }
 

image.png 2. 如果文件已存在需要让用户判断是否覆盖原文件

  const filePath = path.resolve(process.cwd(), prjName);

  if (fs.existsSync(filePath)) {
    const run = await isOverWrite(prjName);
    if (run) {
      await fs.remove(filePath);
    } else return;
  }
  
  export const isOverWrite = async (fileName: string) => {
  log.warning(`${fileName} 文件已存在 !`);
  return select({
    message: "是否覆盖原文件: ",
    choices: [
      { name: "覆盖", value: true },
      { name: "取消", value: false },
    ],
  });
};
  1. 选择模板

就是预设了一个模版,将模版转为 select中 choices 需要的格式

// 这里保存了我写好的预设模板
export const templates: Map<string, TemplateInfo> = new Map([
  [
    "Vite5-Vue3-Typescript-template",
    {
      name: "Vue-admin-template",
      downloadUrl: "https://github.com/github-learning/vue3-admin", // 为提高github 访问速度,使用 kk 来加速
      description: "Vue3技术栈前端开发模板",
      branch: "main",
    },
  ],
  [
    "React-template",
    {
      name: "React-admin-template",
      downloadUrl: "https://github.com/github-learning/vue3-admin", // 目前还没有开发React 技术栈模版,暂时用Vue 替代
      description: "React技术栈前端开发模板",
      branch: "main",
    },
  ],
]);
const templateName = await select({
    message: "请选择需要初始化的模板:",
    choices: templateList,
  });

  1. 下载模版(这里主要逻辑封装在utils 里的 clone 函数中)

//  下载模板
  const gitRepoInfo = templates.get(templateName);

  if (gitRepoInfo) {
    await clone(gitRepoInfo.downloadUrl, prjName, [
      "-b",
      `${gitRepoInfo.branch}`,
    ]);
  } else {
    log.error(`${templateName} 模板不存在`);
  }
  1. clone 函数

主要就是利用 simple-git 去拉取git 仓库代码

git.clone(url, prjName, options), "代码下载中: ", { estimate: 8000, // 展示预估时间 })

import simpleGit, { SimpleGit, SimpleGitOptions } from "simple-git";
import { log } from "./log";
import createLogger from "progress-estimator";
import chalk from "chalk";

const figlet = require("figlet");

const logger = createLogger({
  // 初始化进度条
  spinner: {
    interval: 300, // 变换时间 ms
    frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].map((item) =>
      chalk.blue(item)
    ), // 设置加载动画
  },
});
const goodPrinter = async () => {
  const data = await figlet.textSync("欢迎使用 vp-cli-tools 脚手架", {
    font: "Standard",
  });
  console.log(chalk.rgb(40, 156, 193).visible(data));
};

// 下面就是一些相关的提示

const gitOptions: Partial<SimpleGitOptions> = {
  baseDir: process.cwd(), // 根目录
  binary: "git",
  maxConcurrentProcesses: 6, // 最大并发进程数
};

export const clone = async (
  url: string,
  prjName: string,
  options: string[]
): Promise<any> => {
  const git: SimpleGit = simpleGit(gitOptions);
  try {
    // 开始下载代码并展示预估时间进度条
    await logger(git.clone(url, prjName, options), "代码下载中: ", {
      estimate: 8000, // 展示预估时间
    });

    // 下面就是一些相关的提示
    console.log();
    console.log(chalk.blueBright(`==================================`));
    console.log(chalk.blueBright(`=== 欢迎使用 vp-cli-tools 脚手架 ===`));
    console.log(chalk.blueBright(`==================================`));
    console.log();

    log.success(`项目创建成功 ${chalk.blueBright(prjName)}`);
    log.success(`执行以下命令启动项目:`);
    log.info(`cd ${chalk.blueBright(prjName)}`);
    log.info(`${chalk.yellow("pnpm")} install`);
    log.info(`${chalk.yellow("pnpm")} run dev`);
    goodPrinter();
  } catch (err: any) {
    log.error("下载失败");
    log.error(String(err));
  }
};

至此 代码就 就clone 到本地了

image.png

npm 发包

  1. npm login
  2. npm publish

难点疑点

1. 不同系统的兼容性问题

终归到底,实现的是一个node js 脚本,最后使用 node xxx.js 执行,那有没有一种方案,解决不同系统的兼容性问题,将其转变为可执行的文件, shebang 机制,详见上面

2. 调用方式的选择

目前使用的 node ./dist/index.js create xxx, 等安装vp-cli-tools 后,在生产环境就可以使用vp-cli-tools 来替代开发环境中 node ./dist/index.js,比如 vp-cli-tools create xxx

全局调用方式的弊端(更新不及时)

当我们使用vite / vue 的时候,官方的cli 更新,对我们是无感的,因为他是放在npm 上,我们使用npm create vite 的时候,会自动拉取最新版本

但是当我们把 脚手架当成全局 脚本 执行, vp-cli-tools create xxx 就相当于 npm create xxx, 没有npm 做屏障,需要自己维vp-cli-tools 的版本,做一个checkVseriosn的 校验

npm create xxx 怎么实现

当我们尝试通过 npm create vp-cli-tools 来运行cli

会报错 registry.npmjs.org/create-vp-c… 但其实我们的名字是vp-cli-tools,说明对于npm 上面的cli ,需要满足他的规范,将名字 以create- 开头, 那么我们修改package.json 中的name 即可,然后就可以使用 npm create vp-cli-tools ,

image.png

如果使用全局的调用方式,该怎么更新版本 ?

安装依赖时,检测当前本地的版本,和线上版本做一个对比,如果低于线上版本,则waring 提示用户,cli 升级(npm install vp-cli-tools -g),也可以实现一个update 函数,内部其实也是执行 npm install vp-cli-tools -g

checkVersion
// 入口指令 index.ts
program
  .command("update")
  .description("更新vp-cli-tools")
  .action(() => {
    update();
  });
// create 时 version 对比  create.ts
export const getNpmLatestVersion = async (npmName: string) => {
  // data['dist-tags'].latest 为最新版本号
  console.log("name", npmName);
  try {
    const { data } = (await getNpmInfo(npmName)) as AxiosResponse;
    console.log(
      "%c [  ]-57",
      "font-size:13px; background:pink; color:#bf2c9f;",
      data
    );
    return data["dist-tags"].latest;
  } catch (error) {
    console.log("error", error);
  }
};
// npm 包提供了根据包名称查询包信息的接口// 我们在这里直接使用 axios 请求调用即可
export const getNpmInfo = async (npmName: string) => {
  const npmUrl = "https://registry.npmjs.org/" + npmName;
  console.log("npmUrl", npmUrl);
  let res = {};
  try {
    res = await axios.get(npmUrl);
  } catch (err) {
    log.error(err as string);
  }
  console.log("res", res);
  return res;
};

export const checkVersion = async (name: string, curVersion: string) => {
  const latestVersion = await getNpmLatestVersion(name);
  const need = lodash.gt(latestVersion, curVersion);
  if (need) {
    log.info(
      `-----检测到 vp-cli-tools 最新版:${chalk.blueBright(
        latestVersion
      )} 当前版本:${chalk.blueBright(curVersion)} ~`
    );
    log.info(
      `可使用 ${chalk.yellow("pnpm")} install vp-cli-tools@latest 更新 ~`
    );
  }
  return need;
};
// create  主函数 调用 
await checkVersion(name, version); // 检测版本更新
update
import process from "child_process";
import chalk from "chalk";
import ora from "ora";
import os from "os"; // 导入 os 模块
const spinner = ora({
  text: "vp-cli-tools 正在更新",
  spinner: {
    interval: 300,
    frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].map((item) =>
      chalk.blue(item)
    ), // 设置加载动画
  },
});

export default function update() {
  spinner.start();

  process.exec(
    "npm install vp-cli-tools@latest -g",
    (error, stdout, stderr) => {
      spinner.stop();

      // 判断操作系统类型
      const platform = os.platform();

      // 如果安装没有权限
      if (error && stderr.includes("EACCES")) {
        console.log(
          chalk.red("没有权限进行全局安装。请尝试使用以下命令重试:")
        );
        if (platform === "linux" || platform === "darwin") {
          // 针对 Linux/macOS 系统
          console.log(
            chalk.yellow(
              "sudo npm install vp-cli-tools@latest -g  // 对于 Linux/macOS 用户"
            )
          );
        } else if (platform === "win32") {
          // 针对 Windows 系统
          console.log(chalk.yellow("管理员权限执行命令 // 对于 Windows 用户"));
        }
        return;
      }

      if (!error) {
        console.log(chalk.green("更新成功"));
      } else {
        console.log(chalk.red(error));
      }
    }
  );
}

自动安装依赖 / 启动项目

上面我们下载完项目,会返回下载成功的提示,但是我们直接用脚本安装依赖运行项目,不是更方便

// 在 utils/clone.ts 中,新增 自动安装 依赖和 自动运行项目


// 安装项目依赖
const installDependencies = (prjName: string): Promise<void> => {
  const projectDir = path.join(process.cwd(), prjName);

  return new Promise((resolve, reject) => {
    const npmInstall = spawn("npm", ["install", "--verbose", "--force"], {
      cwd: projectDir,
      stdio: "inherit", // 将子进程的输出直接映射到父进程(当前进程)的输出
      shell: true, // 使用 shell,确保命令在 Windows 和其他平台上都能运行
    });

    npmInstall.on("close", (code) => {
      if (code === 0) {
        console.log(chalk.green("依赖安装成功"));
        resolve();
      } else {
        reject(`依赖安装失败,退出代码: ${code}`);
      }
    });

    npmInstall.on("error", (err) => {
      reject(`依赖安装时发生错误: ${err.message}`);
    });
  });
};

// 运行项目
const runProject = (prjName: string): Promise<void> => {
  return new Promise((resolve, reject) => {
    const projectDir = path.join(process.cwd(), prjName);
    const spinnerTip = ora("项目启动中...").start();

    // 检查项目目录是否存在
    if (!fs.existsSync(projectDir)) {
      spinnerTip.fail("项目目录不存在");
      reject(new Error("项目目录不存在"));
      return;
    }

    // 使用 spawn 执行 pnpm run serve
    const serveProcess = spawn("pnpm", ["run", "serve"], {
      cwd: projectDir,
      stdio: "pipe", // 子进程的输出流式处理
      shell: true, // 确保在跨平台运行时正常
    });

    serveProcess.stdout.on("data", (data) => {
      const output = data.toString();
      console.log(chalk.green(output)); // 实时打印日志

      // 检测到启动完成的标志
      if (output.includes("App running at")) {
        spinnerTip.succeed("项目启动成功!");
        resolve(); // 完成 Promise
      }
    });

    serveProcess.stderr.on("data", (data) => {
      console.error(chalk.red(data.toString()));
    });

    serveProcess.on("close", (code) => {
      if (code !== 0) {
        spinnerTip.fail("项目启动失败");
        reject(new Error(`项目启动失败,退出代码: ${code}`));
      }
    });

    serveProcess.on("error", (err) => {
      spinnerTip.fail("项目启动失败");
      reject(new Error(`项目启动失败: ${err.message}`));
    });
  });
};


await installDependencies(prjName);
await runProject(prjName);
    
    

这里遇到一个问题,就是使用 exec 去执行逻辑,无法实时输出流信息,在网络比较卡的时候,卡在哪里,就跟尴尬,了解到 exec 主要是将子进程的输出缓冲到内存中,并在子进程执行完毕后一次性通过回调返回,并不适合 我们实时交互的功能,后来采用 spawn 实时地与子进程进行交互并获取其输出,但是 一般项目的流到 App running at 就终止后续流的输出,我们可以在这个时候resolve, 等待下次文件变更,再更新流

内部私有 / 存放在 npm 上

以上都是 发布到npm 公共区,但是当我们有自己的服务器,并不想公开,比如我们使用的是 Nexus,那么就可以使用

步骤 1:将包发布到 Nexus

你可以使用 npm publish 命令将包发布到你在 Nexus 中创建的私有仓库。

  1. 确保你已经在 Nexus 上配置了适当的权限。如果你还没有配置,可以在 Nexus 的用户管理页面中创建用户并为其分配适当的权限。
  2. 登录 Nexus:
npm login --registry=http://localhost:8081/repository/npm-private-repo/

你需要输入用户名、密码和邮箱(这些信息是你在 Nexus 中配置的)。

  1. 发布 npm 包到 Nexus:
npm publish --registry=http://localhost:8081/repository/npm-private-repo/

此时,npm 包就会上传到 Nexus 的私有仓库中。

步骤 2:安装私有仓库中的包

现在,你可以从你的私有仓库安装包。如果你已经在 .npmrc 中配置了仓库地址,你可以直接通过 npm install 安装私有包:


npm install <package-name>

源设置

  1. .npmrc 文件中添加以下配置,将 Nexus 的 npm 仓库作为默认仓库:
registry=http://localhost:8081/repository/npm-private-repo/
  1. 或者使用nrm 工具

image.png

  1. 通过 配置package.json

这样就和发布到npm一样了,不需要在制定publish 的源头

"publishConfig": {
    "registry": "http://192.168.xx.xx:6056/repository/npm-hosted/",
    "access": "public"
  },

fs / fs-extra 相关

2. 增强功能(fs-extra

fs-extra 是对 fs 模块的增强,它是基于 fs 构建的,但提供了更多的功能和便捷的 API,特别是一些常用的文件操作功能。fs-extra 包含 fs 模块的所有方法,并添加了以下几个有用的扩展功能:

a) fs-extra 提供的额外功能
  • fs.copy() :用来复制文件或目录,可以进行深度复制(递归复制目录)。fs 本身没有 copy 方法,需要手动实现。

  • fs.remove() :删除文件或目录,包括非空目录。fs.rmdir 只支持删除空目录,而 fs.remove 则可以删除非空目录。

  • fs.ensureFile() :确保文件存在,如果文件不存在,则创建文件。等价于 fs.writeFile,但如果目标文件不存在,会先创建空文件,再写入数据。

  • fs.ensureDir() :确保目录存在。如果目录不存在,创建目录。类似于 fs.mkdir,但它会自动创建不存在的父目录(递归创建)。

  • fs.emptyDir() :清空目录中的所有文件和子目录。

  • fs.move() :用来移动文件或目录,类似于 fs.rename,但 fs.rename 在某些文件系统上可能不起作用(例如,跨越不同的文件系统时)。

  • 如果你只需要简单的文件操作,fs 是足够的。

  • 如果你需要更多便捷的文件操作,像递归复制、删除非空目录等,或者希望使用更现代的 API(如 async/await),fs-extra 提供了更多功能和更易用的接口。

源代码地址

github github.com/github-lear…

npm www.npmjs.com/package/vp-…