构建模块化 CLI:Lerna + Commander 打造灵活的基础脚手架
在现代软件开发中,创建 定制化的命令行工具(CLI) 已成为满足公司业务需求的关键一环。这类工具可以辅助执行诸如代码检查、项目初始化等任务。为了提高开发效率并简化维护过程,我们将功能模块化,并通过多个子包来组织这些功能。本文将介绍如何使用 Lerna
来管理一个多包项目,并基于 Commander
实现一个基础的 CLI
脚手架框架。
初始化:创建入口文件
项目结构
我们以 ice-basic-cli
为例,这是一个空的 CLI 项目。首先,通过 lerna init
初始化 Lerna 项目,然后使用 lerna create cli
创建入口子包。这一步将在项目的根目录下生成 packages/cli
文件夹,其内部结构如下:
ice-basic-cli/
├── .git/
├── packages/
│ └── cli/
│ ├── __tests__
│ │ └── cli.test.js
│ ├── lib/
│ │ └── index.js
│ ├── bin/
│ │ └── cli.js
│ ├── package.json
│ └── README.md
├── .gitignore
├── lerna.json
└── package.json
CLI 入口配置
cli/bin/cli.js
是 CLI 的入口文件,它负责接收命令行参数并调用相应的逻辑处理函数。为确保脚本可执行,我们在文件顶部添加了 shebang
行 (#!/usr/bin/env node),并且导入了 lib/index.js 中定义的入口函数。
// bin/cli.js
#!/usr/bin/env node
import entry from "../lib/index.js";
entry(process.argv.slice(2));
对于不熟悉初始化命令中的 shebang
行(#!/usr/bin/env node)或 bin
入口文件概念的朋友,建议参考 Node.js 构建命令行工具:实现 ls 命令的 -a 和 -l 选项 这篇文章,它提供了详细的解释和示例。
命令行接口实现
lib/index.js
提供了 CLI 的核心逻辑,包括对 Commander
的初始化和自定义命令的注册。这里我们定义了一个简单的 init 命令。
import { program } from 'commander';
import createCli from './createCli.js';
export default function (args) {
const cli = createCli();
// 定义命令及其行为
cli.command('init [name]')
.description('初始化新项目')
.action((name) => {
console.log(`>> Initializing project: ${name}`);
});
cli.parse(args);
}
同时,在 lib/createCli.js
中,我们封装了 Commander
的初始化设置,使得其他部分可以复用此配置。
import { program } from "commander";
export default function createCli() {
return program
.name("@ice-basic-cli/cli")
.version("0.0.1", "-v, --version", "显示当前版本")
.option("-d, --debug", "开启调试模式", false);
}
包配置与依赖安装
为了使我们的 CLI 可以全局调用,需要正确配置 package.json
中的 bin
字段指向入口文件。此外,我们还指定了 "type": "module"
以启用 ES Module
支持,从而保证与最新的 JavaScript 生态系统的兼容性。
{
"name": "@ice-basic-cli/cli",
"version": "0.0.1",
"main": "bin/cli.js",
"bin": {
"@ice-basic-cli/cli": "./bin/cli.js"
},
"type": "module",
...
}
接下来,通过 cnpm install commander --save --workspace=packages/cli
安装所需的 Commander 库,并通过 npm link --workspace=packages/cli
创建本地符号链接以便测试。
模块化选择:ES Modules vs CommonJS
在项目中,我们选择了 ES Modules
作为默认的模块系统,而非传统的 CommonJS
。这是因为 ES Modules
更加现代化,提供了更好的互操作性和静态分析支持。更重要的是,随着越来越多的库开始采用 ES Modules
格式,保持一致的模块化标准有助于减少潜在的问题,确保项目的长期可持续性。
完成上述配置后,在 Git Bash 中运行命令 npx @ice-basic-cli/cli
可以看到如下结果:
抽象 Command 类:构建模块化 CLI 命令
为了让命令行工具(CLI
)中的命令更加实用,并能作为独立的子包使用,我们将命令逻辑抽象为一个通用的 Command
父类。这样不仅提高了代码的可维护性和复用性,也为后续扩展奠定了基础。
定义公共的 Command 父类
首先,我们使用 lerna create command
创建一个新的子包来存放 Command
父类。这将在项目的 packages/
目录下生成一个新的文件夹 command
,其中包含所有必要的文件结构。
在 command/lib/command.js
中定义 Command
类,该类封装了创建命令的基本逻辑,同时提供钩子函数以支持命令执行前后的自定义行为。
class Command {
constructor(instance) {
if (!instance) {
throw new Error("Command instance must not be null");
}
this.program = instance;
const cmd = this.program.command(this.command);
cmd.description(this.description);
cmd.usage(this.usage);
// 添加命令生命周期钩子
cmd.hook('preAction', () => this.preAction());
cmd.hook('postAction', () => this.postAction());
// 添加命令选项
if (this.options?.length > 0) {
this.options.forEach(option => cmd.option(...option));
}
// 设置命令的行为
cmd.action((...params) => this.action(...params));
}
get command() {
throw new Error("The 'command' getter must be implemented in a subclass.");
}
get description() {
throw new Error("The 'description' getter must be implemented in a subclass.");
}
get options() {
return [];
}
get usage() {
return '[options]';
}
action(...params) {
throw new Error("The 'action' method must be implemented in a subclass.");
}
preAction() {}
postAction() {}
}
export default Command;
接着,确保 package.json 文件中正确配置了名称和模块类型:
{
"name": "@ice-basic-cli/command",
"type": "module",
}
实现具体的子类命令
接下来,我们创建一个特定的命令子类 InitCommand
来实现 init
功能。通过 lerna create init
创建新的子包,修改 package.json 中的配置:
{
"name": "@ice-basic-cli/init",
"type": "module",
}
并安装 @ice-basic-cli/command
作为依赖:
npm install @ice-basic-cli/command --workspace=packages/cli
然后,在 init/lib/init.js
中实现继承自 Command
的 InitCommand
类:
"use strict";
import Command from "@ice-basic-cli/command";
class InitCommand extends Command {
get command() {
return "init [name]";
}
get options() {
return [["-f, --force", "是否强制更新", false]];
}
get description() {
return "初始化项目";
}
action([name], { force }) {
console.log(`Initializing project: ${name}, Force mode: ${force}`);
}
}
function createInitCommand(instance) {
return new InitCommand(instance);
}
export default createInitCommand;
最后一步是将新创建的 InitCommand
整合进主 CLI 应用。为此,在 cli 子包中添加 @ice-basic-cli/init
依赖:
npm install @ice-basic-cli/init --workspace=packages/cli
并修改 cli/lib/index.js 文件,使其引用并注册 InitCommand:
"use strict";
import createCli from "./createCli.js";
import createInitCommand from "@ice-basic-cli/init";
export default function (args) {
const cli = createCli();
createInitCommand(cli);
cli.parse(args);
}
此时,运行 npx @ice-basic-cli/cli
时,能够看到与之前一致的结果,但现在的架构更加模块化,便于维护和扩展。
工具函数的封装与集成
在构建复杂CLI工具时,通常会遇到一些通用的功能需求,比如路径判断、日志记录等。为了提高代码复用性和项目的模块化程度,我们将这些功能封装为独立的子包,确保它们可以在项目中的任何地方使用。
创建 utils 子包
首先,通过 lerna create utils
命令创建一个新的子包来存放工具函数,并修改默认生成的文件结构以适应 ES Modules
标准。具体步骤如下:
重命名并配置入口文件:将
lib/util.js
重命名为lib/index.js
,并在package.json
中指定正确的入口点。{
"name": "@ice-basic-cli/utils",
"main": "lib/index.js",
"type": "module",
}
实现调试状态检测:在
lib/isDebug.js
中定义一个简单的函数用于判断是否启用了调试模式。function isDebug() {
return process.argv.includes("--debug") || process.argv.includes("-d");
}
export default isDebug;
统一封装日志输出:创建
lib/log.js
文件,借助npmlog
库实现统一的日志格式。首先安装依赖:npm install npmlog --save --workspace=packages/utils
然后编写代码:
import log from 'npmlog';
import isDebug from './isDebug.js'; if (isDebug()) {
log.level = "verbose";
} else {
log.level = "info";
} log.heading = "ice-basic-cli";
log.addLevel("success", 2000, { fg: "green", bold: true, bg: "red" });
export default log;
处理 ES Module 的路径问题:由于 ES Modules 不直接支持
__filename
和__dirname
,我们创建lib/getPath.js
来提供替代方案。import { fileURLToPath } from "url";
import { dirname as pathDirname } from "path";
export function dirname(importMeta) {
const file = filename(importMeta);
return file !== "" ? pathDirname(file) : "";
}
export function filename(importMeta) {
return importMeta.url ? fileURLToPath(importMeta.url) : "";
}
导出工具函数:最后,在
lib/index.js
中导出所有工具函数,以便其他模块可以方便地引用。"use strict";
import log from "./log.js";
import isDebug from "./isDebug.js";
import { dirname, filename } from "./getPath.js";
export { log, isDebug, dirname, filename };
集成工具函数到 CLI 子包
完成 utils
子包后,我们需要将其集成到主 CLI
应用中。这一步骤包括安装依赖以及增强命令行接口的功能。
安装工具函数包
执行以下命令安装 @ice-basic-cli/utils
作为依赖:
npm install @ice-basic-cli/utils --workspace=packages/cli
增强命令行接口功能
接下来,我们可以进一步完善 cli/lib/createCli.js
文件,添加自动获取 package.json
版本号和名称的能力,加入 NodeJS
版本校验,并监听未知命令。此外,还需要安装几个辅助库:
npm install semver chalk fs-extra --save --workspace=packages/cli
下面是更新后的 createCli.js
文件:
"use strict";
import { program } from "commander";
import semver from "semver";
import { dirname, log } from "@ice-basic-cli/utils";
import { resolve } from "path";
import fse from "fs-extra";
import chalk from "chalk";
const __dirname = dirname(import.meta);
const pkgPath = resolve(__dirname, "../package.json");
const pkg = fse.readJSONSync(pkgPath);
function preAction() {
checkNodeVersion();
}
const LOWEST_NODE_VERSION = "18.0.0";
function checkNodeVersion() {
if (!semver.gte(process.version, LOWEST_NODE_VERSION)) {
const message = `ice-basic-cli 需要安装 ${LOWEST_NODE_VERSION} 或更高版本的 Node.js`;
throw new Error(chalk.red(message));
}
}
export default function createCli() {
program
.name(Object.keys(pkg.bin)[0])
.usage("<command> [options]")
.version(pkg.version)
.option("-d, --debug", "是否开启调试模式", false)
.hook("preAction", preAction)
.on("option:debug", function () {
if (program.opts().debug) {
log.verbose("debug", "launch debug mode");
}
})
.on("command:*", function (obj) {
log.info("未知命令:" + obj[0]);
});
return program;
}
添加全局错误处理
为了提升用户体验,我们还在 cli/lib/index.js
中增加了全局错误捕获机制,确保未处理的异常和未捕获的 Promise 拒绝不会导致程序崩溃。
"use strict";
import createInitCommand from "@ice-basic-cli/init";
import createCli from "./createCli.js";
import { isDebug, log } from "@ice-basic-cli/utils";
export default function (args) {
const program = createCli();
createInitCommand(program);
program.parse(args);
}
process.on("uncaughtException", (e) => printErrorLog(e, "uncaughtException"));
process.on("unhandleRejection", (e) => printErrorLog(e, "unhandleRejection"));
function printErrorLog(e) {
if (isDebug()) {
log.info(e);
} else {
log.info(e.message);
}
}
优先使用本地依赖
最后,我们可以通过引入 import-local
来优化 bin/cli.js
文件,使得如果本地项目存在同名命令行工具,则优先使用本地版本。这样做不仅保证了开发环境的一致性,还能加快命令执行速度。
首先安装依赖:
npm install import-local --save --workspace=packages/cli
然后修改 bin/cli.js
文件:
#!/usr/bin/env node
import importLocal from "import-local";
import { log, filename } from "@ice-base-cli/utils";
import entry from "../lib/index.js";
const __filename = filename(import.meta);
if (importLocal(__filename)) {
log.info("cli", "使用本次 cli");
} else {
log.info("远程 cli");
entry(process.argv.slice(2));
}
以上便是整个多包框架的构建过程。通过这种方式,我们不仅提高了CLI工具的功能性和灵活性,还增强了其可维护性和扩展性。
发布 npm
以 @组织名/包名 的格式发布 NPM 包,首先需要在 npmjs.com 上注册一个组织(Organization)。
在发布前,建议更新每个子包的版本号。由于我们对整个项目进行了修改,采用一键发布的方式更为方便。只需执行以下命令即可发布所有修改过的子包:
npm publish --workspaces --access=public
该命令会遍历所有的工作区,检查是否有新的改动需要发布,并将这些改动以公共访问权限发布到 NPM。
如果你对前端工程化有兴趣,或者想了解更多相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享~
构建模块化 CLI:Lerna + Commander 打造灵活的基础脚手架的更多相关文章
- 18-Node.js学习笔记-Express-请求处理-构建模块化路由
构建模块化路由 const express = require('express') //创建路由对象 const home = express.Router(); //将路由和请求路径进行匹配 ap ...
- Golang如何快速构建一个CLI小工示例
这篇文章主要为大家介绍了Golang如何快速构建一个CLI小工具详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪 如何Golang快速构建一个CLI小工具 在现实开发的 ...
- 使用Razor Generator构建模块化ASP.NET MVC应用程序
在构建Web应用程序的时候,我们很难做到模块化的开发,这是因为Web应用程序不仅仅包含编译的C#代码,还包含了js.css和aspx等资源. 在ASP.NET MVC中,我们发布应用程序的时候,还会包 ...
- [Vue 牛刀小试]:第十七章 - 优化 Vue CLI 3 构建的前端项目模板(1)- 基础项目模板介绍
一.前言 在上一章中,我们开始通过 Vue CLI 去搭建属于自己的前端 Vue 项目模板,就像我们 .NET 程序员在使用 asp.net core 时一样,我们更多的会在框架基础上按照自己的开发习 ...
- 【神经网络与深度学习】chainer边运行边定义的方法使构建深度学习网络变的灵活简单
Chainer是一个专门为高效研究和开发深度学习算法而设计的开源框架. 这篇博文会通过一些例子简要地介绍一下Chainer,同时把它与其他一些框架做比较,比如Caffe.Theano.Torch和Te ...
- javascript 构建模块化开发
在使用 sea.js .require.js . angular 的时候. 我们使用到 define . module(require) 的方式,定义模块,和依赖模块 下面给出 define 和 m ...
- 利用 Composer 一步一步构建自己的 PHP 框架(一)——基础准备
『Composer 一统天下的时代已经到来!』——白岩松 “一个时代结束了,另一个时代开始了.” Framework Interoperability Group(框架可互用性小组),简称 FIG,成 ...
- 那些年读过的书《Java并发编程实战》一、构建线程安全类和并发应用程序的基础
1.线程安全的本质和线程安全的定义 (1)线程安全的本质 并发环境中,当多个线程同时操作对象状态时,如果没有统一的状态访问同步或者协同机制,不同的线程调度方式和不同的线程执行次序就会产生不同的不正确的 ...
- 二进制入门-打造Linux shellcode基础篇
0x01 前言 本文的目的不是为了介绍如何进行恶意的破坏性活动,而是为了教会你如何去防御此类破坏性活动,以帮助你扩大知识范围,完善自己的技能,如有读者运用本文所学技术从事破坏性活动,本人概不负责. ...
- 基于 Lerna 管理 packages 的 Monorepo 项目最佳实践
本文首发于 vivo互联网技术 微信公众号 https://mp.weixin.qq.com/s/NlOn7er0ixY1HO40dq5Gag作者:孔垂亮 目录 一.背景二.Monorepo vs M ...
随机推荐
- JNI和HAL 的区别
JNI (Java Native Interface) 和 HAL (Hardware Abstraction Layer) 在 Android 系统中都扮演着与本地代码交互的重要角色,但它们的功能和 ...
- 批量读取nii文件的shape
import SimpleITK as sitk from glob import glob import os path = glob(r"D:\MyData\date\*") ...
- 五款扩展组件齐发 —— Volcano、Keda、Crane-scheduler 等,邀你体验
今年 3 月,KubeSphere 启动了首届扩展组件开发者训练营,吸引了 60 名开发者报名.经过一个半月的密集培训和实战演练,这些开发者成功打造了五款创新的扩展组件,现已全部上架至 KubeSph ...
- Machine Learning Week_6 Adjust the Model.
目录 0 Advice for Applying Machine Learning 1 Evaluating a Learning Algorithm 1.1 Deciding What to Try ...
- WSGI、mini-web框架
阅读目录: 1.服务器动态资源请求 2.应用程序示例 3.Web 动态服务器 4.mini-web框架-1-文件结构 5.mini-web框架-2-显示页面 6.mini-web框架-3-替换模板 一 ...
- 轻量级网络-VoVNet 论文解读
摘要 1,介绍 2,高效网络设计的影响因素 2.1,内存访问代价 2.2,GPU计算效率 3,建议的方法 3.1,重新思考密集连接 3.2,One-Shot Aggregation 3.3,构建 Vo ...
- Java中“=”克隆理解
在Java中,对于基本类型可以使用"="来进行克隆,此时两个变量除了相等是没有任何关系的. 而对于引用类型却不能简单地使用"="进行克隆,这与java的内存空间 ...
- 强化学习环境gym/gymnasium下的atari环境的v0、v4、v5的说明
声明: 本文是最新版gym-0.26.2下Atari环境的安装以及环境版本v0,v4,v5的说明的部分更新和汇总,可以看作是更新和延续版本. 由于gym已经由openai公司独立出来,虽然开发团队和投 ...
- PSD.See 隐私政策声明
PSD.See will not collect any user privacy data. PSD.See 不会收集任何用户隐私数据.
- WiFi流量劫持—— 浏览任意页面即可中毒!
大家都知道公共场所的Wifi安全性很差,但并不清楚究竟有多差.大多以为只要不上QQ.不登陆网站账号就没事了,看看新闻小说什么的应该毫无关系. 的确如此,看看新闻网页没有涉及任何敏感的账号信息.即便是数 ...