[譯] 如何使用 Node.js 構建一個命令列應用(CLI)

Yuqi發表於2019-06-01

atZ3n9vMFjjXDl_XxDtL_FCRSOt6EF0d8LnbMRCCJQUesMme8lzdGpCyMr4-wt1nlIGuoT29EI_tkVpuD_P2mxzbfhbn-ZPcqmZ5QCY_nM9d4ywWEYQxKYc9mjxUnp_uFJzMOMnr

Node.js 內建的命令列應用(CLI)讓你能夠在使用其龐大的生態系統的同時自動化地執行重複性的任務。並且,多虧了像 npmyarn 這樣的包管理工具,讓這些命令列應用可以很容易就在多個平臺上分發和使用。在本篇文章中,我將會講述為何需要寫 CLI,如何使用 Node.js 完成它,一些實用的包,以及你如何釋出你新寫好的 CLI。

為什麼要用 Node.js 建立命令列應用

Node.js 能夠如此流行的原因之一就是它有豐富的包生態系統,如今在 npm 註冊處 已經有超過 900000 個包。通過在 Node.js 中寫你自己的 CLI,你就可以進入這個生態系統,而其中也包含了鉅額數目的針對 CLI 的包。包括:

  • inquirerenquirer 或者 prompts,可用於處理複雜的輸入提示
  • email-prompt 可方便地提示郵箱輸入
  • chalkkleur 可用於彩色輸出
  • ora 是一個好看的載入提示
  • boxen 可以用於在你的輸出外加上邊框
  • stmux 可以提供一個和 tmux 類似的多終端介面
  • listr 可以展示程式列表
  • ink 可以使用 React 構建 CLI
  • meow 或者 arg 可以用於基本的引數解析
  • commanderyargs 可以用來比較複雜的引數解析,並支援子命令
  • oclif 是一個用於構建可擴充套件 CLI 的框架,作者是 Heroku(gluegun 可作為替換方案)

還有很多方便的方法可以用來使用 CLI,它們都發布在 npm 上,可以同時使用 yarnnpm 進行管理。例如 create-flex-plugin,是一個可以用來為 Twilio Flex 建立外掛的 CLI。你可以使用全域性命令來安裝它:

# 使用 npm 安裝:
npm install -g create-flex-plugin
# 使用 yarn 安裝:
yarn global add create-flex-plugin
# 安裝之後你就可以使用了:
create-flex-plugin
複製程式碼

或者它也可以作為專案依賴:

# 使用 npm 安裝:
npm install create-flex-plugin --save-dev
# 使用 yarn 安裝:
yarn add create-flex-plugin --dev
# 安裝之後命令將被儲存在
./node_modules/.bin/create-flex-plugin
# 或者通過由 npm 支援的 npx 使用:
npx create-flex-plugin
# 以及通過 yarn 使用:
yarn create-flex-plugin
複製程式碼

事實上,npx 能支援在沒有安裝的時候就執行 CLI。只需要執行 npx create-flex-plugin,這時候如果找不到本地或者全域性的已安裝版本,它將會自動下載這個包並放入快取中。

npm 6.1 版本後,npm inityarn 都支援使用 CLI 來構建專案,命令的名字形如 create-*。例如,剛才說的 create-flex-plugin,我們要做的就是:

# 使用 Node.js:
npm init flex-plugin
# 使用 Yarn:
yarn create flex-plugin
複製程式碼

構建第一個 CLI

如果你更喜歡看視訊學習,點選這裡在 YouTube 觀看教程

目前我們已經解釋過用 Node.js 建立 CLI 的原因,現在就讓我們開始構建一個 CLI 吧。在本篇教程裡,我們會使用 npm,但如果你想用 yarn,絕大多數的命令也都是相同的。確保你的系統中已經安裝了 Node.jsnpm

本篇教程中,我們將會建立一個 CLI,通過執行命令 npm init @your-username/project,它可以根據你的偏好構建一個新的專案。

