用 Node.js 構(gòu)建 CLI 工具

CLI 與GUI介紹

命令行界面(英語:Command Line Interface,縮寫:CLI)用戶通過鍵盤輸入指令,計算機接收到指令后,予以執(zhí)行笔喉。

圖形用戶界面(英語:Graphical User Interface,縮寫:GUI)是指采用圖形方式顯示的計算機操作用戶界面疯兼。與早期計算機使用的命令行界面相比然遏,除了降低用戶的操作負擔(dān)之外贫途,對于新用戶而言吧彪,圖形界面對于用戶來說在視覺上更易于接受。

CLI 程序中的一些概念

命令

通常我們執(zhí)行的 CLI 程序本身就是一個命令(主命令)丢早,當(dāng) CLI 程序功能分類比較多的時候姨裸,可以根據(jù)子功能的不同提供更多的一些子命令秧倾,如:

// create 子命令
vue create <project-name>

// add 子命令
vue add <plugin-name>
參數(shù)

參數(shù)是配合著命令調(diào)用傳入的數(shù)據(jù),類似函數(shù)參數(shù)傀缩,如下 <project-name> app1就是參數(shù):

vue create app1
選項

選項是命令內(nèi)置好的一些選項那先,以供調(diào)用命令的時候根據(jù)不同的需要進項選配:

// -f 或 --force 選項(當(dāng)app1已存在的時候,-f 強制重新創(chuàng)建并覆蓋)
vue create app1 -f

bin 文件

通常赡艰,node.js 文件需要使用 node 命令來運行售淡,如:

node test.js

我們可以使用如下的方式來簡化腳本運行

#!/usr/bin/env node
// #! 行必須寫在文件第一行,指定該腳本解析器路徑
// /usr/bin/env => env: 獲取環(huán)境變量信息
// /usr/bin/env node => env | grep PATH => 從電腦的環(huán)境變量 PATH 中查找 node 并執(zhí)行

console.log('hello');

現(xiàn)在可以命令行中省略 node 執(zhí)行文件了

./test.js

命令行參數(shù)的獲取

使用 Node.js 內(nèi)置 process 對象的 argv 屬性來獲取這些數(shù)據(jù):

//app.js文件 
#!/usr/bin/env node

// process : 獲取到當(dāng)前程序運行的進程相關(guān)的一些信息和數(shù)據(jù)
// process.argv : 當(dāng)前程序運行的參數(shù)信息
// output
// ['node的路徑','當(dāng)前執(zhí)行文件的路徑','參數(shù)慷垮、選項'...]
console.log( process.argv );

//命令行
$ ./app.js -v
[
  'C:\\Program Files\\nodejs\\node.exe',
  'C:\\Users\\Desktop\\test\\app.js',
  '-v'
]

commander 庫

該庫對 process.argv 進行解析揖闸,并提供了更易用的 API

npm install commander

Commander類

通過實例化 Commander 類對象來完成 CLI 程序構(gòu)建。

const { Command } = require('commander');
const program = new Command();

或直接調(diào)用內(nèi)置構(gòu)建好的一個 實例對象:

const { program } = require('commander');

選項

通過 option 方法指定要解析的選項:

program.option('-v, --version', '這是參數(shù)的描述');
// 設(shè)置選項參數(shù)
program.option('-p, --port', '端口', 80);

可選

<> 表示必填料身。

program.option('-p, --port <port>', '端口', 80);

必填

[] 表示可選

program.option('-p, --port [port]', '端口', 80);

命令參數(shù)

program.argument('<username>', '登錄用戶名', '默認值');

處理函數(shù)

當(dāng)命令解析后的執(zhí)行函數(shù)

program.action((參數(shù)1汤纸,參數(shù)2, 選項列表, program) => {
  //...
});
解析
program.parse(process.argv)
使用示例
const { Command } = require('commander');
const program = new Command();

program.option('-v, --version', '這是參數(shù)的描述');
program.option('-p,--port [port]', '端口', '8888');
console.log( process.argv );

// 執(zhí)行動作 opts里包含傳入的參數(shù)
program.action((opts) => {
    console.log(`輸入的參數(shù)`, opts)
    if (opts.version) {
        console.log(`version: 1.0.0`)
    }
    if (opts.port) {
        console.log(`端口: ${opts.port}`)
    }
});

program.parse(process.argv)

命令行字體美化

chalk 庫

//安裝
npm i chalk
//使用
const chalk = require('chalk');
console.log(chalk.blue('Hello world!'));

