構(gòu)建模塊化 CLI:Lerna + Commander 打造靈活的基礎(chǔ)腳手架

在現(xiàn)代軟件開發(fā)中,創(chuàng)建 定制化的命令行工具(CLI) 已成為滿足公司業(yè)務(wù)需求的關(guān)鍵一環(huán)。這類工具可以輔助執(zhí)行諸如代碼檢查嚼摩、項(xiàng)目初始化等任務(wù)。為了提高開發(fā)效率并簡化維護(hù)過程,我們將功能模塊化枕面,并通過多個子包來組織這些功能愿卒。本文將介紹如何使用 Lerna 來管理一個多包項(xiàng)目,并基于 Commander 實(shí)現(xiàn)一個基礎(chǔ)的 CLI 腳手架框架潮秘。

初始化:創(chuàng)建入口文件

項(xiàng)目結(jié)構(gòu)

我們以 ice-basic-cli 為例掘猿,這是一個空的 CLI 項(xiàng)目。首先唇跨,通過 lerna init 初始化 Lerna 項(xiàng)目稠通,然后使用 lerna create cli 創(chuàng)建入口子包。這一步將在項(xiàng)目的根目錄下生成 packages/cli 文件夾买猖,其內(nèi)部結(jié)構(gòu)如下:

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 的入口文件改橘,它負(fù)責(zé)接收命令行參數(shù)并調(diào)用相應(yīng)的邏輯處理函數(shù)。為確保腳本可執(zhí)行玉控,我們在文件頂部添加了 shebang 行 (#!/usr/bin/env node)飞主,并且導(dǎo)入了 lib/index.js 中定義的入口函數(shù)。

// bin/cli.js
#!/usr/bin/env node
import entry from "../lib/index.js";
entry(process.argv);

對于不熟悉初始化命令中的 shebang 行(#!/usr/bin/env node)或 bin 入口文件概念的朋友高诺,建議參考 Node.js 構(gòu)建命令行工具:實(shí)現(xiàn) ls 命令的 -a 和 -l 選項(xiàng) 這篇文章碌识,它提供了詳細(xì)的解釋和示例。

命令行接口實(shí)現(xiàn)

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('初始化新項(xiàng)目')
        .action((name) => {
            console.log(`>> Initializing project: ${name}`);
        });
    cli.parse(args);
}

同時牡拇,在 lib/createCli.js 中魁瞪,我們封裝了 Commander 的初始化設(shè)置,使得其他部分可以復(fù)用此配置惠呼。

import { program } from "commander";
export default function createCli() {
  return program
    .name("@ice-basic-cli/cli")
    .version("0.0.1", "-v, --version", "顯示當(dāng)前版本")
    .option("-d, --debug", "開啟調(diào)試模式", false);
}

包配置與依賴安裝

為了使我們的 CLI 可以全局調(diào)用导俘,需要正確配置 package.json 中的 bin 字段指向入口文件。此外剔蹋,我們還指定了 "type": "module" 以啟用 ES Module 支持旅薄,從而保證與最新的 JavaScript 生態(tài)系統(tǒng)的兼容性。

{  
  "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 創(chuàng)建本地符號鏈接以便測試。

模塊化選擇:ES Modules vs CommonJS

在項(xiàng)目中律想,我們選擇了 ES Modules 作為默認(rèn)的模塊系統(tǒng)猎莲,而非傳統(tǒng)的 CommonJS绍弟。這是因?yàn)?ES Modules 更加現(xiàn)代化技即,提供了更好的互操作性和靜態(tài)分析支持。更重要的是樟遣,隨著越來越多的庫開始采用 ES Modules 格式而叼,保持一致的模塊化標(biāo)準(zhǔn)有助于減少潛在的問題身笤,確保項(xiàng)目的長期可持續(xù)性。

完成上述配置后葵陵,在 Git Bash 中運(yùn)行命令 npx @ice-basic-cli/cli 可以看到如下結(jié)果:

1_入口文件.png

抽象 Command 類:構(gòu)建模塊化 CLI 命令

為了讓命令行工具(CLI)中的命令更加實(shí)用液荸,并能作為獨(dú)立的子包使用,我們將命令邏輯抽象為一個通用的 Command 父類脱篙。這樣不僅提高了代碼的可維護(hù)性和復(fù)用性娇钱,也為后續(xù)擴(kuò)展奠定了基礎(chǔ)。

定義公共的 Command 父類

首先绊困,我們使用 lerna create command 創(chuàng)建一個新的子包來存放 Command 父類文搂。這將在項(xiàng)目的 packages/ 目錄下生成一個新的文件夾 command,其中包含所有必要的文件結(jié)構(gòu)秤朗。

command/lib/command.js 中定義 Command 類煤蹭,該類封裝了創(chuàng)建命令的基本邏輯,同時提供鉤子函數(shù)以支持命令執(zhí)行前后的自定義行為取视。

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());
    // 添加命令選項(xiàng)
    if (this.options?.length > 0) {
      this.options.forEach(option => cmd.option(...option));
    }
    // 設(shè)置命令的行為
    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",
}