通過執行如下程式碼,開始一個新的 Node.js 專案:

mkdir create-project && cd create-project
npm init --yes
複製程式碼

之後在專案的根目錄下建立一個名為 src/ 的目錄,然後將一個名為 cli.js 的檔案放在這個目錄下,並在檔案中寫入程式碼:

export function cli(args) {
 console.log(args);
}
複製程式碼

在這個函式中,我們將會解析引數邏輯並觸發實際需要的業務邏輯。接下來,我們需要建立 CLI 的入口。在專案根目錄下建立目錄 bin/ 然後建立一個名為 create-project 的檔案。寫入程式碼:

#!/usr/bin/env node

require = require('esm')(module /*, options*/);
require('../src/cli').cli(process.argv);
複製程式碼

在這一小片程式碼中,完成了幾件事情。首先,我們引入了一個名為 esm 的模組,這個模組讓我們能在其他檔案中使用 import。這和構建 CLI 並不直接相關,但是本篇教程中我們需要使用 ES 模組,而包 esm 讓我們能在 Node.js 版本不支援時無需程式碼轉換而使用 ES 模組。然後我們引入 cli.js 檔案並呼叫函式 cli,並將 process.argv 傳入,它是從命令列傳入函式指令碼的引數陣列。

在我們測試指令碼之前,需要通過執行如下命令安裝 esm 依賴:

npm install esm
複製程式碼

另外,我們還要將暴露 CLI 指令碼的需求同步給包管理器。方法是在 package.json 檔案中新增合適的入口。別忘了也要更新屬性 descriptionnamekeywordmain

{
 "name": "@your_npm_username/create-project",
 "version": "1.0.0",
 "description": "A CLI to bootstrap my new projects",
 "main": "src/index.js",
 "bin": {
   "@your_npm_username/create-project": "bin/create-project",
   "create-project": "bin/create-project"
 },
 "publishConfig": {
   "access": "public"
 },
 "scripts": {
   "test": "echo \"Error: no test specified\" && exit 1"
 },
 "keywords": [
   "cli",
   "create-project"
 ],
 "author": "YOUR_AUTHOR",
 "license": "MIT",
 "dependencies": {
   "esm": "^3.2.18"
 }
}
複製程式碼

如果你注意到 bin 屬性,你會發現我們將其定義為一個具有兩個鍵值對的物件。這個物件內定義的是包管理器將會安裝的 CLI 命令。在上述的例子中,我們為同一段指令碼註冊了兩個命令。一個通過加上了我們的使用者名稱來使用自己的 npm 作用域,另一個是為了方便使用的通用的 create-project 命令。

做好了這些,我們可以測試指令碼了。最簡單的測試方法是使用 npm link 命令。在你的專案終端中執行:

npm link
複製程式碼

這個命令將會全域性地安裝你當前專案的連結,所以當你更新程式碼的時候,也並不需要重新執行 npm link 命令。在執行 npm link 命令後,你的 CLI 命令應該已經可用了。試著執行:

create-project
複製程式碼

你應該可以看到類似的輸出:

[ '/usr/local/Cellar/node/11.6.0/bin/node',
  '/Users/dkundel/dev/create-project/bin/create-project' ]
複製程式碼

注意,這兩個地址依賴於你的專案地址和 Node.js 安裝地址,並會隨之變化而不同。並且這個陣列會隨著你增加引數而變長。試試執行:

create-project --yes
複製程式碼

此時輸出可以反映出新增了新的引數:

[ '/usr/local/Cellar/node/11.6.0/bin/node',
  '/Users/dkundel/dev/create-project/bin/create-project',
  '--yes' ]
複製程式碼

引數解析與輸入處理

現在我們準備解析傳入指令碼的引數,並賦予其邏輯意義。我們的 CLI 支援一個引數及多個選項:

  • [template]:我們支援開箱即用的多模版。如果使用者沒有傳入這個引數,我們將給出提示讓使用者選擇
  • --git:它將會執行 git init,來例項化一個新的 git 專案
  • --install:它將會自動地為專案安裝所有依賴
  • --yes:它將會跳過所有提示,直接使用預設選項

