从零开始开发cli

这是一篇篇幅很长的文章,分为四个部分,每个部分都可以单独食用。希望能够对你有一点作用,谢谢!

  1. 使用npm发布插件包
  2. 学习使用commander.js插件
  3. 学习使用inquirer.js插件
  4. 在vue-cli基础上封装一个cli

1.使用npm发布插件包

npm 为我们打开了连接整个 JavaScript 天才世界的一扇大门。它是世界上最大的软件注册表,每星期大约有 30 亿次的下载量,包含超过 600000 个 包(package) (即,代码模块)。来自各大洲的开源软件开发者使用 npm 互相分享和借鉴。包的结构使您能够轻松跟踪依赖项和版本。
npm 由三个独立的部分组成:​

  1. 网站:是开发者查找包(package)、设置参数以及管理 npm 使用体验的主要途径。
  2. 注册表(registry):是一个巨大的数据库,保存了每个包(package)的信息。
  3. 命令行工具 (CLI):通过命令行或终端运行。开发者通过 CLI 与 npm 打交道。

我们可以从npm上获取大量优秀第三方包,当然我们也可以上传自己的包。
下面简单介绍如何使用npm创建和发布自己的包。

创建npm账号

要上传我们自己的包,首先要有npm账号,如果你没有npm账号,可以去注册页注册并登录自己的账号。输入用户名、邮件、密码即可创建。
在命令行输入npm login,根据提示输入账号、密码,即可在命令行登录我们的账号。后续可以通过此账号上传我们开发的npm包。
在命令行输入npm whoami可以测试自己是否登录成功,登录成功则会打印出你的用户名。

包的分类

npm包分为public和private两种类型。

scopes

在此之前,我们先了解这么一个概念——scopes(中文意思是作用域)。我们在注册npm账号和创建组织时,你将被授予一个与你的用户或组织名称匹配的范围,即你获得了一个适用范围(scope),这个范围是你的用户名或者创建的组织名。你可以将此范围用作相关包的命名空间。如你有一个package名叫mypackage,你的用户名为myusername,则你可以把这个package放到你的域里@myusername/mypackage ​
作用:
避免与别人的包重名,发生冲突。
限制该包的访问权限。

pubilc package

作为npm用户或组织成员,你可以创建和发布公共包,任何人都可以下载并在他们自己的项目中使用。​
unscoped:的公共包存在于全球公共注册表(registry.npmjs.org)的命名空间中,并且可以在package.json文件中仅以包的名称来引用:package-name。
scoped:的公共包属于某个用户或组织,在package.json文件中作为依赖关系包含时,必须在前面加上用户或组织名称。
@username/package-name
@org-name/package-name

private package

要使用私有软件包,你必须使用npm 2.7.0或更高版本,并且有一个付费用户或组织账户。
通过npm私有包,你可以使用npm注册表来托管只对你和选定的合作者可见的代码,允许你在项目中管理和使用私有代码与公共代码

私有包总是有一个范围,而范围内的包默认为私有。

用户范围的私有包只能由你和被你授予读或读/写权限的合作者访问。
组织范围的私有包只能由被授予读或读/写权限的团队访问。

创建package.json文件

发布到注册表的软件包必须包含一个package.json文件。package.json文件会表明你的包名、版本、作用、列出你的项目所依赖的软件包、关键词等等。这份文件是你需要知道的关于package.json文件中需要的所有内容。它必须是实际的JSON,而不仅仅是一个JavaScript对象字面。所以package.json对npm包来说至关重要。我们使用动手创建demo来学习。
打开命令行,在指定目录创建一个demo项目
mkdir zzmath
cd zzmath
创建package.json文件
npm init,根据提示输入你的包的信息。
使用编辑器打开package.json文件。

{
  "name": "zzmath",
  "version": "1.0.0",
  "description": "一些简单的数据计算方法封装",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "math",
    "js"
  ],
  "author": "",
  "license": "ISC"
}
复制代码

这就是创建package.json文件后会有的一些信息。
name:包名
version: 版本号
description:包的描述
scripts: 脚本
main:指定包的入口文件
keywords: 包的关键词
author: 作者
license: ISC

更多字段请参考官方介绍

README.md

为了帮助其他人在npm上找到你的软件包,并在他们的项目中拥有使用你的代码的良好体验,建议在你的包的根目录中包括一个README文件。你的 README 文件可以包括安装、配置和使用你的软件包中的代码的说明,以及任何其他用户可能会发现的有用信息。README文件将显示在包页面上。

语义版本管理

为了保持JavaScript生态系统的健康、可靠和安全,每当你对自己拥有的npm包进行重大更新时,建议在package.json文件中发布该包的新版本,并在其上标注更新的版本号,以遵循语义版本规范。遵循语义版本规范有助于依赖你的代码的其他开发人员了解特定版本的变化程度,并在必要时调整自己的代码。
软件包版本从1.0.0开始,遵循以下规则。
语义版本控制的概念很简单:所有的版本都有 3 个数字:x.y.z。​

  1. 第一个数字是主版本。
  1. 第二个数字是次版本。
  1. 第三个数字是补丁版本。
复制代码

当发布新的版本时:
当进行不兼容的 API 更改时,则升级主版本。
当以向后兼容的方式添加功能时,则升级次版本。
当进行向后兼容的缺陷修复时,则升级补丁版本。

npm 设置了一些规则,可用于在 package.json 文件中选择要将软件包更新到的版本(当运行 npm update 时)。规则使用了这些符号:

^: 只会执行不更改最左边非零数字的更新。 如果写入的是 ^0.13.0,则当运行 npm update 时,可以更新到 0.13.10.13.2 等,但不能更新到 0.14.0 或更高版本。 如果写入的是 ^1.13.0,则当运行 npm update 时,可以更新到 1.13.11.14.0 等,但不能更新到 2.0.0 或更高版本。
~: 如果写入的是 〜0.13.0,则当运行 npm update 时,会更新到补丁版本:即 0.13.1 可以,但 0.14.0 不可以。
>: 接受高于指定版本的任何版本。
>=: 接受等于或高于指定版本的任何版本。
<=: 接受等于或低于指定版本的任何版本。
<: 接受低于指定版本的任何版本。
=: 接受确切的版本。
-: 接受一定范围的版本。例如:2.1.0 – 2.6.2。
||: 组合集合。例如 < 2.1 || > 2.6。
可以合并其中的一些符号,例如 1.0.0 || >=1.1.0 <1.2.0,即使用 1.0.0 或从 1.1.0 开始但低于 1.2.0 的版本。
还有其他的规则:
无符号: 仅接受指定的特定版本(例如 1.2.1)。
latest: 使用可用的最新版本。

发布和更新包

继续使用前面的demo,我们来开发一个简单的工具包,并发布到npm上。
新建index.js文件,封装两个简单的数学计算方法。

//index.js
/**
 * @description: 返回数组中最大数的两倍
 * @param {Array} arr
 * @return {*}
 */
function maxDouble(arr){
  return Math.max(...arr) * 2;
}

/**
 * @description: 返回数组中最小数的两倍
 * @param {Array} arr
 * @return {*}
 */
function minDouble(arr){
 return Math.min(...arr) * 2;
}

module.exports = {
  maxDouble,
  minDouble,
};
复制代码

发布包

在命令行执行npm publish,即可发布我们的第一个npm包
截屏2021-06-29 下午10.03.55.png

到npm官网搜索即可找到我们刚发布的npm包截屏2021-06-29 下午10.05.11.png

使用发布的包

新建一个test-demo小项目,并执行安装命令,安装我们的包
截屏2021-06-29 下午10.08.33.png
截屏2021-06-29 下午10.09.36.png

新建test.js,导入我们的包

//test.js
const zzmath = require('zzmath')

console.log(zzmath.maxDouble([2, 10, 11]));
console.log(zzmath.minDouble([2, 10, 11]));
复制代码

在控制台 执行命令 node test.js,查看输出。

zhuxiaodong@zhuxiaodongdeMacBook-Pro test-demo % node test.js
22
4
复制代码

更新包

前面说过,我们的包需要严格遵守语言版本管理,我们更新包的时候需要更新包的版本号。我们可以通过修改package.json文件里version来更改包的版本。或者通过执行命令
npm version patch:更新补丁版本
npm version major:更新主版本
npm version minor:更新次版本

我们修改一下我们的包,然后更改版本号,执行npm publish即可更新包。
截屏2021-06-29 下午10.31.01.png截屏2021-06-29 下午10.31.45.png

为软件包添加dist-tags

(dist-tags)是人类可读的标签,你可以用它来组织和标记你发布的软件包的不同版本。dist-tags是对语义版本的补充。除了比语义版本号更容易让人读懂之外,标签还允许发布者更有效地发布他们的软件包。
比如我们可以为包打上beta的标签,告诉人们此包还处于beta测试中,请谨慎使用。
:::warning
:由于dist-tags与语义版本共享一个命名空间,因此要避免dist-tags与现有版本号发生冲突。建议避免使用以数字或字母 “v “开头的dist-tag。
:::

使用命令npm publish --tag <tag> 为包打标签。

也可以使用以下命令为指定的版本打上标签
npm dist-tag add <package-name>@<version> [<tag>]

我们为我们的包打上beta标签,在打标签之前需要先更新版本号。
截屏2021-06-29 下午10.39.17.png
截屏2021-06-29 下午10.41.57.png
使用npm发布插件包就讲到这里,更多知识请查看官方文档

2.commander.js的简单使用

commander.js是完整的 node.js 命令行解决方案。可以为你的脚手架工具定义执行命令、选项,处理传入的参数以及自动生成帮助信息等。基本上是现在脚手架工具必装的插件。

安装

npm install commander

声明program变量

#!/usr/bin/env node
const { program } = require('commander');

//or

const { Command } = require('commander');
const program = new Command();
复制代码

首先体验一个小例子:

  1. 新建一个项目文件夹easy-cli,进入文件夹,执行npm init根据提示一直回车,新建package.json文件。
  2. 执行npm install commander安装commander。

截屏2021-06-20 下午1.55.53.png
根据前两部操作完成之后大概是这样。

  1. 在根目录新建bin文件夹,在文件夹中新建easy.js文件。
  2. package.json文件中设置命令名到本地文件名的映射。
  "bin": {
    "easy": "bin/easy.js"
  },
复制代码
  1. easy文件开头输入#!/usr/bin/env node,指定easy.js的解释程序是node。
  2. 声明program变量
#!/usr/bin/env node
//easy.js
const { Command } = require("commander");
const program = new Command();
复制代码
  1. 定义一个选项
//easy.js
//..other code

program.option('-c, --cons', 'console hello world!')

//解析
program.parse()
const options = program.opts();

if(options.cons) console.log("hello world!");
复制代码

测试我们的命令:

在控制台输入npm i -g ../easy-cli 安装我们的cli,mac用户可能需要使用sudo权限sudo npm i -g ../easy-cli安装。这样就在全局和我们的cli项目文件建立了一个连接。
使用npm list -g可以查看我们在全局安装的所有包。

zxd@zxddeMBP easy-cli % npm list -g                
/usr/local/lib
├── @vue/cli-init@4.2.2
├── @vue/cli@4.5.9
├── easy-cli@1.0.0 -> ./../../../Users/zxd/learnSpace/easy-cli
├── express-generator@4.16.1
├── http-server@0.12.3
├── nodemon@2.0.6
├── npm@7.15.0
└── typescript@4.1.2
复制代码

可以看到我们的easy-cli已经安装到了全局。
只需建立一次连接,在后续修改cli后,不需要再次执行。