實(shí)現(xiàn)具體的子類命令

接下來,我們創(chuàng)建一個特定的命令子類 InitCommand 來實(shí)現(xiàn) init 功能作谭。通過 lerna create init 創(chuàng)建新的子包稽物,修改 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 中實(shí)現(xiàn)繼承自 CommandInitCommand 類:

"use strict";
import Command from "@ice-basic-cli/command";
class InitCommand extends Command {
  get command() {
    return "init [name]";
  }
  get options() {
    return [["-f, --force", "是否強(qiáng)制更新", false]];
  }
  get description() {
    return "初始化項(xiàng)目";
  }
  action([name], { force }) {
    console.log(`Initializing project: ${name}, Force mode: ${force}`);
  }
}
function createInitCommand(instance) {
  return new InitCommand(instance);
}
export default createInitCommand;

最后一步是將新創(chuàng)建的 InitCommand 整合進(jìn)主 CLI 應(yīng)用折欠。為此姨裸,在 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);
}

此時怨酝,運(yùn)行 npx @ice-basic-cli/cli 時傀缩,能夠看到與之前一致的結(jié)果,但現(xiàn)在的架構(gòu)更加模塊化农猬,便于維護(hù)和擴(kuò)展赡艰。

工具函數(shù)的封裝與集成

在構(gòu)建復(fù)雜CLI工具時,通常會遇到一些通用的功能需求斤葱,比如路徑判斷慷垮、日志記錄等。為了提高代碼復(fù)用性和項(xiàng)目的模塊化程度揍堕,我們將這些功能封裝為獨(dú)立的子包料身,確保它們可以在項(xiàng)目中的任何地方使用。

創(chuàng)建 utils 子包

首先衩茸,通過 lerna create utils 命令創(chuàng)建一個新的子包來存放工具函數(shù)芹血,并修改默認(rèn)生成的文件結(jié)構(gòu)以適應(yīng) ES Modules 標(biāo)準(zhǔn)。具體步驟如下:

  1. 重命名并配置入口文件:將 lib/util.js 重命名為 lib/index.js,并在 package.json 中指定正確的入口點(diǎn)幔烛。

    {
        "name": "@ice-basic-cli/utils",
        "main": "lib/index.js",
        "type": "module",
    }
    
  2. 實(shí)現(xiàn)調(diào)試狀態(tài)檢測:在 lib/isDebug.js 中定義一個簡單的函數(shù)用于判斷是否啟用了調(diào)試模式啃擦。

    function isDebug() {
      return process.argv.includes("--debug") || process.argv.includes("-d");
    }
    export default isDebug;
    
  3. 統(tǒng)一封裝日志輸出:創(chuàng)建 lib/log.js 文件,借助 npmlog 庫實(shí)現(xiàn)統(tǒng)一的日志格式饿悬。首先安裝依賴:

    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令蛉,我們創(chuàng)建 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. 導(dǎo)出工具函數(shù):最后狡恬,在 lib/index.js 中導(dǎo)出所有工具函數(shù)珠叔,以便其他模塊可以方便地引用。

    "use strict";
    import log from "./log.js";
    import isDebug from "./isDebug.js";
    import { dirname, filename } from "./getPath.js";
    export { log, isDebug, dirname, filename };
    

集成工具函數(shù)到 CLI 子包

完成 utils 子包后弟劲,我們需要將其集成到主 CLI 應(yīng)用中运杭。這一步驟包括安裝依賴以及增強(qiáng)命令行接口的功能。

安裝工具函數(shù)包

執(zhí)行以下命令安裝 @ice-basic-cli/utils 作為依賴:

npm install @ice-basic-cli/utils --workspace=packages/cli

增強(qiáng)命令行接口功能

接下來函卒,我們可以進(jìn)一步完善 cli/lib/createCli.js 文件辆憔,添加自動獲取 package.json 版本號和名稱的能力,加入 NodeJS 版本校驗(yàn)报嵌,并監(jiān)聽未知命令虱咧。此外,還需要安裝幾個輔助庫:

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", "是否開啟調(diào)試模式", 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;
}

添加全局錯誤處理