對於我們的專案,將會使用 inquirer 來提示輸入引數,並使用 arg 庫來解析 CLI 引數。通過執行如下命令來安裝依賴:

npm install inquirer arg
複製程式碼

首先我們來寫解析引數的邏輯,解析過程將會把引數解析為一個 options 物件,供我們使用。將如下程式碼加入到 cli.js 中:

import arg from 'arg';

function parseArgumentsIntoOptions(rawArgs) {
 const args = arg(
   {
     '--git': Boolean,
     '--yes': Boolean,
     '--install': Boolean,
     '-g': '--git',
     '-y': '--yes',
     '-i': '--install',
   },
   {
     argv: rawArgs.slice(2),
   }
 );
 return {
   skipPrompts: args['--yes'] || false,
   git: args['--git'] || false,
   template: args._[0],
   runInstall: args['--install'] || false,
 };
}

export function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 console.log(options);
}
複製程式碼

執行 create-project --yes,你將能看到 skipPrompt 會變成 true,或者試著傳遞其他引數例如 create-project cli,那麼 template 屬性就會被設定。

現在我們已經能解析 CLI 引數了,我們還需要新增方法來提示使用者輸入引數資訊,以及當 --yes 標誌被輸入的時候,略過提示資訊並使用預設引數。將如下程式碼加入 cli.js 檔案:

import arg from 'arg';
import inquirer from 'inquirer';

function parseArgumentsIntoOptions(rawArgs) {
// ...
}

async function promptForMissingOptions(options) {
 const defaultTemplate = 'JavaScript';
 if (options.skipPrompts) {
   return {
     ...options,
     template: options.template || defaultTemplate,
   };
 }

 const questions = [];
 if (!options.template) {
   questions.push({
     type: 'list',
     name: 'template',
     message: 'Please choose which project template to use',
     choices: ['JavaScript', 'TypeScript'],
     default: defaultTemplate,
   });
 }

 if (!options.git) {
   questions.push({
     type: 'confirm',
     name: 'git',
     message: 'Initialize a git repository?',
     default: false,
   });
 }

 const answers = await inquirer.prompt(questions);
 return {
   ...options,
   template: options.template || answers.template,
   git: options.git || answers.git,
 };
}

export async function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 options = await promptForMissingOptions(options);
 console.log(options);
}
複製程式碼

儲存檔案並執行 create-project,你將會看到這樣的模版選擇提示:

[譯] 如何使用 Node.js 構建一個命令列應用(CLI)

之後,你將會被詢問是否要初始化 git。兩個問題都作出先擇後,你將看到列印出了這樣的輸出:

{ skipPrompts: false,
  git: false,
  template: 'JavaScript',
  runInstall: false }
複製程式碼

嘗試執行 create-project -y 命令,此時所有的提示都會被忽略。你將會馬上看到命令列輸入的選項:

[譯] 如何使用 Node.js 構建一個命令列應用(CLI)

編寫程式碼邏輯

現在我們已經可以通過提示資訊以及命令列引數來決定對應的邏輯選項,下面我們來寫能夠建立專案的邏輯程式碼。我們的 CLI 將會和 npm init 命令類似,寫入一個已經存在的目錄,並會將所有在 templates 目錄下的檔案拷貝到專案中。我們也允許通過選項修改目標目錄地址,這樣你可以在其他專案中重用這段邏輯。

在我們寫邏輯程式碼之前,在專案根目錄下建立一個名為 templates 的目錄,並將目錄 typescriptjavascript 放在此目錄下。它們的名字都是小寫的版本,我們將會提示使用者從中選擇一個。在本篇文章中,我們就使用這兩個名字,但其實你可以使用你任意喜歡的命名。在這個目錄下,放入檔案 package.json 並加入任意你需要的專案基礎依賴,以及任意你需要拷貝到專案中的檔案。之後我們的程式碼將會把這些檔案全都拷貝到新的專案中。如果你需要一些創作靈感,你可以在 github.com/dkundel/create-project 檢視我使用的檔案。

