打造nodejs命令行工具

前端工程化当中,大家都使用过很多的 nodejs 的命令行工具,最常用的 npm ,新建项目的脚手架 vue-clicreate-react-app 等。彩色文字、对话式的交互、loading 动画等,都让人感觉高大上。在浏览器的世界,渲染呈现不同的文字、图像,大家都比较熟悉,但是对于命令行的操作,我只停留在写 log 的阶段。如何控制文字颜色、控制光标位置、清除之前的输出?当然有一些现成的库可以做到,我还是想去探究下原理。研究一下发现,这些都跟一个叫 ANSI转义序列, ANSI escape code 的标准有关。

ANSI转义序列

在开始之前,还是要先介绍一下 ANSI转义序列, ANSI escape code ,以下摘自维基百科。

ANSI转义序列 是一种带内信号的转义序列标准,用于控制视频文本终端上的光标位置、颜色和其他选项。大多数ANSI转义序列是嵌入文本中以ESC转义字符和"["字符开始的字节序列。终端会把这些字节序列解释为相应的指令,而不是普通的字符编码。

维基百科说的比较明白,ANSI转义序列是一个命令行转义控制符的标准,第一个字节是 ASCII 字符 ESC (码点是27,十六进制 0x1B),第二个字节则是 0x40 - 0x5F (ASCII @A–Z[]^_)范围内的字符。往命令行输出这样的序列,就是在发送特定的命令,命令的含义由第二个字节控制,我下面就列几个命令:

序列 名称
ESC X SOS - 字符串开始
ESC \ ST - 字符串终止
ESC [ CSI - 控制序列导入器

其中 ESC 在 js 中用 unicode 来表示的话,就是 \u001B

而我今天主要讲的是 CSI 命令,即 \u001B[, 我也列几个:

序列 作用
CSI n A 光标上移 n
CSI n C 光标前移 n
CSI n; m H 光标移动到第 n 行,第 m
CSI n K 擦除第 n
CSI n m SGR – 选择图形再现

这下就更清楚了,就拿光标上移来说吧,以下代码光标上移 1 格:

console.log('\u001B[1A')

这些是光标的操作,这里 SGR 命令又得展开讲一下,涉及到后面的文字颜色等问题,其中的 n 可以有很多个选项,我列几个:

n 作用
0 重置
3 斜体
4 下划线
9 划除
30-37 设置文字前景色
39 默认文字前景色
40-47 设置文字背景颜色
49 默认文字背景颜色

比如说下面的代码就是输出带下划线的字符 1,然后重置,以免接下来的输出都带下划线:

console.log('\u001B[4m' + '1' + '\u001B[0m')

多个参数可以用 ; 隔开,比如同时设置下划线、斜体和删除线(可能看不到这么多效果,因为有兼容性问题,是不是很熟悉的问题 :) ),然后重置:

console.log('\u001B[3;4;9m' + '1' + '\u001B[0m')

这是基础知识,接下一个个详细探讨,灵活运用。

颜色

3位/4位

最开始时是3位颜色,也就是8种,后面扩到4位,共16种。颜色的代码比较少,可以直接查表,比如下面是输出红色背景、绿色文字的 Hello World,然后重置为默认的前景背景色:

console.log('\u001B[41;32m' + 'Hello World' + '\u001B[49;39m')

设置了特定效果之后,立刻重置是个好习惯,整屏大红大绿还是比较辣眼睛的 :)

8位

后来流行 256 色,也就是 8 位。因为是扩展的,语法特别点:38;5;$color48;5;$color , 其中 38 与 48 分别是设置前景色与背景色,$color 为 0-255 区间的颜色, 具体颜色值意义可以查表。示例:

// 设置前景色为:200
console.log('\u001B[38;5;200m' + 'Hello World' + '\u001B[39m')
// 设置背景色为:200
console.log('\u001B[48;5;200m' + 'Hello World' + '\u001B[49m')

24位

接下来就是 24 位真彩色了,语法跟 8 位的有点相似:38;2;$r;$g;$b48;2;$r;$g;$b ,其中 $r$g$b 分别是0-255 区间红绿蓝颜色值,38、48 分别是设置前景色与背景色。示例:

// 设置前景色为:rgb(20, 80, 140)
console.log('\u001B[38;2;20;80;140m' + 'Hello World' + '\u001B[39m')
// 设置背景色为:rgb(20, 80, 140)
console.log('\u001B[48;2;20;80;140m' + 'Hello World' + '\u001B[49m')

封装库

对于命令行设置文字颜色的封装库,推荐 chalk ,以上的颜色设置可能在各个操作系统的不同版本上支持程度不同,chalk 会为你转换为兼容的最接近的颜色,其它特性各位看官可以去翻翻 wiki 或源代码。

光标操作

光标的操作其实与设置颜色差不多,也属于 CSI 系列,以下列几个例子:

// 隐藏光标
console.log('\u001B[?25l');
// 恢复光标
console.log('\u001B[?25h');
// 上移 n 格
console.log(`\u001B[${n}A`);
// 擦除 n 行
console.log(`\u001B[${n}K`);
// 向上滚动 n 行
console.log(`\u001B[${n}S`);