交互式命令行

有時候,需要 CLI 程序能夠與用戶進行一些交互芹血,比如提供給用戶選項或者輸入些文本贮泞。

//安裝
npm install inquirer
使用示例
const inquirer = require('inquirer');

const promptOptions = [];

promptOptions.push({
    type: "input",
    name: "serverName",
    message: "請輸入應(yīng)用名稱",
    default: "app",
});
promptOptions.push({
    type: "checkbox",
    name: "middlewares",
    message: "請選擇要安裝的中間件",
    choices: ['koa-static-cache', 'koa-router', 'koa-body'],
    default: ['koa-static-cache', 'koa-router'],
});

inquirer.prompt(promptOptions).then(answer => {
    console.log('answer', answer)
    // answer {
    //     serverName: ' app2',
    //     middlewares: [ 'koa-static-cache', 'koa-router', 'koa-body' ]
    //   }
})

如何執(zhí)行shell

通過指令獲取參數(shù)和創(chuàng)建對應(yīng)的文件夾后,需要執(zhí)行npm init 和等npm i 指令

在這之前需要先了解下node如何實現(xiàn)多進程通信幔烛。因為我們需要在新創(chuàng)建的文件夾內(nèi)執(zhí)行命令啃擦。而在當(dāng)前cli的路徑內(nèi)打印出執(zhí)行消息。

Node.js 內(nèi)置 process_child(子進程)

process_child 提供了幾種方式來新建子進程

官網(wǎng)介紹地址:https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_child_process_spawn_command_args_options

新建子進程的方式

spawn :

子進程中執(zhí)行的是非node程序说贝,提供一組參數(shù)后议惰,執(zhí)行的結(jié)果以流(Stream)的形式返回。

exec:

子進程執(zhí)行的是非node程序乡恕,傳入一串shell命令言询,執(zhí)行后結(jié)果以回調(diào)的形式(Buffer)返回,與execFile不同的是exec可以直接執(zhí)行一串shell命令傲宜。

execFile:

子進程中執(zhí)行的是非node程序运杭,提供一組參數(shù)后,執(zhí)行的結(jié)果以回調(diào)的形式返回函卒。

fork:

子進程執(zhí)行的是node程序辆憔,提供一組參數(shù)后,執(zhí)行的結(jié)果以流的形式返回报嵌,與spawn不同虱咧,fork生成的子進程只能執(zhí)行node應(yīng)用。

子進程的和父進程的通信有三類信息stdin锚国、stdout腕巡、stderr,通過來設(shè)置血筑,

介紹地址:https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio

stdio 設(shè)置

pipe:

相當(dāng)于['pipe', 'pipe', 'pipe']绘沉,子進程的stdio和父進程的stdio通過管道進行連接煎楣。

ignore:

相當(dāng)于['ignore','ignore', 'ignore'],子進程的stdio綁定到/dev/null,丟棄數(shù)據(jù)的輸入輸出车伞。

inherit:

繼承父進程相關(guān)的stdio,等同于[process.stdin,process.stdout,process.sterr]或者[0,1,2],此時子進程的stdio都是綁定在同一個地方择懂。

使用示例
const spawn = require('child_process').spawn;

const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.log(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
});
//或者
const spawn = require('child_process').spawn;
// 在node里執(zhí)行shell命令
spawn('ls', ['-lh', '/usr'],{
    stdio: [ 'inherit',  'inherit',  'inherit' ]
});

一個執(zhí)行shell的便捷庫

//安裝
npm install execa
// 使用 同步模式執(zhí)行指定命令。
const cmd = `npm init -y`;
execa.commandSync(cmd, {
  //指令執(zhí)行路徑
  cwd: options.rootDirectory,
  //通信方式設(shè)置
  stdio: ["inherit", "inherit", "inherit"],
});

其它一些工具庫

package name 驗證庫

npm: validate-npm-package-name

增強版的 fs 模塊

npm: fs-extra

打開瀏覽器

npm: open

package.json 中的 bin 字段

安裝依賴時另玖,如果包的 package.json 文件有 bin 字段困曙,就會在 node_modules 文件夾下面的 .bin 目錄中復(fù)制了 bin 字段鏈接的執(zhí)行文件。我們在調(diào)用執(zhí)行文件時谦去,可以不帶路徑赂弓,直接使用命令名來執(zhí)行相對應(yīng)的執(zhí)行文件。

