年底了,大家都在冲最后的绩效了,为了 我的💰,满心欢喜(
迫不得已)利用自己宝贵的时间,帮公司写了一个通用项目框架 cli,希望大家用好,给一个好的反馈,让我钱包🥁🥁,过好年
背景
在现代前端开发中,脚手架工具(CLI)在团队项目中扮演着重要角色。我们的团队在项目开发中面临以下挑战:
- 开发效率低:项目初始化、代码生成和重复性操作耗费了大量时间。
- 缺乏统一规范:不同成员的代码风格不一致,提交记录混乱,影响项目质量。
- 构建部署复杂:手动构建和部署容易出错,影响交付效率。
为了提升团队开发效率、规范化流程和减少重复性工作,主人公主导了前端 CLI 脚手架工具的设计与开发。通过这个工具,我们实现了项目初始化、代码生成、规范化操作以及自动化 CI/CD 流程。
最终效果
命令行交互工具
CI / CD
技术要点
Node.js 开发
整个 CLI 工具基于 Node.js 开发。核心功能通过处理命令行参数实现,灵活满足不同开发场景。
依赖库
- 命令参数解析:
commander用于处理命令行参数和选项。 - 人机交互:
prompts实现与用户的动态交互,支持多选、确认等操作(inquirer/input / select)。 - 控制台高亮:
chalk和picocolors用于美化控制台输出,提高可读性。 - ora: 终端
loading美化工具 - figlet: 终端生成
艺术字 - git-clone: 下载项目
模版工具 - 模板处理:
handlebars作为模板引擎,用于生成动态代码片段。 - 私有 NPM 仓库:
verdaccio/nexus用于搭建团队的 NPM 私有仓库,实现包管理的自主化。 - fs-etra: 用来操作
文件目录瑞士军刀
概念引入
cli 大家都熟悉,大家熟悉的vue,在 终端 cv这个命令,然后 一个项目就 clone 到你的本地了
再比如 vite 提供的cli
cli,其实也很简单,就两个核心的点
-
通过
读取本地文件 / clone的方式 去拉取已经存在的项目base 模版 -
为了提供
可选择/可配置性,需要通过命令行界面(CLI),来实现读取 / clone已经存在的文件模版
整体开发流程
- 项目组 定制好 项目模版,规定存放的目录位置(直接放到cli 目录 / 存放在单独的私有仓库)
- 协定使用方式,采用全局 安装 / 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()
- 首先初始化一个Command对象,传入的参数作为我们的指令名称。
- 通过program可以执行cli的一些提示,比如-v,--version, create,description 等
- 在 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 配置的属性和方法
create(name) 方法的具体实现
- 执行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: "请输入项目名称" });
}
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 },
],
});
};
- 选择模板
就是预设了一个模版,将模版转为 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,
});
- 下载模版(这里主要逻辑封装在utils 里的 clone 函数中)
// 下载模板
const gitRepoInfo = templates.get(templateName);
if (gitRepoInfo) {
await clone(gitRepoInfo.downloadUrl, prjName, [
"-b",
`${gitRepoInfo.branch}`,
]);
} else {
log.error(`${templateName} 模板不存在`);
}
- 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 到本地了
npm 发包
- npm login
- 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 ,
如果使用全局的调用方式,该怎么更新版本 ?
安装依赖时,检测当前本地的版本,和线上版本做一个对比,如果低于线上版本,则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 中创建的私有仓库。
- 确保你已经在 Nexus 上配置了适当的权限。如果你还没有配置,可以在 Nexus 的用户管理页面中创建用户并为其分配适当的权限。
- 登录 Nexus:
npm login --registry=http://localhost:8081/repository/npm-private-repo/
你需要输入用户名、密码和邮箱(这些信息是你在 Nexus 中配置的)。
- 发布 npm 包到 Nexus:
npm publish --registry=http://localhost:8081/repository/npm-private-repo/
此时,npm 包就会上传到 Nexus 的私有仓库中。
步骤 2:安装私有仓库中的包
现在,你可以从你的私有仓库安装包。如果你已经在 .npmrc 中配置了仓库地址,你可以直接通过 npm install 安装私有包:
npm install <package-name>
源设置
- 在
.npmrc文件中添加以下配置,将 Nexus 的 npm 仓库作为默认仓库:
registry=http://localhost:8081/repository/npm-private-repo/
- 或者使用nrm 工具
- 通过 配置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…