如何用 Node.js 实现一个微型 CLI

2,619 阅读7分钟

什么是 CLI

命令行界面(英语:command-line interface,缩写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。

实现一个微型 CLI Demo

Node.js 官方示例:微型 CLI

readline.createInterface

首先创建一个接口的实例,用于处理流信息,例:输入、输出、提示字符串、自动补全、历史记录等。

const rli = readline.createInterface({
  // 要监听的可读流。此选项是必需的。
  input: process.stdin,
  // 将逐行读取数据写入的可写流。
  output: process.stdout
  // prompt // 要使用的提示字符串。默认值: '> '。
  // historySize  //保留的最大历史记录行数。 要禁用历史记录,请将此值设置为 0。
  // completer // 用于 Tab 自动补全的可选函数。
});

创建完成后一个基本的 CLI 就已经有了。但是,仅仅是拥有了能够处理输入输出等流信息的能力而已。但是此时只能够输入,不能够输出,如果需要输出能力则需要进一步进行完善。

on line

如果需要根据输入流的信息来反馈一些信息显示(输出流),则需要使用返回的实例来监听输入流的内容,然后进行相应的处理,再返回流信息用于输出显示。

// on 函数是为需要监听的指令
// line 是能接受到当前命令行中的输入流信息,通过函数回调的方式返回处理过的字符串。
rli.on('line', line => {
  const line2str = line.trim()

  if (line2str === '嗨') { // 在命令行中输入 “嗨” 并回车,CLI 则会输出一个 “Hi!”
    console.log('Hi!')
  }
  if (line2str === '你好吗') { // 没错是个英语老方了
    console.log('I\'m fine, thank you, and you?')
  }
})

通过监听输入的行信息加以处理的逻辑,最后返回一个输出信息就实现了简单的输入输出互动效果。

至此,一个大概的互动式的 CLI 核心部分就已经完成了。

启动 CLI

如需使用 npm 命令的话则需要在 package.json 中 scripts 里加入你的命令名称和脚本位置。

"scripts": {
  // 其他命令……
  "cli": "node build/index.js" // 新增的 npm 命令,通过 npm 命令可以启动 cli 脚本。
},
// 这时候就可以通过 npm run cli 命令运行 CLI 了。

脚本位置的话不能直接使用 ./filePath 或 /filePath 这样的路径会无法识别。需要使用 node filePath/xxx.js,这样 node 就会将脚本位置定位至当前项目开始寻找。

退出 CLI

当所有输入完成后或者达到特定条件就可以退出 CLI 模式了。

if (line2str === '再见') {
  console.log('Bye!')
  process.exit(0); // 退出 CLI 模式
}

通过 process.exit 就可以实现退出当前的 CLI 模式返回到命令行中。

process 在接下来的内容中还会使用到,但是可以先看以下 NodeJs 对他的定义:

process 对象是一个全局变量,它提供有关当前 Node.js 进程的信息并对其进行控制。作为一个全局变量,它始终可供 Node.js 应用程序使用,无需使用 require()。 它也可以使用 require() 显式地访问

实现一个简单的问答式 CLI

什么情况会需要用到 CLI 功能呢?我们可以假设一个这样的场景:你在写 Vue 的时候是不是会重复的新建 xxx.vue 文件呢?这时候就可以使用 CLI 生成了。当然你会说:“我可以 copy & paste 啊!”。你当然可以,但是每次 copy & paste 完了你又要把里面的代码手动删除掉,不觉得很麻烦吗?这时候一条命令加上简单的输入就可以生成干净的 xxx.vue 模板,甚至附带的 xxx.js、xxx.css 也可以一并生成岂不是更有效率?

“我就喜欢 copy & paste!”。好了兄弟,你坐下,当我没说。

下面我们继续来分析一下实现这样的一个 CLI 需要考虑哪些因素

问题

“一个问答式的 CLI 当然需要问题啦,这不是废话嘛。”

话是没错,但是问题如何问当然也有一点点的讲究。那就是问题一定会是封闭式的问题,封闭式问题因为提得比较具体且圈定的范围固定,也就要求了回答者必须在这个范围内给予明确的回答。

如果是开放式问题的话,那么就会导致回答者(使用者)就有很大的自我发挥空间。因为问题过于开放笼统的话,那么答案就没有固定范围了,这时候你的问题也就是无效提问了。

答案

这个不必过多解释,既然是封闭式问题那就只有一些固定的选项,以及再照顾一下默认选项即可。

例如:代码文件类型?【JS/ts/vue/css】

其中大写/加粗一般为默认类型,即回车即选择。

生成路径

所有问题都回答、选择完成后文件的生成路径,一般来说必须默认一个生成路径以及提供自定义填写符合文件夹规则的路径。当然也可以将其做成半开闭形式,即有固定几种选择也可以自定义填写符合文件夹规则的路径。

例如:指定文件路径?【SRC/components/assets/yourpath】

生成的模板

通过答案得知需要生成的是哪种类型的文件或者是某一类文件或某一种文件组合生成多个文件。

反馈结果

当所有回答都完成时,需要及时反馈、显示一些重要的步骤或信息,让使用者直观的知道进程如何,以及最终结果。

上面将一些所考虑的因素都说完了,这里就开始进入代码的实际编码和设计部分了。这部分开始会以我自己的项目为例来说明。

问题 & 答案的设计

首先需要一个问题列表:

// 构建问题列表
const buildQuestion = () => {
  // 问题文字内容、提示、类型
  const questionText = [
    {question: '组件名称?', tips: '', type: '[template]'},
    {question: '指定文件夹路径?', tips: '(最大深度:4)', type: '[./views/]'},
    {question: '代码文件类型?', tips: '', type: '[JS/ts]'},
    {question: '样式表类型?', tips: '', type: '[CSS/less/sass/scss]'},
    // {question: '是否创建单独的Api文件?', tips: '', type: '[y/N]'} // 暂时未想好该如何处理 API 文件的构建和写入
  ];

  return questionText.map(item => {
    return { text: item.question, question: `\x1B[32m?\x1B[97m ${item.question}${item.tips}\x1B[32m${item.type}` }
  });
}

你可以设计你自己的问题列表来决定需要生成的是那些内容/代码。

以及一个无效回答的默认值和一个记录回答对象:

// 无有效输入时使用的默认内容
const defAnswer = {
  fileName: 'template',
  filePath: './views/',
  codeType: 'js',
  cssType: 'css',
  fileApi: false,
};

// 记录问题的回答内容
const answer = {
  fileName: '',
  filePath: '',
  codeType: '',
  cssType: '',
  fileApi: false,
};

当用户输入了答案后我们就需要去检查这个答案是否符合规则或者有效:

// 检查是否符合规则,并处理答案默认选项
const checkAnswer = (step, content) => {
  // if (step > 1) { content = content.toLowerCase() }
  switch (step) {
    case 0:
      return answer.fileName = /^[a-zA-Z]{1,20}$/g.test(content)
        ? content : defAnswer.fileName;
    case 1:
      return answer.filePath = path.join(
        findChatIndex(
          path.join(defAnswer.filePath, content), '\\', 3),
        answer.fileName);
    case 2:
      content = content.toLowerCase()
      return answer.codeType = /^js|ts$/ig.test(content)
        ? content : 'js';
    case 3:
      content = content.toLowerCase()
      return answer.cssType = /^css|less|sass|scss$/ig.test(content)
        ? content : 'css';
    case 4:
      if (/^y|Y|n|N$/ig.test(content)) {
        const tempYN = content.toLowerCase()
        answer.fileApi = tempYN === 'y' ? true : false
        return content
      } else {
        answer.fileApi = false
        return 'N'
      }
  };
};

处理路径

针对用户自定输入路径时的处理,以及还要考虑不同操作系统路径分割符不一致的情况。

// 拼接路径
let findChatIndex = (str, chat, num) => {
  if (str.match(/\\/g).length <= num) return str;

  let chatIndex = str.indexOf(chat);
  for (let index = 0; index < num; index++) {
    let tempIndex = str.indexOf(chat, chatIndex + 1);
    if (tempIndex !== -1) {
      chatIndex = tempIndex
    }
  }
  return str.substr(0, chatIndex);
};

处理模板

这里我就不贴代码了,因为我是使用了字符串模板来作为模板的输出内容,因为方便且字符串模板可以保存格式(缩进和换行)

参考这里:template.js(已更新,结构基于模板,逻辑基于 Proxy 生成文件)

到这就完了?不,到这只是完成了考虑因素的代码实现部分,还有一些是需要我们继续完善的,例如输入输出的处理,显示、反馈处理等

输入输出的设计

一般来说在进入一个独立的 CLI 模式之前会对控制台之前的内容进行一个简单的清理:

readline.cursorTo(process.stdout, 0, 0); // 光标位置 0,0 即第一行第一位
readline.clearScreenDown(process.stdout); // 清理屏幕内容

这一步,简单来说就有很好,清理之后没有其他无关信息。当然没有的话也无伤大雅,属于锦上添花的部分,看需要来吧。

然后就是开始初始化第一个问题:

// 初始化第一个问题。
console.log(questionList[stepQuestion].question); // 问题一
// 设定输入内容样式
console.log('\x1B[36m'); // 控制台字符样式

当然你也可以写多一点东西,比如输出一段简介或者输出一些其他自己喜欢的内容。

接下来就是比较重要的问题和答案处理部分了,监听 line 输入自然是不用多说。

// on 函数是为需要监听的指令
// line 是能接受到当前命令行中的输入流信息,通过函数回调的方式返回处理过的字符串。
  const line2str = line.trim()

  // 将检查处理后的答案信息存储用于后续命令行内容输出
  let tempAnswer = checkAnswer(stepQuestion, line2str);

  // 将光标移入上一次步骤的位置,可以造成用户已经选择完成的效果。
  readline.cursorTo(process.stdout, 0, stepQuestion);
  // 清理之前的输入内容。
  readline.clearScreenDown(process.stdout);
  // 选择完成后输出选择后的结果信息。
  console.log(`\x1B[32m?\x1B[97m ${questionList[stepQuestion].text}\x1B[36m%s`, tempAnswer);

  // 重置控制台样式。
  console.log('\x1B[0m');

// 如果当问题的步骤小于问题的长度时,则问题步长 + 1。
  if (stepQuestion < lenQuestion) {
    stepQuestion++;
    // 输出下一个问题内容
    console.log(questionList[stepQuestion].question);
  } else { …… }

这里可以看出我使用的是记录步长的方式来处理什么时候开始下一个问题的提问与答案的记录。

之前也考虑过使用递归,但是最终实现起来处理提问与答案的记录稍微麻烦,当然你也可以尝试。

else 部分呢就是处理所有答案都回答完成的情况了:

  else {
    tpl.bulidTpl(answer)
    .then(() => {
      // 否则可以认为已经选择完成
      // console.log('再见! %o', answer);
      console.log('再见!');
      console.log('\x1B[0m');
      process.exit(0);
    })
    .catch(err => {
      console.log(`\x1B[31m${err.message}`);
      console.log(`\x1B[31m${err.error}`);
      console.log('\x1B[0m');
      process.exit(0);
    });
  }

最后最后还是需要额外考虑一下意外触发 CLI 任务中断的情况:

rli.on('line', line => {})
.on('close', () => {
  console.log('\x1B[0m');
  console.log('【信息】您已中断模板创建任务,感谢您的使用再见!');
  process.exit(0);
});
能看到到这里呢也就说明了,这个 微型的问答式的 CLI 也就完成了。
![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/4/6/1714e8ce7a1db0b8~tplv-t2oaga2asx-image.image)
最终效果

最后

当然这个只是一个简单的 CLI 实现而已,关于这个 CLI 我自己也还有一些想法,因为这里面还是有一些可以改进和优化的地方,例如现在是只能生成 Vue 这一套单一的文件模板,哪能不能生成其他框架的文件模板呢?或者是可以通过配置文件的方式生成的是一整套项目结构呢?又或者是代码模板能不能使用代码的方式而不是字符串模板生成代码模板呢?
这些也都是我自己需要考虑和更深入学习了解的地方。
各位小伙伴可能也会有自己的想法可以创造很多有趣、好玩的 CLI。当然也祝各位小伙伴能够学到有用的知识,然后把这些知识转变成代码然后创造生产力工具为自己和公司、企业、社区增砖添瓦。
版权声明:
本文版权属于作者 林小帅,未经授权不得转载及二次修改。
转载或合作请在下方留言及联系方式。