在不需要调试的时候可以使用npm unlink -g easy-cli删除连接。

在命令行执行我们的命令以及选项 easy -c
输出:
hello world!

  1. 定义一个命令
//easy.js
//..other code
program.option('-c, --cons', 'console hello world!')

program
  .command("double")
  .arguments("<number>")
  .description("将输入的参数x2")
  .action((number) => {
    console.log(number*2);
  });

program.parse()
const options = program.opts();

if(options.cons) console.log("hello world!");
复制代码

在命令行输入 easy double 2执行这个命令
输出:
4

在命令行输入 easy double 2 -c
输出:
4
hello world!

大家可以尝试执行以下命令,看看会打印什么

  1. easy
  2. easy double
  3. easy -c double 2
  4. 在没定义命令和定义命令分别执行easy -c

带着好奇和疑问,开始学习commander.js

选项(option)

option是我们能够通过命令执行的选项。例如:在安装npm包时,将包安装在dev依赖里。
npm i <package-name> -save-dev,--save-dev就是定义的选项。

Commander 使用.option() 方法来定义选项,同时可以附加选项的简介。每个选项可以定义一个短选项名称(-后面接单个字符)和一个长选项名称(–后面接一个或多个单词),使用逗号、空格或|分隔。

program.option('-s, --small', 'small pizza size')
//              短名称 长名称    选项简介
复制代码

选项参数

有两种最常用的选项,一类是 boolean 型选项,选项无需配置参数(上面体验的那种),另一类选项则可以设置参数(使用尖括号声明在该选项后,如–expect )。如果在命令行中不指定具体的选项及参数,则会被定义为undefined。带参数的又分为必填参数和可选参数

选项可以通过在Command对象上调用.opts()方法来获取
通过program.parse(arguments)方法处理参数,没有被使用的选项会存放在program.args数组中。该方法的参数是可选的,默认值为process.argv

必填参数

使用尖括号定义的参数是必填的。

#!/usr/bin/env node
const { Command } = require("commander");
const program = new Command();

program.option("-c, --cons <input>", "console input"); //参数

program.parse()

const options = program.opts();

if(options.cons) console.log(options.cons);
复制代码

执行命令 easy -c "hey,how are you?"
输出:
hey,how are you?

执行命令 easy -c
输出:
error: option ‘-c, –cons ‘ argument missing

可选参数

使用方括号定义的参数是可选的。选项在不带参数时可用作boolean选项,在带有参数时则从参数中得到值。

#!/usr/bin/env node
const { Command } = require("commander");
const program = new Command();

program.option("-c, --cons [input]", "console input or console hello world!"); //参数

program.parse();

const options = program.opts();

if (options.cons===true){
  console.log("hello world!");
}else{
  console.log(options.cons);
}

复制代码

执行命令 easy -c "hey,how are you?"
输出:
hey,how are you?

执行命令 easy -c
输出:
hello world!

选项默认值

可以通过在选项解释后面添加第四个值来为选项添加默认值。
:::warning
必填选项的默认值只在输入命令时 不输入必填选项 时使用,如果输入必填选项,但是没有输入必填选项的参数则会报错。
:::

#!/usr/bin/env node
const { Command } = require("commander");
const program = new Command();

program.option("-c, --cons [input]", "console input or console hello world!","Hola"); //可选选项  默认值
program.option(
  "-ch, --cheese <type>",
  "add the specified type of cheese",
  "blue"
);//必填选项 默认值

program.parse();
const options = program.opts();

console.log(options.cons);
console.log(`cheese: ${program.opts().cheese}`);
复制代码

执行命令 easy -c "hey,how are you?"
输出:
hey,how are you?
cheese: blue

执行命令 easy -c
输出:
Hola
cheese: blue

执行命令 easy -c -ch
输出:
error: option ‘-ch, –cheese ‘ argument missing

变长参数选项

定义选项时,可以通过使用…来设置参数为可变长参数。在命令行中,用户可以输入多个参数,解析后会以数组形式存储在对应属性字段中。在输入下一个选项前(-或–开头),用户输入的指令均会被视作变长参数。与普通参数一样的是,可以通过–标记当前命令的结束。

#!/usr/bin/env node
program
  .option('-n, --number <numbers...>', 'specify numbers')
  .option('-l, --letter [letters...]', 'specify letters');

program.parse();

console.log('Options: ', program.opts());
console.log('Remaining arguments: ', program.args);
复制代码

执行命令 easy -n 1 2 3 --letter a b c
输出:

Options:  { number: [ '1', '2', '3' ], letter: [ 'a', 'b', 'c' ] }
Remaining arguments:  []
复制代码

执行命令 easy -n 1 2 3 --letter a b c -- ext
输出:

Options:  { number: [ '1', '2', '3' ], letter: [ 'a', 'b', 'c' ] }
Remaining arguments:  [ 'ext' ]
复制代码

版本选项

version方法可以设置版本,其默认选项为-V--version,设置了版本后,命令行会输出当前的版本号。

#!/usr/bin/env node
const { Command } = require("commander");
const program = new Command();

program.version("0.0.1");

//通常使用package.json文件里的version
//program.version(require("../package.json").version)

//可以在version方法里再传递一些参数(长选项名称,描述信息),用法与option方法类似。
//program.version('0.0.1', '-v, --vers', 'output the current version');

program.parse()
复制代码

执行命令:easy -V
输出:0.0.1

帮助选项

commander.js会自动生成帮助信息。默认帮助选项帮助信息是-h,--help
命令行执行easy -h
输出:

Usage: easy [options]

Options:
  -V, --version  output the version number
  -h, --help     display help for command
复制代码

我们也可以自定义帮助信息

#!/usr/bin/env node
const { Command } = require("commander");
const program = new Command();

program
  .option('-f, --foo', 'enable some foo');

program.addHelpText('after', `
Example call:
  $ custom-help --help`);