{
  "bin": "./xxx.js"
}
scripts: {  
  start: './node_modules/bin/xxx.js build'
}
// 簡寫為
scripts: {  
  start: 'xxx build'
}

完整的使用例子

地址:https://www.npmjs.com/package/le-koa-server

//安裝
npm i le-koa-server -g
//執(zhí)行命令
le-koa-server

le-koa-server.js

#!/usr/bin/env node
const { Command } = require('commander');
const packageJson = require('./package.json')
const fs = require('fs')
const chalk = require('chalk');
const validateNpmProjectName = require('validate-npm-package-name')
const inquirer = require('inquirer');
const execa = require('execa')
const open = require("open")
const program = new Command();


program.version(packageJson.version);
// 設(shè)置選項信息
program.option('-p,--port [port]', '端口');
// 設(shè)置參數(shù)
// le-koa-server app -p 9999
program.argument('[server-name]', 'server 的名稱哪轿,英文盈魁、數(shù)字、_組成');

// 執(zhí)行動作窃诉,參數(shù)是一一對應(yīng)的杨耙。選項會集中解析到對象,放在最后一項飘痛。(參數(shù)1珊膜,參數(shù)2,...選項)
program.action(async (webServerName, opts) => {
    const promptOptions = [];

    promptOptions.push({
        type: "input",
        name: "serverName",
        message: "請輸入應(yīng)用名稱",
        default: "app",
    });
    promptOptions.push({
        type: "input",
        name: "serverPort",
        message: "請輸入應(yīng)用端口",
        default: 8888,
    });
    promptOptions.push({
        type: "checkbox",
        name: "middlewares",
        message: "請選擇要安裝的中間件",
        choices: ['koa-static-cache', 'koa-router', 'koa-body'],
        default: ['koa-static-cache', 'koa-router'],
    });

    //第二個參數(shù)說明:如果用戶已經(jīng)通過指令輸入了值宣脉,不必再詢問用戶车柠。
    const answer = await inquirer.prompt(promptOptions, {
        serverName: webServerName,
        serverPort: opts.port,
    });

    // 整理用戶輸入和選擇的信息嗅回。process.cwd()當(dāng)前用戶執(zhí)行指令的路徑
    const options = {
        serverName: answer.serverName,
        serverPort: answer.serverPort,
        rootDirectory: process.cwd() + `/${answer.serverName}`,
        dependencies: ['nodemon', 'koa', ...answer.middlewares]
    }

    //校驗名稱是否合法
    if (validateNpmProjectName(options.serverName).errors?.length) {
        console.error(chalk.red(`無效的項目名稱:${options.serverName}`))
        process.exit(1);
    }

    //創(chuàng)建文件夾
    try {
        fs.mkdirSync(options.serverName);
    } catch (e) {
        console.error(chalk.red.bgWhite(`${options.serverName} 已經(jīng)存在了`))
        process.exit(1);
    }

    // 初始化package.json
    const cmd = `npm init -y`;
    execa.commandSync(cmd, {
        cwd: options.rootDirectory,
        stdio: ["ignore", "ignore", "ignore"],
    });

    // 安裝依賴
    const dependeniesCmd = `npm install ${options.dependencies.join(' ')}`

    execa.commandSync(dependeniesCmd, {
        cwd: options.rootDirectory,
        stdio: ["inherit", "inherit", "inherit"],
    });


    // 生成入口文件
    const log = `"服務(wù)啟動成功:http://localhost:${options.serverPort}"`
    const content = `
        const Koa = require('koa');

        const app = new Koa();
        
        app.use((ctx, next) => {
            ctx.body = 'Hello';
        });
        
        app.listen(${options.serverPort}, () => {
            console.log(${log});
        });
    `;
    const entryFile = options.rootDirectory + "/app.js";
    fs.writeFileSync(entryFile, content, {
        encoding: "utf-8",
    });

    // 打開瀏覽器
    await open(`http://localhost:${options.serverPort}`);

    // 啟動應(yīng)用
    const runCmd = `nodemon app.js --port=${options.serverPort}`;
    execa.commandSync(runCmd, {
        cwd: options.rootDirectory,
        stdio: ["inherit", "inherit", "inherit"],
    });


});


// 開始解析
program.parse();

package.json

