什么是CLI
命令行界面(英语:Command-Line Interface,缩写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(character user interface, CUI)——维基百科。
我们平时在开发中也会用到许多命令行,例如yarn,git命令等等。
这些命令可以减少低级重复劳动,专注业务提高开发效率,规范流程。我们可以从工作中总结繁杂、有规律可循、或者简单重复劳动的工作用CLI来完成。
CLI根据不同业务场景有不同的功能,但万变不离其宗,本质都是通过命令行交互的方式在本地电脑运行代码,执行一些任务。
与npm scripts的对比
其实对于部分前端使用的脚手架来说,也可以通过npm scripts实现,比如我们可以在package.json
中的scripts里配置命令"cli": "node cli.js"
来达到同样的目的。
不过用npm scripts来实现,需要将具体的自动化流程相关的代码集成到业务项目中,与业务耦合度高,也不方便复用和迭代。我们通常更愿意通过包管理工具升级,而不是去代码仓库ctrl+cv
相关文件。
如何开发CLI
假设我们想要开发一个带交互的CLI,应该怎么下手呢?
我们给一个具体点的场景,假设我们需要参与维护一个npm package,而这个package经常发版,但repo里的package.version
更新并不及时。
出于各种原因,当我们自己需要发版的时候,不能简单地改本地repo里的版本号,需要去npm上查询这个包的最新版本,才能确定自己发布的下一个版本号。
对于这个场景,我们可以将其提炼成CLI工具,帮助我们节省确定版本号的成本。
这个CLI工具需要具备的一些特点:
- 基于Node.js开发
- 能够让用户通过交互选择npm的tag,自动生成版本号
- 通过命令行输出友好的提示
-
配置项目,让系统能够找到CLI的可执行文件
首先我们会遇到第一个问题,我们可能经常用使用别人开发的CLI,比如webpack-cli
,create-react-app
,但系统是怎么去找到这些命令后面的逻辑并执行的呢?
如果你能回想起以前在电脑上手动配置JAVA环境变量的繁琐步骤,当我们想要在shell里直接用某个命令之前,需要将其添加到系统变量PATH中,而npm提供了bin field来简化了这一流程
// package.json
{
"bin": {
"mycli": "./cli.js"
}
}
// or
{
"name": "mycli",
"bin": "./cli.js"
}
当我们在packge.json
里声明bin后,再通过npm install这个package,npm会创建一个symlink到我们的cli.js
文件。这里分为两种情况:
- 如果在某个项目下执行
npm install mycli
,CLI的可执行文件只会link到项目里的.bin目录下。这也意味只能在该项目下通过npm scripts才能访问到该CLI。
我们能在node_modules下找到很多这样的link到本项目目录下的可执行文件
- 如果执行
npm install --global mycli
,CLI的可执行文件会link到全局的bin模块(/usr/local/bin
),这时可以直接在shell中通过CLI的名字调用。
全局安装 typescript 后, /usr/local/bin
中有 tsc 的link文件,可以直接在shell中调用 tsc
-
CLI的入口文件
所有的Node.js CLI的第一行都是以#!/usr/bin/env node
开头,我们也需要将其添加到cli.js中。
若是有使用过Linux或者Unix的前端开发者,对于#!
应该不陌生,这个字符序列被称为Shebang,通常在Unix系统的基本中第一行开头中出现,用于指明这个脚本文件的解释程序。
在文件中存在Shebang的情况下,类Unix操作系统的程序加载器会分析Shebang后的内容,将这些内容作为解释器指令,并调用该指令,并将载有Shebang的文件路径作为该解释器的参数。
所以我们可以了解,运行cli命令时操作系统选择了node作为解释器,将cli.js文件路径传给了node,实际上还是回到了node cli.js
,所以CLI实现的具体功能,我们完全可以继续使用掌握的node技能来完成。
-
管理CLI提供的命令
我们需要管理CLI提供的命令和接受的参数,参数都可以从process.env.argv
数组中获得,但我们也可以用command.js来简化封装的步骤。
#!/usr/bin/env node
import { program } from 'commander';
import { publish } from './command';
program
.command('publish')
.description('自动获取npm上最新的版本并生成版本号')
.option('-s --selectMode', '通过命令行交互的方式选择发布的参数,可以选择发布的类型和tag', false)
.action(publish => {
version(options);
});
commander.js实现了命令行参数与处理逻辑一对一的对应,具体用法可以参考其详尽的文档,这里就不赘述了。
-
在CLI内部执行shell命令
我们的这个CLI需要获取npm上最新的版本号,假设我们熟知npm 命令,我们可以在命令行里执行npm view [package] version
来获取版本,而我们需要关注如何在node环境下执行这个shell命令,并获取其返回值。
在Node.js里执行shell命令,我们离不开child_process
这个内置模块,我们可以简单地通过child_process.exec
来执行我们想要的命令,从其callback中拿到命令执行后的stdout。
当然,直接使用Node.js的原生模块,不可避免地需要处理一些兼容性问题,我们也可以在项目中引入shelljs,shelljs基于child_process
封装,处理了各种操作系统下的兼容性,也内置了一些简单的命令。
不过shelljs并未提供Promise封装,其异步回调也是通过callback完成,我们可以简单地对shelljs进行一个封装。
import shell from 'shelljs';
// 由于shelljs不支持await语法,这里对shelljs进行简单的promise封装
export function execAsync(command, options) n{
return new Promise((resolve, reject) => {
shell.exec(command, { silent: true, ...options, async: true }, (code, stdout, stderr) => {
if (code !== 0) {
return reject(new Error(stderr));
}
return resolve(stdout);
});
});
}
-
添加交互,Inquier源码解析
假设我们需要能够让用户通过交互选择版本号中的tag,我们需要在命令的函数里添加人机交互逻辑,而我们可以通过process
模块来实现这部分功能:
-
通过
process.stdout.write
将提示输出给用户- 当然也可以使用
console.log
,console.log
是对stdout.write
的封装
- 当然也可以使用
-
通过
process.stdin.on
接受用户的输入,通过回调处理输入
示例如下:
function inputTag() {
process.stdout.write('请输入想要使用的tag: ');
process.stdin.setEncoding('utf-8');
process.stdin.on('data', inputTag => {
// 处理用户输入的tag
// ...
// 处理结束后退出
process.stdin.end();
process.exit();
});
}
如果我们提供预设的几个tag(如alpha、beta等),让使用者通过上下箭头选择tag,回车来确认,处理的逻辑会复杂一点,这里我们可以参考Inquirer.js,看看它是怎么实现命令行交互的。
我们以Inquier的list为例,list是Inquier预设的一种交互类型,用户可以通过上下箭头选择输入,通过回车确定选项:
使用也很简单,我们只需要传入message和choices字符串数组即可。
import inquirer from 'inquirer';
const answers = await inquirer.prompt({
name: 'tag',
type: 'list',
// 自定义的提示
message: '请选本次发布使用的tag',
// 自定义的选项
choices: ['alpha', 'beta', 'gamma', 'delta'],
});
// answers.tag为我们需要使用的值
当我们使用console.log
输出信息时,输出的每一行都是既定的结果,那我们要如何去实现这样的一个交互,让用户在选择的时候,控制台输出的UI界面并没有闪动,只有选择的光标在移动呢?
我们去看Inquier list类型的源码,用户输入是由Node的readline模块处理,使用readline模块可以每次一行地读取用户的输入,也方便对于控制台的IO集中管理。
const readline = require('readline');
// Default `input` to stdin
const input = process.stdin;
// Add mute capabilities to the output
const output = new MuteStream();
output.pipe(process.stdout);
const rl = readline.createInterface({
terminal: true,
input,
output,
});
Inquier内部借助rxjs的fromEvent
监听命令行的keypress
事件,并根据不同的键位注册相应的事件。当用户使用上下箭头时,会重新执行ListPrompt
里的render
方法。
render() {
// Render question
let message = this.getQuestion();
if (this.firstRender) {
message += chalk.dim('(Use arrow keys)');
}
// 如果交互的状态为已完成,生成包含用户选择项的界面
if (this.status === 'answered') {
message += chalk.cyan(this.opt.choices.getChoice(this.selected).short);
} else {
// 如果交互的状态为pending,这里会生成整个选择的界面
// ... 省略循环生成界面的代码
}
this.firstRender = false;
this.screen.render(message);
}
原理也很简单:每次render的时候会重新生成整个包含提示Message,选项,以及表明选择位置的光标的string,并将整个string交给screen模块渲染。
screen模块通过 process.stdout.write(ansiEscapes.eraseLines(lines))
指定行数,擦除旧的内容,最后将新的内容输出到控制台内。
-
输出友好的提示,Chalk源码解析
我们可以看到,Inquier里面使用了Chalk模块,我们也经常在开发CLI时引入这个库帮助我们控制输出到控制台中字符串的样式。
其使用方式也很简单:
那Chalk内部是怎么实现的呢?
其原理最重要的一个知识点就是ANSI Escape sequences,ASCII编码中有些字符是不能用来在终端中打印显示的,比如'\n' 0x0A
代表换行,这些字符被称为控制符。
而其中的一个控制符 '\e' \x1b
比较特殊,文本中出现这个控制符,表示接下来的字符是ANSI Escape code
编码,而ANSI Escape code
编码中有专门控制字符颜色的控制符。
所以我们在控制台中输入console.log('\x1b[32m老实人\x1b[m')
其实也是能够达到同样的效果的。
而在使用控制符控制颜色后,我们通常需要再取消这些颜色设置,不影响其他输出的字符,所以有时候在使用cli时,发现有些颜色错位的问题,其实是取消颜色的控制符没有正常渲染出来导致的。