為了遞迴的拷貝所有的檔案,我們將會使用一個名為 ncp 的庫。這個庫能夠支援跨平臺的遞迴拷貝,甚至有標識可以支援強制覆蓋已有檔案。另外,為了能夠展示彩色輸出,我們還將安裝 chalk。執行如下程式碼來安裝依賴:

npm install ncp chalk
複製程式碼

我們將會把專案核心的邏輯都放到 src/ 目錄下的 main.js 檔案中。建立新檔案並將如下程式碼加入:

import chalk from 'chalk';
import fs from 'fs';
import ncp from 'ncp';
import path from 'path';
import { promisify } from 'util';

const access = promisify(fs.access);
const copy = promisify(ncp);

async function copyTemplateFiles(options) {
 return copy(options.templateDirectory, options.targetDirectory, {
   clobber: false,
 });
}

export async function createProject(options) {
 options = {
   ...options,
   targetDirectory: options.targetDirectory || process.cwd(),
 };

 const currentFileUrl = import.meta.url;
 const templateDir = path.resolve(
   new URL(currentFileUrl).pathname,
   '../../templates',
   options.template.toLowerCase()
 );
 options.templateDirectory = templateDir;

 try {
   await access(templateDir, fs.constants.R_OK);
 } catch (err) {
   console.error('%s Invalid template name', chalk.red.bold('ERROR'));
   process.exit(1);
 }

 console.log('Copy project files');
 await copyTemplateFiles(options);

 console.log('%s Project ready', chalk.green.bold('DONE'));
 return true;
}
複製程式碼

這段程式碼會匯出一個名為 createProject 的新函式,這個函式會首先檢查指定的模版是否是可用的,檢查的方法是使用 fs.access 來檢查檔案的可讀性(fs.constants.R_OK),然後使用 ncp 將檔案拷貝到指定的目錄下。另外,在拷貝成功後,我們還要輸出一些帶顏色的日誌,內容為 DONE Project ready

之後,更新 cli.js,加入對新函式 createProject 的呼叫:

import arg from 'arg';
import inquirer from 'inquirer';
import { createProject } from './main';

function parseArgumentsIntoOptions(rawArgs) {
// ...
}

async function promptForMissingOptions(options) {
// ...
}

export async function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 options = await promptForMissingOptions(options);
 await createProject(options);
}
複製程式碼

為了測試我們的進度,在你的系統中某個位置例如 ~/test-dir 中建立一個新目錄,然後在這個資料夾內使用某個模版執行命令。比如:

create-project typescript --git
複製程式碼

你應該能看到一個通知,表明專案已經被建立,並且檔案已經被拷貝到了這個目錄下。

[譯] 如何使用 Node.js 構建一個命令列應用(CLI)

現在還有另外兩步需要做。我們希望可配置的初始化 git 並安裝依賴。為了完成這個,我們需要另外三個依賴:

  • execa 用於讓我們能在程式碼中很便捷的執行像 git 這樣的外部命令
  • pkg-install 用於基於使用者使用什麼而觸發命令 yarn installnpm install
  • listr 讓我們能指定任務列表,並給使用者一個整齊的程式概覽

通過執行如下命令來安裝依賴:

npm install execa pkg-install listr
複製程式碼

之後更新 main.js,加入如下程式碼:

import chalk from 'chalk';
import fs from 'fs';
import ncp from 'ncp';
import path from 'path';
import { promisify } from 'util';
import execa from 'execa';
import Listr from 'listr';
import { projectInstall } from 'pkg-install';

const access = promisify(fs.access);
const copy = promisify(ncp);

async function copyTemplateFiles(options) {
 return copy(options.templateDirectory, options.targetDirectory, {
   clobber: false,
 });
}