{
"name": "le-koa-server",
"version": "1.0.0",
"description": "",
"main": "app.js",
"dependencies": {
"chalk": "^4.1.2",
"commander": "^8.2.0",
"execa": "^5.1.1",
"inquirer": "^8.1.5",
"open": "^8.2.1",
"validate-npm-package-name": "^3.0.0"
},
"devDependencies": {},
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"bin": "./le-koa-server.js",
"keywords": [],
"author": "",
"license": "ISC"
}

在 NPM 上發(fā)布 package

我們可以把本地的 package 發(fā)布到 npm 倉庫讓其他人使用己沛,相關(guān)操作如下:

1、注冊 npm 賬戶

注冊:https://www.npmjs.com/signup 攀细。
如果要發(fā)布npm包羊苟,需要驗證郵箱也完成塑陵。

2、登錄

使用 npm login 登錄授權(quán)

npm login
// 后續(xù)會提示輸入用戶名和密碼

3蜡励、發(fā)布

使用 npm publish 命令發(fā)布

npm publish

也可以登錄 npm 的 web 端令花,對已發(fā)布的應(yīng)用進行管理

注意

發(fā)布到 npm 倉庫上的 package,必須包含 package.json 文件凉倚,且內(nèi)容格式必須滿足特定要求:

https://docs.npmjs.com/cli/v7/configuring-npm/package-json

package 的名字除了滿足格式要求外兼都,要發(fā)布到 npm 倉庫中的 package 名稱不能重復(fù),可以使用 scope 來進行命名

4稽寒、更新

npm update

5扮碧、刪除

npm unpublish
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市瓦胎,隨后出現(xiàn)的幾起案子芬萍,更是在濱河造成了極大的恐慌,老刑警劉巖搔啊,帶你破解...
    沈念sama閱讀 222,729評論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件柬祠,死亡現(xiàn)場離奇詭異,居然都是意外死亡负芋,警方通過查閱死者的電腦和手機漫蛔,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,226評論 3 399
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來旧蛾,“玉大人莽龟,你說我怎么就攤上這事∠翘欤” “怎么了毯盈?”我有些...
    開封第一講書人閱讀 169,461評論 0 362
  • 文/不壞的土叔 我叫張陵,是天一觀的道長病袄。 經(jīng)常有香客問我搂赋,道長,這世上最難降的妖魔是什么益缠? 我笑而不...
    開封第一講書人閱讀 60,135評論 1 300
  • 正文 為了忘掉前任脑奠,我火速辦了婚禮,結(jié)果婚禮上幅慌,老公的妹妹穿的比我還像新娘宋欺。我一直安慰自己,他們只是感情好胰伍,可當(dāng)我...
    茶點故事閱讀 69,130評論 6 398
  • 文/花漫 我一把揭開白布齿诞。 她就那樣靜靜地躺著,像睡著了一般骂租。 火紅的嫁衣襯著肌膚如雪掌挚。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,736評論 1 312
  • 那天菩咨,我揣著相機與錄音吠式,去河邊找鬼。 笑死抽米,一個胖子當(dāng)著我的面吹牛特占,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播云茸,決...
    沈念sama閱讀 41,179評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼是目,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了标捺?” 一聲冷哼從身側(cè)響起懊纳,我...
    開封第一講書人閱讀 40,124評論 0 277
  • 序言:老撾萬榮一對情侶失蹤揉抵,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后嗤疯,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體冤今,經(jīng)...
    沈念sama閱讀 46,657評論 1 320
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,723評論 3 342
  • 正文 我和宋清朗相戀三年茂缚,在試婚紗的時候發(fā)現(xiàn)自己被綠了戏罢。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,872評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡脚囊,死狀恐怖龟糕,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情悔耘,我是刑警寧澤讲岁,帶...
    沈念sama閱讀 36,533評論 5 351
  • 正文 年R本政府宣布,位于F島的核電站衬以,受9級特大地震影響催首,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜泄鹏,卻給世界環(huán)境...
    茶點故事閱讀 42,213評論 3 336
  • 文/蒙蒙 一郎任、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧备籽,春花似錦舶治、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,700評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至珠闰,卻和暖如春惜浅,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背伏嗜。 一陣腳步聲響...
    開封第一講書人閱讀 33,819評論 1 274
  • 我被黑心中介騙來泰國打工坛悉, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人承绸。 一個月前我還...
    沈念sama閱讀 49,304評論 3 379
  • 正文 我出身青樓裸影,卻偏偏與公主長得像,于是被迫代替她去往敵國和親军熏。 傳聞我的和親對象是個殘疾皇子轩猩,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,876評論 2 361

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