在現代軟件開發中,創建 定製化的命令行工具(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 中實現繼承自 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) : ""; } - 處理 ES Module 的路徑問題:由於 ES Modules 不直接支持
-
導出工具函數:最後,在
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。
如果你對前端工程化有興趣,或者想了解更多相關的內容,歡迎查看我的其他文章,這些內容將持續更新,希望能給你帶來更多的靈感和技術分享~