program.parse(process.argv);
复制代码

命令行执行easy -h
输出:

Usage: easy [options]

Options:
  -f, --foo   enable some foo
  -h, --help  display help for command

Example call:
  $ custom-help --help
复制代码

更多帮助信息的设置请参考官方文档。

命令(command)

命令就是我们使用脚本执行的方法,如我们使用npm安装包的时候使用 npm i <package-name> -s或者npm install <package-name> -s命令执行安装方法,iinstall就是方法名,i是方法的简称,install是方法的全称,-s就是命令的选项。将包安装在dependencies

通过.command().addCommand()可以配置命令,有两种实现方式:

  1. 为命令绑定处理函数
  2. 将命令单独写成一个可执行文件(适用命令的逻辑过多的时候)

.command()的第一个参数可以配置命令名称及命令参数,参数支持必选(尖括号表示)、可选(方括号表示)及变长参数(点号表示,如果使用,只能是最后一个参数)。跟选项类似。尖括号(例如)意味着必选,而方括号(例如[optional])则代表可选。可以向.description()方法传递第二个参数,从而在帮助中展示命令参数的信息。该参数是一个包含了 “命令参数名称:命令参数描述” 键值对的对象。

#!/usr/bin/env node
const { Command } = require("commander");
const program = new Command();


program
  .command("clone <source> [destination]") 
	//定义命令名称clone 必填参数source 可选参数destination
  .description("clone a repository into a newly created directory") //命令的描述
  .action((source, destination) => {//命令处理函数
    console.log(`repository has been cloned from ${source} to ${destination}`);
  });

program.parse(process.argv);
复制代码

命令行执行easy clone a b
输出:

repository has been cloned from a to b
复制代码

也可以将命令写成单独的可执行文件

当.command()带有描述参数时,就意味着使用独立的可执行文件作为子命令。 Commander 将会尝试在入口脚本(例如 ./examples/pm)的目录中搜索program-command形式的可执行文件,例如easy-start, pm-install。通过配置选项executableFile可以自定义名字。

#!/usr/bin/env node
//easy.js
const { Command } = require("commander");
const program = new Command();


program
.command("start", "每隔1秒输出当前时间秒数")// 需要有 easy-start.js可执行文件
.command("start2","每隔2秒输出当前时间秒数", { executableFile: 'milliseconds' })//需要有milliseconds.js 可执行文件

program.parse(process.argv);
复制代码

新建easy-start.js文件

#!/usr/bin/env node

setInterval(() => {
  console.log(new Date().getSeconds());
}, 1000);

复制代码

新建milliseconds.js文件

#!/usr/bin/env node

setInterval(() => {
  console.log(new Date().getSeconds());
}, 2000);
复制代码

分别执行命令:easy starteasy start2,查看输出。

变长参数

在参数名后加上...来声明可变参数,且只有最后一个参数支持这种用法。

program
  .command('rmdir <dirs...>')
  .action(function (dirs) {
    dirs.forEach((dir) => {
      console.log('rmdir %s', dir);
    });
  });
复制代码

命令行输入easy rmdir 1 2 34 45
输出:
rmdir 1
rmdir 2
rmdir 34
rmdir 45

单独的可执行文件接收参数

#!/usr/bin/env node
//easy.js
const { Command } = require("commander");
const program = new Command();


program
.command("start <length> <number...>", "遍历number")// 需要有 easy-start.js可执行文件

program.parse(process.argv);
复制代码
#!/usr/bin/env node
//easy-start.js
const { Command } = require("commander");
const program = new Command();

program.parse();

console.log(program.args)
复制代码

命令行输入easy start 5 12345 4 23
输出:[ ‘5’, ‘12345’, ‘4’, ’23’ ]

需要通过制定参数位置来取参数。 “5”是length,剩下的都是number。

监听

监听命令和选项可以执行自定义函数。

#!/usr/bin/env node
const { Command } = require("commander");
const program = new Command();

program.option("-c, --cons", "console hello world!");

program
  .command("clone <source> [destination]")
  //定义命令名称clone 必填参数source 可选参数destination
  .description("clone a repository into a newly created directory") //命令的描述
  .action((source, destination) => {
    //命令处理函数
    console.log(`repository has been cloned from ${source} to ${destination}`);
  });

program.on("option:cons", function () {
  console.log("输入选项 --cons")
});

program.on("command:*", function (operands) {
  console.error(`error: unknown command '${operands[0]}'`);
  const availableCommands = program.commands.map((cmd) => cmd.name());
  console.log("availableCommands:", availableCommands);
});

program.parse();
const options = program.opts();

if (options.cons) console.log("hello world!");
复制代码

命令行输入easy clone a b -c
输出:

输入选项 --cons
repository has been cloned from a to b
hello world!
复制代码

命令行输入easy pull a b -c
输出:

输入选项 --cons
error: unknown command 'pull'
availableCommands: [ 'clone' ]
hello world!
复制代码

示例

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

program
  .version('0.0.1')
  .option('-c, --config <path>', 'set config path', './deploy.conf');

program
  .command('setup [env]')
  .description('run setup commands for all envs')
  .option('-s, --setup_mode <mode>', 'Which setup mode to use', 'normal')//命令的选项
  .action((env, options) => {
    env = env || 'all';
    console.log('read config from %s', program.opts().config);
    console.log('setup for %s env(s) with %s mode', env, options.setup_mode);
  });

program
  .command('exec <script>')
  .alias('ex')//定义别名
  .description('execute the given remote cmd')
  .option('-e, --exec_mode <mode>', 'Which exec mode to use', 'fast')
  .action((script, options) => {
    console.log('read config from %s', program.opts().config);
    console.log('exec "%s" using %s mode and config %s', script, options.exec_mode, program.opts().config);
  }).addHelpText('after', `
Examples:
  $ deploy exec sequential
  $ deploy exec async`
  );
  
