找回密码
 立即注册
首页 业界区 业界 构建模块化 CLI:Lerna + Commander 打造灵活的基础脚手 ...

构建模块化 CLI:Lerna + Commander 打造灵活的基础脚手架

梨恐 前天 18:49
在现代软件开发中,创建 定制化的命令行工具(CLI) 已成为满足公司业务需求的关键一环。这类工具可以辅助执行诸如代码检查、项目初始化等任务。为了提高开发效率并简化维护过程,我们将功能模块化,并通过多个子包来组织这些功能。本文将介绍如何使用 Lerna 来管理一个多包项目,并基于 Commander 实现一个基础的 CLI 脚手架框架。
初始化:创建入口文件

项目结构

我们以 ice-basic-cli 为例,这是一个空的 CLI 项目。首先,通过 lerna init 初始化 Lerna 项目,然后使用 lerna create cli 创建入口子包。这一步将在项目的根目录下生成 packages/cli 文件夹,其内部结构如下:
  1. ice-basic-cli/
  2. ├── .git/
  3. ├── packages/
  4. │   └── cli/
  5. │       ├── __tests__
  6. │       │   └── cli.test.js
  7. │       ├── lib/
  8. │       │   └── index.js
  9. │       ├── bin/
  10. │       │   └── cli.js
  11. │       ├── package.json
  12. │       └── README.md
  13. ├── .gitignore
  14. ├── lerna.json
  15. └── package.json
复制代码
CLI 入口配置