async function initGit(options) {
 const result = await execa('git', ['init'], {
   cwd: options.targetDirectory,
 });
 if (result.failed) {
   return Promise.reject(new Error('Failed to initialize git'));
 }
 return;
}

export async function createProject(options) {
 options = {
   ...options,
   targetDirectory: options.targetDirectory || process.cwd()
 };

 const templateDir = path.resolve(
   new URL(import.meta.url).pathname,
   '../../templates',
   options.template
 );
 options.templateDirectory = templateDir;

 try {
   await access(templateDir, fs.constants.R_OK);
 } catch (err) {
   console.error('%s Invalid template name', chalk.red.bold('ERROR'));
   process.exit(1);
 }

 const tasks = new Listr([
   {
     title: 'Copy project files',
     task: () => copyTemplateFiles(options),
   },
   {
     title: 'Initialize git',
     task: () => initGit(options),
     enabled: () => options.git,
   },
   {
     title: 'Install dependencies',
     task: () =>
       projectInstall({
         cwd: options.targetDirectory,
       }),
     skip: () =>
       !options.runInstall
         ? 'Pass --install to automatically install dependencies'
         : undefined,
   },
 ]);

 await tasks.run();
 console.log('%s Project ready', chalk.green.bold('DONE'));
 return true;
}
複製程式碼

這段程式碼將會在傳入 --git 或者使用者在提示中選擇了 git 的時候執行 git init,並且會在傳入 --install 的時候執行 npm install 或者 yarn,否則它將會跳過這兩個任務,並用一段訊息通知使用者如果他們想要自動安裝,請傳入 --install

首先刪除掉已經存在的測試資料夾然後建立一個新的,然後試一下效果如何。執行命令:

create-project typescript --git --install
複製程式碼

在你的資料夾中,你應該能看到 .git 資料夾和 node_modules 資料夾,表示 git 已經被初始化,以及 package.json 中指定的依賴已經被安裝了。

[譯] 如何使用 Node.js 構建一個命令列應用(CLI)

恭喜你,你的第一個 CLI 已經整裝待發了!

[譯] 如何使用 Node.js 構建一個命令列應用(CLI)

如果你希望你的程式碼能作為實際的模組使用,這樣其他人可以在他們的程式碼中複用你的邏輯,你還需要在目錄 src/ 下新增檔案 index.js,這個檔案暴露出了 main.js 的內容:

require = require('esm')(module);
require('../src/cli').cli(process.argv);
複製程式碼

接下來做什麼?

現在你的 CLI 程式碼已經準備好,你可以由此為基礎,向更多的方向發展。如果你僅僅想自己使用,而不想和其他人分享,那麼你就需要繼續沿用 npm link 即可。事實上,執行 npm init project 試試看,你的程式碼也將被觸發。

如果你想要和其他人分享你的程式碼模版,你可以將程式碼推送到 GitHub 來供參閱,或者更好的方法是,使用 npm publish 將它作為一個包推送到 npm 註冊處。在你釋出之前,你還需要確保在 package.json 檔案中新增一個 files 屬性,來指明那些檔案應該被髮布:

 },
 "files": [
   "bin/",
   "src/",
   "templates/"
 ]
}
複製程式碼

如果你想要檢查那個檔案將會被髮布,執行 npm pack --dry-run 然後檢視輸出。之後使用 npm publish 來發布你的 CLI。你可以在 @dkundel/create-project 找到我的專案,或者試試看執行 npm init @dkundel/project

還有很多的功能你可以加入進來。在我的專案中,我還新增了一些依賴,用於為我建立 LICENSECODE_OF_CONDUCT.md.gitignore。你可以在 GitHub 找到實現這些功能的原始碼,或著檢視上面提到的倉庫來擴充附加功能。如果你發現某個你覺得應該被列出在文章中而我並沒有列出的庫,或者想要給我看你的 CLI,儘管傳送訊息給我!

使用 JavaScript 還可以構建更多:

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章