详细的使用可以去维基百科查表,我粗略看了一遍,可以实现隐藏/显示光标、上下左右移动光标、清除行/屏幕内容、获取/存储/复位光标、上下滚动等功能。

如果你想要现成的封装库的话,可以看看 ansi-escapes 。nodejs 的 readline 模块也有相关的封装。

动画

命令行下动画的实现,其实和你熟悉的差不多,通过频繁地擦除与输出变化的内容,给你的眼睛营造动画的感觉。找一些特别形状的字符,快速地切换,形成有规律的图像变化,再配合颜色的设定,目的即达成。

下面演示一个简单的动画:

// 当前帧
var now = 0;
// 动画帧
var frames = ["⠋", "⠙", "⠴", "⠦"];
// 频率
var interval = 80;
// 先隐藏光标,不然不太好看
process.stdout.write('\u001B[?25l');
// 设置定时器
setInterval(function () {
  // 擦除行内容
  process.stdout.write('\u001B[1K');
  // 回到起始位置,不然输出不在同一个点上
  process.stdout.write('\u001B[1G');
  // 输出帧
  process.stdout.write(frames[now]);
  // 更新
  if (++now >= frames.length) now = 0;
}, interval);

// ctrl + c
process.on('SIGINT', function () {
  // 擦除行内容
  process.stdout.write('\u001B[1K');
  // 最后恢复光标
  process.stdout.write('\u001B[?25h');
  // 结束进程
  process.exit(0);
});

这里不用 console.log 的原因是它会自动加上换行符 \n,标准输出是个 可写流,自己动手调用 write 方法写数据即可。

结束脚本的时候请按下 ctrl + c ,要是遇到了 bug (可能 发生 在 windows 上):结束程序后光标一直看不见,你应该会知道怎么把光标显示回来的 :) 。

封装库可以试一下 ora

交互式命令行

作为命令行工具,与用户交互是很必要的功能,比如让用户输入信息/选择选项等。

了解完上面介绍的颜色、动画、光标操作后,渲染内容方面其实已经差不多,与用户交互还需要获取用户输入。熟悉 nodejs 的同学,应该很快就意识到,命令行输入即标准输入 process.stdin ,是一个 可读流 。监听标准输入的 data 事件即可。

process.stdin.on('data', function (data) {
  console.log(data);
});

运行一下,发现输出的是 buffer 。翻翻 文档 ,可读流在没有设置编码方式的时候,默认是给 buffer 的,我们设置一下编码方式为 utf-8

process.stdin.setEncoding('utf-8');
process.stdin.on('data', function (data) {
  console.log(data);
});

这是好像可以了,但其实还有个问题,这样只有在用户敲了回车之后才能响应。而我们要与用户交互的话,很多时候是想在用户按下任意键时就开始响应,比如这种场景是:我们在屏幕上输出好几个选项,在用户用方向键切换选项后,我们给用户选中的选项变色,让用户知道现在选中了哪个选项。

怎么办呢?方法肯定有的,查看 文档

当 Node.js 检测到正运行在一个文本终端(TTY)的上下文中时,则 process.stdin 默认会被初始化为一个 tty.ReadStream 实例,且 process.stdout 和 process.stderr 默认会被初始化为一个 tty.WriteStream 实例。

process.stdin 作为 tty.ReadStream 的实例,继承了 setRawMode 方法。调用此方法可以打开或关闭命令行的原始模式,在原始模式下,输入的每个字符均触发 data 事件。

下面的示例检测用户是否按下 按键:

process.stdin.setEncoding('utf-8');
process.stdin.setRawMode(true);
process.stdin.on('data', function (data) {
  // up arrow
  if (data === '\u001B[A') {
    console.log('up arrow pressed!');
  }
  // ctrl c
  if (data === '\u0003') {
    console.log('bye!');
    process.exit(0);
  }
});

这样判断按键有点原始,比较晦涩,可以借助 readline 模块的 emitKeypressEvents 方法,让标准输入流产生 keypress 事件:

const readline = require('readline');
process.stdin.setRawMode(true);
process.stdin.setEncoding('utf-8');
readline.emitKeypressEvents(process.stdin);
process.stdin.on('keypress', function (key, data) {
  console.log(key, data);
  if (data.name === 'c' && data.ctrl) {
    process.exit(0);
  }
});

封装库可以看看 Inquirer.js ,其实 nodejs 的 readline 模块也有相关函数。

命令行参数

nodejs 的命令行参数放在 process.argv 数组里,第一个是 node 的路径,第二个是脚本文件的路径,第三个开始是自定义参数。这个比较容易理解,比如你这样运行你的脚本 node app.js --config=config.json ,在 app.js 里,process.argv[2] 就是 --config=config.json ,然后你自己处理一下,就知道设置了什么参数。

使用 commander.jsyargs 等封装库可以玩得更溜。

总结

开始介绍了一下ANSI转义序列;然后顺势讲了设置命令行文字颜色、光标位置、动画以及监听键盘事件的实现;最后提及了命令行参数的知识。主要介绍一下原理性的东西,虽然有提到第三方库,但是并没有介绍怎么用,大家可以自行查看文档(或参考 此文章 )。希望对酷炫的命令行充满好奇的同学有点帮助。

参考:

(完)

This post was published 1862 days ago, some content may be inaccurate. Edit it on GitHub
Comment loading