program.parse(process.argv);

复制代码

更多知识请前往官方文档

3.inquirer.js的使用

inquirer.js是一个命令行交互式工具,通过设置问题、选项,在执行命令时在控制台展示这些问题,并在用户做出回答后接收答案。

安装

npm install inquirer

Demo小体验

#!/usr/bin/env node
const inquirer = require("inquirer");

const requireLetterAndNumber = (value) => {
  if (/\w/.test(value) && /\d/.test(value)) {
    return true;
  }

  return "Password need to have at least a letter and a number";
};

const questions = [
  {
    type: "expand",
    message: "Conflict on `file.js`",
    name: "q_1",
    choices: [
      {
        key: "y",
        name: "Overwrite",
        value: "overwrite",
      },
      {
        key: "a",
        name: "Overwrite this one and all next",
        value: "overwrite_all",
      },
      {
        key: "d",
        name: "Show diff",
        value: "diff",
      },
      new inquirer.Separator("."),
      {
        key: "x",
        name: "Abort",
        value: "abort",
      },
    ],
  },
  {
    type: "input",
    name: "q_2",
    message: "Question with filtering and validating Text",
    validate: async () => {
      await new Promise((r) => setTimeout(r, 3000));
      return true;
    },
    filter: async (answer) => {
      await new Promise((r) => setTimeout(r, 3000));
      return `filtered${answer}`;
    },
    filteringText: "Filtering your answer...",
    validatingText: "validating what you wrote",
  },
  {
    type: "input",
    name: "q_3",
    message: "Question without filtering and validating Text",
    validate: async () => {
      await new Promise((r) => setTimeout(r, 3000));
      return true;
    },
    filter: async (answer) => {
      await new Promise((r) => setTimeout(r, 3000));
      return `filtered${answer}`;
    },
  },
  {
    type: "input",
    name: "q_4",
    message: "what‘s your last name",
    default() {
      return "Doe";
    },
  },
  {
    type: "list",
    name: "q_5",
    message: "What do you want to do?",
    choices: [
      "Order a pizza",
      "Make a reservation",
      new inquirer.Separator(),
      "Ask for opening hours",
      {
        name: "Contact support",
        disabled: "Unavailable at this time",
      },
      "Talk to the receptionist",
    ],
  },
  {
    type: "list",
    name: "q_6",
    message: "What size do you need?",
    choices: ["Jumbo", "Large", "Standard", "Medium"],
    filter(val) {
      return val.toLowerCase();
    },
  },
  {
    type: "password",
    name: "q_9",
    message: "Enter a password",
    validate: requireLetterAndNumber,
  },
  {
    type: "password",
    name: "q_10",
    message: "Enter a masked password",
    mask: "*",
    validate: requireLetterAndNumber,
  },
  {
    type: "confirm",
    name: "q_11",
    message: "password is ok?",
    default: true,
  },
  {
    type: "input",
    name: "q_12",
    message: "How many do you need?",
    validate(value) {
      const valid = !isNaN(parseFloat(value));
      return valid || "Please enter a number";
    },
    filter: Number,
  },
  {
    type: "rawlist",
    name: "q_13",
    message: "What do you want to do?",
    choices: [
      "Order a pizza",
      "Make a reservation",
      new inquirer.Separator(),
      "Ask opening hours",
      "Talk to the receptionist",
    ],
  },
];

inquirer.prompt(questions).then((answers) => {
  console.log(JSON.stringify(answers, null, "  "));
});

复制代码

在控制台执行 node demo.js 查看效果。

通过分析上面的代码和控制台的交互效果,我们看到我们定义了一个对象questions数组,每一个对象有type``name``message,有些有validate``dafault``filter``choices等,将这个questions数组作为参数传给inquirer.prompt()方法,返回一个promise,可以得到用户在控制台输入的答案。

Methods

inquirer.prompt(questions, answers) -> promise