cli/bin/cli.js 是 CLI 的入口文件,它负责接收命令行参数并调用相应的逻辑处理函数。为确保脚本可执行,我们在文件顶部添加了 shebang 行 (#!/usr/bin/env node),并且导入了 lib/index.js 中定义的入口函数。
  1. // bin/cli.js
  2. #!/usr/bin/env node
  3. import entry from "../lib/index.js";
  4. entry(process.argv);
复制代码
对于不熟悉初始化命令中的 shebang 行(#!/usr/bin/env node)或 bin 入口文件概念的朋友,建议参考 Node.js 构建命令行工具:实现 ls 命令的 -a 和 -l 选项 这篇文章,它提供了详细的解释和示例。
命令行接口实现

lib/index.js 提供了 CLI 的核心逻辑,包括对 Commander 的初始化和自定义命令的注册。这里我们定义了一个简单的 init 命令。
  1. import { program } from 'commander';
  2. import createCli from './createCli.js';
  3. export default function (args) {
  4.     const cli = createCli();
  5.    
  6.     // 定义命令及其行为
  7.     cli.command('init [name]')
  8.         .description('初始化新项目')
  9.         .action((name) => {
  10.             console.log(`>> Initializing project: ${name}`);
  11.         });
  12.     cli.parse(args);
  13. }
复制代码
同时,在 lib/createCli.js 中,我们封装了 Commander 的初始化设置,使得其他部分可以复用此配置。
  1. import { program } from "commander";
  2. export default function createCli() {
  3.   return program
  4.     .name("@ice-basic-cli/cli")
  5.     .version("0.0.1", "-v, --version", "显示当前版本")
  6.     .option("-d, --debug", "开启调试模式", false);
  7. }
复制代码
包配置与依赖安装

为了使我们的 CLI 可以全局调用,需要正确配置 package.json 中的 bin 字段指向入口文件。此外,我们还指定了 "type": "module" 以启用 ES Module 支持,从而保证与最新的 JavaScript 生态系统的兼容性。
  1. {  
  2.   "name": "@ice-basic-cli/cli",
  3.   "version": "0.0.1",
  4.   "main": "bin/cli.js",
  5.     "bin": {
  6.       "@ice-basic-cli/cli": "./bin/cli.js"
  7.   },
  8. "type": "module",
  9. ...
  10. }
复制代码
接下来,通过 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  可以看到如下结果:
1.png

抽象 Command 类:构建模块化 CLI 命令

为了让命令行工具(CLI)中的命令更加实用,并能作为独立的子包使用,我们将命令逻辑抽象为一个通用的 Command 父类。这样不仅提高了代码的可维护性和复用性,也为后续扩展奠定了基础。
定义公共的 Command 父类

首先,我们使用 lerna create command 创建一个新的子包来存放 Command 父类。这将在项目的 packages/ 目录下生成一个新的文件夹 command,其中包含所有必要的文件结构。
在 command/lib/command.js 中定义 Command 类,该类封装了创建命令的基本逻辑,同时提供钩子函数以支持命令执行前后的自定义行为。
  1. class Command {
  2.   constructor(instance) {
  3.     if (!instance) {
  4.       throw new Error("Command instance must not be null");
  5.     }
  6.     this.program = instance;
  7.     const cmd = this.program.command(this.command);
  8.     cmd.description(this.description);
  9.     cmd.usage(this.usage);
  10.     // 添加命令生命周期钩子
  11.     cmd.hook('preAction', () => this.preAction());
  12.     cmd.hook('postAction', () => this.postAction());
  13.     // 添加命令选项
  14.     if (this.options?.length > 0) {
  15.       this.options.forEach(option => cmd.option(...option));
  16.     }
  17.     // 设置命令的行为
  18.     cmd.action((...params) => this.action(...params));
  19.   }
  20.   get command() {
  21.     throw new Error("The 'command' getter must be implemented in a subclass.");
  22.   }
  23.   get description() {
  24.     throw new Error("The 'description' getter must be implemented in a subclass.");
  25.   }
  26.   get options() {
  27.     return [];
  28.   }
  29.   get usage() {
  30.     return '[options]';
  31.   }
  32.   action(...params) {
  33.     throw new Error("The 'action' method must be implemented in a subclass.");
  34.   }
  35.   preAction() {}
  36.   postAction() {}
  37. }
  38. export default Command;
复制代码
接着,确保 package.json 文件中正确配置了名称和模块类型:
  1. {   
  2.     "name": "@ice-basic-cli/command",
  3.     "type": "module",
  4. }
复制代码
实现具体的子类命令

接下来,我们创建一个特定的命令子类 InitCommand 来实现 init 功能。通过 lerna create init 创建新的子包,修改 package.json 中的配置:
  1. {
  2.     "name": "@ice-basic-cli/init",
  3.     "type": "module",
  4. }
复制代码
并安装 @ice-basic-cli/command 作为依赖:
  1. npm install @ice-basic-cli/command --workspace=packages/cli
复制代码
然后,在 init/lib/init.js 中实现继承自 Command 的 InitCommand 类:
  1. "use strict";
  2. import Command from "@ice-basic-cli/command";
  3. class InitCommand extends Command {
  4.   get command() {
  5.     return "init [name]";
  6.   }
  7.   get options() {
  8.     return [["-f, --force", "是否强制更新", false]];
  9.   }
  10.   get description() {
  11.     return "初始化项目";
  12.   }
  13.   action([name], { force }) {
  14.     console.log(`Initializing project: ${name}, Force mode: ${force}`);
  15.   }
  16. }
  17. function createInitCommand(instance) {
  18.   return new InitCommand(instance);
  19. }
  20. export default createInitCommand;
复制代码
最后一步是将新创建的 InitCommand 整合进主 CLI 应用。为此,在 cli 子包中添加 @ice-basic-cli/init 依赖:
  1. npm install @ice-basic-cli/init --workspace=packages/cli
复制代码
并修改 cli/lib/index.js 文件,使其引用并注册 InitCommand:
  1. "use strict";
  2. import createCli from "./createCli.js";
  3. import createInitCommand from "@ice-basic-cli/init";
  4. export default function (args) {
  5.   const cli = createCli();
  6.   createInitCommand(cli);
  7.   cli.parse(args);
  8. }
复制代码
此时,运行 npx @ice-basic-cli/cli 时,能够看到与之前一致的结果,但现在的架构更加模块化,便于维护和扩展。
工具函数的封装与集成

在构建复杂CLI工具时,通常会遇到一些通用的功能需求,比如路径判断、日志记录等。为了提高代码复用性和项目的模块化程度,我们将这些功能封装为独立的子包,确保它们可以在项目中的任何地方使用。
创建 utils 子包

首先,通过 lerna create utils 命令创建一个新的子包来存放工具函数,并修改默认生成的文件结构以适应 ES Modules 标准。具体步骤如下:

  • 重命名并配置入口文件:将 lib/util.js 重命名为 lib/index.js,并在 package.json 中指定正确的入口点。
    1. {
    2.     "name": "@ice-basic-cli/utils",
    3.     "main": "lib/index.js",
    4.     "type": "module",
    5. }
    复制代码
  • 实现调试状态检测:在 lib/isDebug.js 中定义一个简单的函数用于判断是否启用了调试模式。
    1. function isDebug() {
    2.   return process.argv.includes("--debug") || process.argv.includes("-d");
    3. }
    4. export default isDebug;
    复制代码
  • 统一封装日志输出:创建 lib/log.js 文件,借助 npmlog 库实现统一的日志格式。首先安装依赖:
    1. npm install npmlog --save --workspace=packages/utils
    复制代码
    然后编写代码:
    1. import log from 'npmlog';
    2. import isDebug from './isDebug.js';
    3. if (isDebug()) {
    4.   log.level = "verbose";
    5. } else {
    6.   log.level = "info";
    7. }
    8. log.heading = "ice-basic-cli";
    9. log.addLevel("success", 2000, { fg: "green", bold: true, bg: "red" });
    10. export default log;
    复制代码
  • 处理 ES Module 的路径问题:由于 ES Modules 不直接支持 __filename 和 __dirname,我们创建 lib/getPath.js 来提供替代方案。
    1. import { fileURLToPath } from "url";
    2. import { dirname as pathDirname } from "path";
    3. export function dirname(importMeta) {
    4.   const file = filename(importMeta);
    5.   return file !== "" ? pathDirname(file) : "";
    6. }
    7. export function filename(importMeta) {
    8.   return importMeta.url ? fileURLToPath(importMeta.url) : "";
    9. }
    复制代码
  • 导出工具函数:最后,在 lib/index.js 中导出所有工具函数,以便其他模块可以方便地引用。
    1. "use strict";
    2. import log from "./log.js";
    3. import isDebug from "./isDebug.js";
    4. import { dirname, filename } from "./getPath.js";
    5. export { log, isDebug, dirname, filename };
    复制代码
集成工具函数到 CLI 子包

完成 utils 子包后,我们需要将其集成到主 CLI 应用中。这一步骤包括安装依赖以及增强命令行接口的功能。
安装工具函数包

执行以下命令安装 @ice-basic-cli/utils 作为依赖:
  1. npm install @ice-basic-cli/utils --workspace=packages/cli
复制代码
增强命令行接口功能

接下来,我们可以进一步完善 cli/lib/createCli.js 文件,添加自动获取 package.json 版本号和名称的能力,加入 NodeJS 版本校验,并监听未知命令。此外,还需要安装几个辅助库:
  1. npm install semver chalk fs-extra --save --workspace=packages/cli
复制代码
下面是更新后的 createCli.js 文件:
  1. "use strict";
  2. import { program } from "commander";
  3. import semver from "semver";
  4. import { dirname, log } from "@ice-basic-cli/utils";
  5. import { resolve } from "path";
  6. import fse from "fs-extra";
  7. import chalk from "chalk";
  8. const __dirname = dirname(import.meta);
  9. const pkgPath = resolve(__dirname, "../package.json");
  10. const pkg = fse.readJSONSync(pkgPath);
  11. function preAction() {
  12.   checkNodeVersion();
  13. }
  14. const LOWEST_NODE_VERSION = "18.0.0";
  15. function checkNodeVersion() {
  16.   if (!semver.gte(process.version, LOWEST_NODE_VERSION)) {
  17.     const message = `ice-basic-cli 需要安装 ${LOWEST_NODE_VERSION} 或更高版本的 Node.js`;
  18.     throw new Error(chalk.red(message));
  19.   }
  20. }
  21. export default function createCli() {
  22.   program
  23.     .name(Object.keys(pkg.bin)[0])
  24.     .usage("<command> [options]")
  25.     .version(pkg.version)
  26.     .option("-d, --debug", "是否开启调试模式", false)
  27.     .hook("preAction", preAction)
  28.     .on("option:debug", function () {
  29.       if (program.opts().debug) {
  30.         log.verbose("debug", "launch debug mode");
  31.       }
  32.     })
  33.     .on("command:*", function (obj) {
  34.       log.info("未知命令:" + obj[0]);
  35.     });
  36.   return program;
  37. }
复制代码
添加全局错误处理

为了提升用户体验,我们还在 cli/lib/index.js 中增加了全局错误捕获机制,确保未处理的异常和未捕获的 Promise 拒绝不会导致程序崩溃。
  1. "use strict";
  2. import createInitCommand from "@ice-basic-cli/init";
  3. import createCli from "./createCli.js";
  4. import { isDebug, log } from "@ice-basic-cli/utils";
  5. export default function (args) {
  6.   const program = createCli();
  7.   createInitCommand(program);
  8.   program.parse(args);
  9. }
  10. process.on("uncaughtException", (e) => printErrorLog(e, "uncaughtException"));
  11. process.on("unhandleRejection", (e) => printErrorLog(e, "unhandleRejection"));
  12. function printErrorLog(e) {
  13.   if (isDebug()) {
  14.     log.info(e);
  15.   } else {
  16.     log.info(e.message);
  17.   }
  18. }
复制代码
优先使用本地依赖

最后,我们可以通过引入 import-local 来优化 bin/cli.js 文件,使得如果本地项目存在同名命令行工具,则优先使用本地版本。这样做不仅保证了开发环境的一致性,还能加快命令执行速度。
首先安装依赖:
  1. npm install import-local --save --workspace=packages/cli
复制代码
然后修改 bin/cli.js 文件:
  1. #!/usr/bin/env node
  2. import importLocal from "import-local";
  3. import { log, filename } from "@ice-base-cli/utils";
  4. import entry from "../lib/index.js";
  5. const __filename = filename(import.meta);
  6. if (importLocal(__filename)) {
  7.   log.info("cli", "使用本次 cli");
  8. } else {
  9.   log.info("远程 cli");
  10.   entry(process.argv);
  11. }
复制代码
以上便是整个多包框架的构建过程。通过这种方式,我们不仅提高了CLI工具的功能性和灵活性,还增强了其可维护性和扩展性。
发布 npm

以 @组织名/包名 的格式发布 NPM 包,首先需要在 npmjs.com 上注册一个组织(Organization)。
2.png

在发布前,建议更新每个子包的版本号。由于我们对整个项目进行了修改,采用一键发布的方式更为方便。只需执行以下命令即可发布所有修改过的子包:
  1. npm publish --workspaces --access=public
复制代码
该命令会遍历所有的工作区,检查是否有新的改动需要发布,并将这些改动以公共访问权限发布到 NPM。
如果你对前端工程化有兴趣,或者想了解更多相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享~

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册