从零开始整一个Node CLI程序

265 阅读9分钟

什么是CLI

命令行界面(英语:Command-Line Interface,缩写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(character user interface, CUI)——维基百科。

我们平时在开发中也会用到许多命令行,例如yarngit命令等等。

这些命令可以减少低级重复劳动,专注业务提高开发效率,规范流程。我们可以从工作中总结繁杂、有规律可循、或者简单重复劳动的工作用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,自动生成版本号
  • 通过命令行输出友好的提示
  1. 配置项目,让系统能够找到CLI的可执行文件

首先我们会遇到第一个问题,我们可能经常用使用别人开发的CLI,比如webpack-clicreate-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

  1. 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技能来完成。

  1. 管理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实现了命令行参数与处理逻辑一对一的对应,具体用法可以参考其详尽的文档,这里就不赘述了。

  1. 在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);

    });

  });

}
  1. 添加交互,Inquier源码解析

假设我们需要能够让用户通过交互选择版本号中的tag,我们需要在命令的函数里添加人机交互逻辑,而我们可以通过process模块来实现这部分功能:

  • 通过process.stdout.write将提示输出给用户

    • 当然也可以使用console.logconsole.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))指定行数,擦除旧的内容,最后将新的内容输出到控制台内。

  1. 输出友好的提示,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时,发现有些颜色错位的问题,其实是取消颜色的控制符没有正常渲染出来导致的。