通过inquirer.prompt()方法启动我们的提示。
questions:(数组)包含问题对象(使用反应式接口,你也可以传递一个Rx.Observable实例
answers (对象)包含已经回答的问题的值。默认为{}。
返回一个Promise

问题(question

question有以下属性:

  1. type: (String) 提示的类型。默认值:input – 可能的值:input, number, confirm, list, rawlist, expand, checkbox, password, editor
  2. name: (String) 在答案哈希中存储答案时使用的名称。如果名字包含句号,它将在答案哈希中定义一个路径。
  3. message: (String|Function) 要打印的问题。如果定义为一个函数,第一个参数将是当前查询者会话的答案。默认为name的值(后面有一个冒号)。
  4. default: (String|Number|Boolean|Array|Function) 如果没有输入任何信息,将使用默认值,或者返回默认值的函数。如果定义为一个函数,第一个参数将是当前查询者会话的答案。
  5. choices: (Array|Function) 选择数组或一个返回选择数组的函数。如果定义为一个函数,第一个参数将是当前询问者会议的答案。数组值可以是简单的numbers, strings, objects(包含一个name(在列表中显示)、一个value(保存在答案哈希中)和一个short(选择后显示)属性。选择数组也可以包含一个分隔符。
  6. validate: (Function)接收用户的输入和答案哈希。如果值是有效的,应该返回true,否则返回一个错误信息(String)。如果返回false,会提供一个默认的错误信息。
  7. filter: (Function) 接收用户输入和答案的哈希值。返回过滤后的值,以便在程序内部使用。返回的值将被添加到Answers hash中。
  8. transformer: (Function) 接收用户输入、答案哈希值和选项标志,并返回一个转换后的值,显示给用户。转换只影响编辑时显示的内容。它不会修改答案哈希值。
  9. when: (Function, Boolean) 接收当前用户的答案哈希值,并应根据是否应该问这个问题返回truefalse。该值也可以是一个简单的布尔值。
  10. pageSize: (Number)改变使用listrawListexpandcheckbox时的行数。
  11. prefix: (String) 改变默认的前缀信息。
  12. suffix: (String)改变默认的后缀信息。
  13. askAnswered: (Boolean) 如果答案已经存在,强制提示问题。
  14. loop: (Boolean) 启用列表循环。默认值:true

回答(answers

answers:包含用户在每个提示中的答案的key/value

keyquestion对象的name属性

value可能的值:
confirmBoolean
inputString 用户输入(如果定义了过滤器,则进行过滤)
numberNumber 用户输入(如果定义了过滤器,则进行过滤)
rawlist, listNumber选定的选择值(如果没有指定值则为名称)

分隔符(Separator

分隔符可以被添加到任何choices数组中。
eg:demo中的 q_5 ,默认是“-”,可以传入指定字符作为分隔符。

完整demo

#!/usr/bin/env node
const inquirer = require("inquirer");

const choices = Array.apply(0, new Array(26)).map((x, y) =>
  String.fromCharCode(y + 65)
);
choices.push("Multiline option 1\n  super cool feature \n  more lines");
choices.push("Multiline option 2\n  super cool feature \n  more lines");
choices.push("Multiline option 3\n  super cool feature \n  more lines");
choices.push("Multiline option 4\n  super cool feature \n  more lines");
choices.push("Multiline option 5\n  super cool feature \n  more lines");
choices.push(new inquirer.Separator());
choices.push("Multiline option \n  super cool feature");
choices.push({
  name: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium.",
  value: "foo",
  short: "The long option",
});

const requireLetterAndNumber = (value) => {
  if (/\w/.test(value) && /\d/.test(value)) {
    return true;
  }

  return "Password need to have at least a letter and a number";
};

const questions = [
  {
    type: "expand",
    message: "Conflict on `file.js`",
    name: "q_1",
    choices: [
      {
        key: "y",
        name: "Overwrite",
        value: "overwrite",
      },
      {
        key: "a",
        name: "Overwrite this one and all next",
        value: "overwrite_all",
      },
      {
        key: "d",
        name: "Show diff",
        value: "diff",
      },
      new inquirer.Separator("."),
      {
        key: "x",
        name: "Abort",
        value: "abort",
      },
    ],
  },
  {
    type: "input",
    name: "q_2",
    message: "Question with filtering and validating Text",
    validate: async () => {
      await new Promise((r) => setTimeout(r, 3000));
      return true;
    },
    filter: async (answer) => {
      await new Promise((r) => setTimeout(r, 3000));
      return `filtered${answer}`;
    },
    filteringText: "Filtering your answer...",
    validatingText: "validating what you wrote",
  },
  {
    type: "input",
    name: "q_3",
    message: "Question without filtering and validating Text",
    validate: async () => {
      await new Promise((r) => setTimeout(r, 3000));
      return true;
    },
    filter: async (answer) => {
      await new Promise((r) => setTimeout(r, 3000));
      return `filtered${answer}`;
    },
  },
  {
    type: "input",
    name: "q_4",
    message: "what‘s your last name",
    default() {
      return "Doe";
    },
  },
  {
    type: "list",
    name: "q_5",
    message: "What do you want to do?",
    choices: [
      "Order a pizza",
      "Make a reservation",
      new inquirer.Separator(),
      "Ask for opening hours",
      {
        name: "Contact support",
        disabled: "Unavailable at this time",
      },
      "Talk to the receptionist",
    ],
  },
  {
    type: "list",
    name: "q_6",
    message: "What size do you need?",
    choices: ["Jumbo", "Large", "Standard", "Medium"],
    filter(val) {
      return val.toLowerCase();
    },
  },
  {
    type: "list",
    name: "q_7",
    message: "What's your favorite letter?",
    loop: false,
    choices,
  },
  {
    type: "checkbox",
    name: "q_8",
    message: "Select the letter contained in your name:",
    choices
  },
  {
    type: "password",
    name: "q_9",
    message: "Enter a password",
    validate: requireLetterAndNumber,
  },
  {
    type: "password",
    name: "q_10",
    message: "Enter a masked password",
    mask: "*",
    validate: requireLetterAndNumber,
  },
  {
    type: "confirm",
    name: "q_11",
    message: "password is ok?",
    default: true,
  },
  {
    type: "input",
    name: "q_12",
    message: "How many do you need?",
    validate(value) {
      const valid = !isNaN(parseFloat(value));
      return valid || "Please enter a number";
    },
    filter: Number,
  },
  {
    type: "rawlist",
    name: "q_13",
    message: "What do you want to do?",
    choices: [
      "Order a pizza",
      "Make a reservation",
      new inquirer.Separator(),
      "Ask opening hours",
      "Talk to the receptionist",
    ],
  },
  {
    type: "rawlist",
    name: "q_14",
    message: "What size do you need",
    choices: ["Jumbo", "Large", "Standard", "Medium", "Small", "Micro"],
    filter(val) {
      return val.toLowerCase();
    },
  },
  {
    type: "confirm",
    name: "q_15",
    message: "Do you like bacon?",
  },
  {
    type: "input",
    name: "favorite",
    message: "Bacon lover, what is your favorite type of bacon?",
    when(answers) {
      return answers.q_15;
    },
  },
];

inquirer.prompt(questions).then((answers) => {
  console.log(JSON.stringify(answers, null, "  "));
      inquirer.prompt({  //嵌套
        type: "list",
        name: "beverage",
        message: "And your favorite beverage?",
        choices: ["Pepsi", "Coke", "7up", "Mountain Dew", "Red Bull"],
      });
});


复制代码

可以注释问题对象一个个执行对应的问题,分析,不难学会对应的type的用法。
更多知识请查询官方文档,对于大多数场景,上述demo的知识点就够用了。

4.开发cli脚手架

我们创建vue项目的时候通常使用官方脚手架工具vue-cli 或者使用是自己创建模版项目,里面封装好了各种配置,通过克隆模版项目进行创建项目。首先对于vue-cli 创建的项目来说,我们每次都需要重新配置各种依赖。比如vue.config.js、axios、vuex、vue-router、ui组件库等等,重复工作很多。对于使用之前的模版创建的项目,不够灵活,简单项目不需要vuex或vue-router,不需要ui组件库,对于移动端项目,需要vconsole插件、postcss-pxtorem插件。不同的项目需要的插件不同,使用模版项目不够灵活,只能单独配置或者抽成多个模版项目。所以我们来搭建一个比较灵活的脚手架easy-vue-cli。在vue-cli的基础上,再封装一层,通过命令+询问的方式安装插件、配置项目。
easy-vue-cli选项有是否创建vue.config.js文件、是否使用Gzip压缩,是否安装axios,是否安装Vconsole,是否使用UI组件库。项目里使用了很多node.js的api,这里不详细讲述,请自行查询文档。其实在学会上面的插件的使用后,主要还是要求我们的node.js的功力,发挥自己的想象,实现一些厉害功能。
主要思路是使用vue create命令创建初始化项目,在初始化项目创建好后,询问用户是否安装插件、生成配置。

1.初始化项目

创建easy-vue-cli项目文件夹。使用命令npm init根据提示创建package.json文件。创建bin文件夹和lib文件夹,分别存放我们的命令文件和主要文件。项目还使用了oraloading 插件。可以先安装。

2.创建主要项目文件

bin文件夹新建easy.js文件。文件内容如下:

#!/usr/bin/env node
const { Command } = require("commander");
const program = new Command();

program.version(require("../package.json").version);


program
  .command("create <app-name>")   //定义脚手架的命令是 create
  .description("create a vue app and config some dependencies")
  .action((name) => { //获取项目名,调用创建项目的函数
   const options = program.opts();  
    require("../lib/create")(name, options); 
  });

program.parse(process.argv);
复制代码
在`lib`文件夹新建`create.js`文件。文件内容如下:
复制代码
#!/usr/bin/env node

const spawn = require("child_process").spawn //spawn 使用给定的 command 和 args 中的命令行参数衍生新进程
const fs = require("fs")
const inquirer = require("inquirer");
const generate = require("./generate")

/**
 * @description: delete project
 */
function removeDir(filePath) {
 const stat = fs.statSync(filePath);
 if(stat.isFile()){
   fs.unlinkSync(filePath);
 }else{
  const files = fs.readdirSync(filePath);
  console.log(files)
  if (files.length === 0) {
    fs.rmdirSync(filePath);
  } else {
    let tempFiles = 0;
    files.forEach((file) => {
      tempFiles++;
      const nextFilePath = `${filePath}/${file}`;
      removeDir(nextFilePath);
    });
    //删除母文件夹下的所有子空文件夹后,将母文件夹也删除
    if (tempFiles === files.length) {
      fs.rmdirSync(filePath);
    }
  }
 }
}

const choices = [
  {
    name: "vue.config.js",
    checked:true
  },
  {
    name: "Axios",
  },
  {
    name: "Gzip",
  },
  {
    name: "Vconsole",
  }
];

const questions = [
  {
    type: "checkbox",
    name: "configs",
    message: "Select the config in your app",
    choices,
  },
  {
    type: "confirm",
    name: "UI_Components",
    message: "Do you want to install the ui component library?",
  },
  {
    type: "list",
    name: "ui",
    message: "Select the config in your app",
    choices: ["Element UI", "Ant Design", "Vant"],
    when(answers) {
      return answers.UI_Components;
    },
  },
];


async function create(projectName,options) {
 const cmd = spawn("vue", ["create", projectName],{ stdio:["inherit", "inherit", "pipe"]}) //使用vue create 命令创建初始化项目

 cmd.on("close", function(code, signal){
   if(code===0){//vue初始化项目创建成功,调用inquirer方法,询问用户
    console.log("vue初始化项目创建成功");
    inquirer.prompt(questions).then(async (answers) => {
      if(answers.configs.includes("vue.config.js")){
       await generate.generateVueConfigJS(projectName);//调用生成vue.config.js的方法
      }
      if (answers.configs.includes("Gzip")) {
        //调用 安装并配置compression-webpack-plugin 的方法
        await generate.installCompressionWebpackPlugin(projectName);
      }
    });
   }
 })
 process.on("SIGINT", function () {//监听进程主动关闭,删掉未创建完的项目
   console.log("Got SIGINT.  Press Control-D/Control-C to exit.");
   removeDir(projectName);
 });

}

module.exports = (...args) => {
  return create(...args).catch((err) => {
    error(err);
    process.exit(1);
  });
};



复制代码

lib文件夹下新建generate.js文件。文件内容如下:

const fs = require("fs");
const ora = require("ora");
const spawn = require("child_process").spawn;

/**
 * @description: 生成vue.config.js文件
 */
function generateVueConfigJS(projectName) {
  return new Promise((resolve, reject) => {
    const spinner = ora({
      text: "Generating vue.config.js",
      color: "yellow",
    });
    let file = fs.createReadStream(`${__dirname}/vueConfig.js`, {
      encoding: "utf8",
    });

    let out = fs.createWriteStream(
      `${process.cwd()}/${projectName}/vue.config.js`,
      {
        encoding: "utf8",
      }
    );

    file.on("data", function (dataChunk) {
      out.write(dataChunk, function () {
        spinner.start();
      });
    });

    out.on("open", function (fd) {});

    file.on("end", function () {
      out.end("", function () {
        setTimeout(() => {
          spinner.succeed("Successfully generated vue.config.js");
        }, 500);
        resolve(true);
      });
    });
  });
}

//安装并配置compression-webpack-plugin
function installCompressionWebpackPlugin(projectName) {
 return new Promise((resolve, reject) => {
      const spinner = ora({
        text: "install compression-webpack-plugin",
        color: "yellow",
      }).start();
      const cmd = spawn(
        "npm",
        ["install", "compression-webpack-plugin@4.0.0"],
        {
          stdio: "pipe",
          cwd: `${process.cwd()}/${projectName}`,
        }
      );
      cmd.on("close", function (code, signal) {
        if (code === 0) {
          const content = `const path = require("path")
   const CompressionWebpackPlugin = require("compression-webpack-plugin")
   const isProd = process.env.NODE_ENV === "production"
   function resolve(dir) {
     return path.join(__dirname, dir)
   }
    `;
          const content2 = `
     configureWebpack: (config) => {
       if (isProd) {
         // 生产环境
         config.plugins.push(
           new CompressionWebpackPlugin({
             // 正在匹配需要压缩的文件后缀
             test: /\.(js|css|svg|woff|ttf|json|html)$/,
             // 大于10kb的会压缩
             threshold: 10240,
             deleteOriginalAssets: false
             // 其余配置查看compression-webpack-plugin
           })
         )
       }
     },`;
          //往固定的行写入数据
          const data = fs
            .readFileSync(`${process.cwd()}/${projectName}/vue.config.js`, "utf8")
            .split("\n");
          data.splice(0, 0, content);
          fs.writeFileSync(
            `${process.cwd()}/${projectName}/vue.config.js`,
            data.join("\n"),
            "utf8"
          );
          const data2 = fs
            .readFileSync(`${process.cwd()}/${projectName}/vue.config.js`, "utf8")
            .split("\n");
          data2.splice(data2.length - 45, 0, content2);
          fs.writeFileSync(
            `${process.cwd()}/${projectName}/vue.config.js`,
            data2.join("\n"),
            "utf8"
          );
          spinner.succeed("install compression-webpack-plugin success");
          resolve();
        } else {
          spinner.warn("install compression-webpack-plugin error");
          reject(`install compression-webpack-plugin error`);
        }
      });
 });


}

module.exports = {
  generateVueConfigJS,
  installCompressionWebpackPlugin,
};

复制代码
分别是生成`vue.config.js`文件和安装并配置`compression-webpack-plugin`插件的方法。<br />​
复制代码

新建vueConfig.js文件,存放我们的vue.config.js文件的模版文件,大家可以灵活调整。

module.exports = {
  publicPath: "./", //配置根路径
  outputDir: "dist", //构建输出目录
  assetsDir: "assets", //静态资源目录(js\css\img)
  lintOnSave: true, //是否开启eslint
  productionSourceMap: false, // 生产环境是否生成 sourceMap 文件
  devServer: {},
  css: {
    // 是否使用css分离插件 ExtractTextPlugin
    //如果需要css热更新就设置为false,打包时候要改为true
    extract: false,
    // 开启 CSS source maps?
    sourceMap: process.env.NODE_ENV !== "production",
    // css预设器配置项
    // loaderOptions: {
    //   sass: {
    //     prependData: `@import "@/styles/variables.scss";`,
    //   },
    // },
  },

  chainWebpack: (config) => {
    config.resolve.symlinks(true);
    (config.entry.app = ["babel-polyfill", "./src/main.js"]),
      // 别名配置
      config.resolve.alias
        .set("@", resolve("src"))
        .set("@utils", resolve("src/utils"))
        .set("@api", resolve("src/api"))
        .set("@components", resolve("src/components"))
        .set("@pic", resolve("src/assets/imgs"));
    config.resolve.extensions.clear().merge([".js", ".vue", ".json"]);


    config.optimization.splitChunks({
      chunks: "all", // 控制webpack选择哪些代码块用于分割(其他类型代码块按默认方式打包)。有3个可选的值:initial、async和all。
      minSize: 30000, // 形成一个新代码块最小的体积
      maxSize: 0,
      minChunks: 2, // 在分割之前,这个代码块最小应该被引用的次数(默认配置的策略是不需要多次引用也可以被分割)
      maxAsyncRequests: 5, // 按需加载的代码块,最大数量应该小于或者等于5
      maxInitialRequests: 3, // 初始加载的代码块,最大数量应该小于或等于3
      automaticNameDelimiter: "~",
      name: true,
      cacheGroups: {
        libs: {
          name: "chunk-libs",
          test: /[\\/]node_modules[\\/]/,
          priority: 10,
          chunks: "initial", // only package third parties that are initially dependent
        },
        commons: {
          name: "chunk-commons",
          test: resolve("src/components"), // can customize your rules
          minChunks: 3, //  minimum common number
          priority: 5,
          reuseExistingChunk: true,
        },
      },
    });

    config.plugins.delete("prefetch-index");
    config.plugins.delete("preload-index");
  },
};

复制代码

3.配置命令

package.json文件中添加bin属性

  "bin": {
    "easy": "bin/easy.js"
  },
复制代码

4.测试

,使用npm安装我们的脚手架。npm i ../脚手架路径/
使用easy create test命令创建项目,会先调用vue-cli创建初始化项目
截屏2021-07-11 下午2.17.25.png截屏2021-07-11 下午2.18.14.png截屏2021-07-11 下午2.19.23.png
项目创建成功,查看test项目,会发现项目生成了vue.config.js文件,安装并配置好了compression-webpack-plugin

5.结束

这里easy-vue-cli只实现了生成vue.config.js文件和安装并配置compression-webpack-plugin插件,安装axios和安装ui组件并没有实现,可以参考上述方法自行实现。篇幅太长了。大家可以优化代码、使用远程的模版文件等等。这里只是给出一个思路。有改进的地方、想法欢迎提出讨论、修改。谢谢。

最后

篇幅太长了,谢谢大家能够耐心看完,我还是小菜鸡一枚,欢迎提出改进、错误的地方,如果文章内容对你有一点作用希望可以给一个?, 冲冲冲!!!冲!!!。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享