構建模組化 CLI:Lerna + Commander 打造靈活的基礎腳手架

一颗冰淇淋發表於2024-12-22

在現代軟體開發中,建立 定製化的命令列工具(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);

對於不熟悉初始化命令中的 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 中實現繼承自 CommandInitCommand 類:

"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 標準。具體步驟如下:

  1. 重新命名並配置入口檔案:將 lib/util.js 重新命名為 lib/index.js,並在 package.json 中指定正確的入口點。

    {
        "name": "@ice-basic-cli/utils",
        "main": "lib/index.js",
        "type": "module",
    }
    
  2. 實現除錯狀態檢測:在 lib/isDebug.js 中定義一個簡單的函式用於判斷是否啟用了除錯模式。

    function isDebug() {
      return process.argv.includes("--debug") || process.argv.includes("-d");
    }
    export default isDebug;
    
  3. 統一封裝日誌輸出:建立 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;
    
  4. 處理 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) : "";
    }
    
  5. 匯出工具函式:最後,在 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);
}

以上便是整個多包框架的構建過程。透過這種方式,我們不僅提高了CLI工具的功能性和靈活性,還增強了其可維護性和擴充套件性。

釋出 npm

以 @組織名/包名 的格式釋出 NPM 包,首先需要在 npmjs.com 上註冊一個組織(Organization)。

在釋出前,建議更新每個子包的版本號。由於我們對整個專案進行了修改,採用一鍵釋出的方式更為方便。只需執行以下命令即可釋出所有修改過的子包:

npm publish --workspaces --access=public

該命令會遍歷所有的工作區,檢查是否有新的改動需要釋出,並將這些改動以公共訪問許可權釋出到 NPM。

如果你對前端工程化有興趣,或者想了解更多相關的內容,歡迎檢視我的其他文章,這些內容將持續更新,希望能給你帶來更多的靈感和技術分享~

相關文章