為了提升用戶體驗(yàn)锚国,我們還在 cli/lib/index.js 中增加了全局錯誤捕獲機(jī)制腕巡,確保未處理的異常和未捕獲的 Promise 拒絕不會導(dǎo)致程序崩潰。

"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);
  }
}

優(yōu)先使用本地依賴

最后血筑,我們可以通過引入 import-local 來優(yōu)化 bin/cli.js 文件绘沉,使得如果本地項(xiàng)目存在同名命令行工具,則優(yōu)先使用本地版本豺总。這樣做不僅保證了開發(fā)環(huán)境的一致性车伞,還能加快命令執(zhí)行速度。

首先安裝依賴:

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("遠(yuǎn)程 cli");
  entry(process.argv.slice(2));
}

以上便是整個多包框架的構(gòu)建過程喻喳。通過這種方式另玖,我們不僅提高了CLI工具的功能性和靈活性,還增強(qiáng)了其可維護(hù)性和擴(kuò)展性表伦。

發(fā)布 npm

以 @組織名/包名 的格式發(fā)布 NPM 包谦去,首先需要在 npmjs.com 上注冊一個組織(Organization)。

2_npm 注冊.png

在發(fā)布前蹦哼,建議更新每個子包的版本號鳄哭。由于我們對整個項(xiàng)目進(jìn)行了修改,采用一鍵發(fā)布的方式更為方便纲熏。只需執(zhí)行以下命令即可發(fā)布所有修改過的子包:

npm publish --workspaces --access=public

該命令會遍歷所有的工作區(qū)妆丘,檢查是否有新的改動需要發(fā)布锄俄,并將這些改動以公共訪問權(quán)限發(fā)布到 NPM。

如果你對前端工程化有興趣飘痛,或者想了解更多相關(guān)的內(nèi)容,歡迎查看我的其他文章容握,這些內(nèi)容將持續(xù)更新宣脉,希望能給你帶來更多的靈感和技術(shù)分享~

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市剔氏,隨后出現(xiàn)的幾起案子塑猖,更是在濱河造成了極大的恐慌,老刑警劉巖谈跛,帶你破解...
    沈念sama閱讀 219,270評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件羊苟,死亡現(xiàn)場離奇詭異,居然都是意外死亡感憾,警方通過查閱死者的電腦和手機(jī)蜡励,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,489評論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來阻桅,“玉大人凉倚,你說我怎么就攤上這事∩┏粒” “怎么了稽寒?”我有些...
    開封第一講書人閱讀 165,630評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長趟章。 經(jīng)常有香客問我杏糙,道長,這世上最難降的妖魔是什么蚓土? 我笑而不...
    開封第一講書人閱讀 58,906評論 1 295
  • 正文 為了忘掉前任宏侍,我火速辦了婚禮,結(jié)果婚禮上蜀漆,老公的妹妹穿的比我還像新娘负芋。我一直安慰自己,他們只是感情好嗜愈,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,928評論 6 392
  • 文/花漫 我一把揭開白布旧蛾。 她就那樣靜靜地躺著,像睡著了一般蠕嫁。 火紅的嫁衣襯著肌膚如雪锨天。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,718評論 1 305
  • 那天剃毒,我揣著相機(jī)與錄音病袄,去河邊找鬼搂赋。 笑死,一個胖子當(dāng)著我的面吹牛益缠,可吹牛的內(nèi)容都是我干的脑奠。 我是一名探鬼主播,決...
    沈念sama閱讀 40,442評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼幅慌,長吁一口氣:“原來是場噩夢啊……” “哼宋欺!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起胰伍,我...
    開封第一講書人閱讀 39,345評論 0 276
  • 序言:老撾萬榮一對情侶失蹤齿诞,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后骂租,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體祷杈,經(jīng)...
    沈念sama閱讀 45,802評論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,984評論 3 337
  • 正文 我和宋清朗相戀三年渗饮,在試婚紗的時候發(fā)現(xiàn)自己被綠了但汞。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,117評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡互站,死狀恐怖特占,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情云茸,我是刑警寧澤是目,帶...
    沈念sama閱讀 35,810評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站标捺,受9級特大地震影響懊纳,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜亡容,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,462評論 3 331
  • 文/蒙蒙 一嗤疯、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧闺兢,春花似錦茂缚、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,011評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至桐磁,卻和暖如春悔耘,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背我擂。 一陣腳步聲響...
    開封第一講書人閱讀 33,139評論 1 272
  • 我被黑心中介騙來泰國打工衬以, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留缓艳,地道東北人。 一個月前我還...
    沈念sama閱讀 48,377評論 3 373
  • 正文 我出身青樓看峻,卻偏偏與公主長得像阶淘,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子互妓,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,060評論 2 355

推薦閱讀更多精彩內(nèi)容