什么是 CLI
CLI (Command Line Interface) ,顾名思义是一种通过命令行来交互的工具或者说应用。前端常用的 CLI 有 @angular/cli 、 @vue/cli 、 create-react-app 。
它们可以帮助开发者减少低级重复劳动,专注业务提高开发效率,规范 develop workflow。
为什么要用 CLI
我们可以将工作中繁杂、有规律可循、或者简单重复劳动的工作用 CLI 来完成,只需一些命令,快速完成简单基础劳动。现有工作中的可以用 CLI 完成的工作有:
- 快速生成应用模板,创建 module 模板文件
- 服务启动,如 ng serve
- eslint ,代码校验
- 自动化测试
- 编译build
总体而言就是一些快捷的操作替代人工重复劳动,提升开发效率。
与 npm scripts 相比
npm scripts 也可以实现开发工作流,通过在 package.json 中配置 scripts 字段,执行相关命令,但 CLI 与 npm scripts 相比仍有自己优势:
- npm scripts 只能在项目中使用, CLI 可以全局安装,到处使用
- npm scripts 与业务耦合度高,而 CLI 可以和业务代码剥离
- CLI 可以单独迭代开发,改进原有功能,增加新特性
package.json
每一个以 node 为基础为项目,在根目录中都有 package.json 文件,用于存储项目的信息、配置、运行环境和所依赖的模块等。
接下来介绍一些实现一个 CLI 需要了解的 package.json 中的字段。
name
项目名称
- 只能是小写字母、数字、~、-、_ 这些内容
- 不支持空格,使用连字符区分多个单词
- 支持 scope ,以 @name/ 开头,不过该形式的包托管到npm是收费的 (just 7$)
在创建 js 项目的时候,我们通常会通过 npm init
来快速初始化 package.json
文件,也可以通过 npm int -y
使用默认是值,快速创建。
也可以通过 npm init <initializer>
来快速执行某个包中的脚本创建项目,比如常见的:
npm init react-app project-name
npm init vite@latest my-vue-app --template vue
其实背后执行的是 npx create-react-app project-name
而npx 则相当于执行:
npm i create-react-app
安装到临时目录,使用完之后会删除,不占用户存储空间create-react-app project-name
所以细心的你也可以发现,如果一个包名以 create
开头,那就可以通过 npm init <initializer>
来快速调用该包提供的脚本命令,创建新项目
命令 | 等同 |
---|---|
npm init foo | npx create-foo |
npm init @user/foo | npx @user/create-foo |
npm init @user | npx @user/create |
我们也可以利用这个形式创建一些简便的脚手架工具
bin
指定内部命令所对应可执行文件的位置
编写一个脚手架,我们肯定需要用户安装好我们的包之后,通过命令行的方式直接使用,那就非常有必要设置该字段了,比如 @vue/cli 中:
{ |
在安装 @vue/cli 后,就可以通过 vue
命令来使用了。命令对应的可执行文件需要在文件首行设置下面代码:
该行代码告诉操作系统,使用 env 来找到 node,并使用 node 来作为程序的解释程序。
scripts
这个可能是所有前端使用率最高的一个字段了,开发者通过设置 scripts 字段来自定义一些脚本
{ |
这样就可以通过 npm run build
和 npm start
来执行对应的文件了。不过关于 npm-scripts 还有一些容易遗漏的知识点需要知道:
- 有些命令是不需要加 run 的,比如 npm start 、npm test 、 npm lint
- 使用 & 符号并行执行多个 npm scripts ,使用 && 符号串行执行脚本
- npm script 也是有钩子,分别是 pre 和 post ,对应执行前和执行后
{ |
当执行 npm run build
时,会依次执行 npm run prebuild
、npm run build
、 npm run postbuild
- 可以通过 process.env.npm_** 来获取一些值
- process.env.npm_lifecycle_event 获取正在运行的脚本名称
- process.env.npm_package_** 获取 package.json 相关字段的值
- process.env.npm_config_argv 获取参数
npm scripts 原理
在执行 npm run xx
的时候会自动根据不同平台创建一个 shell
, 类 UNIX
中代指 /bin/sh
,Windows
中使用的是 cmd.exe
, npm scripts 脚本就在这个新创建的 shell 中被运行。
所以可以得出一些结论:
- 只要是 shell 可以运行的命令,都可以作为 npm scripts 脚本
- npm 脚本的退出码,也自然遵循 shell 脚本规则
- 如果系统里安装了 Python ,可以将 Python 作为 npm scripts
- npm scripts 脚本可以使用 shell 的通配符等常规能力 { “scripts”: { “lint”: “eslint **/*.js” } }
不过 npm scripts 创建的 shell 也有一些特殊之处:
创建出来的 shell 会将当前目录的 node_modules/.bin
子目录加入 PATH 变量中,执行完成后,再将 PATH 变量恢复。比如我们在项目中安装 webpack 后,不用写出完成整路径便可以使用 webpack:
{ |
等同于
{ |
dependencies 和 devDependencies
分别对应项目运行所需依赖和开发所需依赖,这里需要知道依赖版本符号的含义:
- version 完全匹配
version 大于该版本
=version 大于等于该版本
- <version 小于该版本
- <=version 小于等于该版本
- ~version 非常接近该版本
- ^version 与该版本兼容 (默认,常用)
- 任意版本
- latest 最新版本
description、keywords 分别对应项目的描述和关键词,在 npm search 时会用到
npm link
在开发 npm 包的时候,我们需要调试包的功能是否正确,直接发布到 npm 是不太现实的,直接将包拷贝到项目中这种方式也显得很傻。这时候我们就可以借助 npm link 命令创建软链接,将其链接到全局 node 模块安装路径中,同时也会将 package.json 中的 bin 字段对应的脚本生成一个可执行文件。
命令行参数
在执行 node 脚本或通过 npm scripts 执行命令的时候,也是可以传递参数的:
npm run serve --prot=80 |
上面两种方式都传入了参数 prot=80 ,在 npm 中通过 process.env.npm_config_argv
获取参数,在 node 中通过 process.argv 获取参数。在构建 cli 工具时,我们需要考虑参数风格,常见的参数风格有三种:
- Unix 风格:前面加 -, 后面跟的是单个字符,例如 ls -l , ls -al 则相当于 ls -a -l
- GNU 风格:前面加 – ,例如上面的 npm run serve –prot=80
- BSD 风格:前面不叫任何修饰符 例如上面的 node serve.js prot=80
参数别名
优秀的 cli 工具在参数的解析上都支持设置参数名,例如 --version
-V
这种常见的形式。实现别名和很简单,只要创建一个别名映射表,然后转化为双向映射即可:
const alias = { |
依托于强大的 npm ,我们可以直接使用已有且成熟的额命令行参数解析库,比如 nopt 、mri 、minimist 、yargs-parser 等,其中 mri 解析效率最高, minimist 覆盖参数输入形式最全。
命令行交互
在 web 开发中,可以使用 prompt 显示对话框进行交互,在 node 中也有类似的功能 readline ,它提供了 question 和 prompt 方法构建命令行界面。
const readline = require('readline'); |
上面的交互的形式相信每为学习编码的同行都写过,只不过当初我们学习的时候用的 c++,而现在换成了 node.js 。
对于常见的问答形式,我们使用原生的 readline 还可以应付,但是在 cli 中,不仅仅是只有问答形式的交互,还有单项选择、进度条等等,同上文提到参数解析一样,我们也可以借助现有的库去完成,比如 inquirer 、enquirer 、prompts 等等。
在编写 cli 工具的时候,通常选用 tj 大神开发的 commander , 完整的 node.js 命令行解决方案,包含了参数解析、命令行交互等等。
前端脚手架常见功能
- 通过交互收集用户输入的信息,并创建 package.json
- 提供 Babel 、TypeScript 、ESLint 、 StyleLint 、CSS 预处理器 这些常见的前端工具
- 从 github 拉取模板并创建项目
创建项目
通过 npm init -y
快速创建 package.json ,在 bin
字段中添加我们要暴露的命令名称,同时为了适配 npm init <initializer>
的形式使用我们的脚手架,我们的项目要以 create 开头,同时在 bin 字段中添加相关脚本
{ |
.bin/create-project.js 负责接收并处理 create-project 传入的参数,调用相关方法创建项目
.bin/index.js 则负责处理 pj 命令相关的功能,由于 demo 先对简单所以 pj 和 create-project 的功能相同,只是调用方式不同。
commander
这里我们使用上文提到 commander 来注册命令
create-project/.bin/create-project.js
|
create-project/.bin/index.js
|
arguments 接收参数,command 注册命令,在执行命令时( action ) 调用 lib/create 创建项目。
inquire
在 lib/create 中通过 inquire 完成用户和脚手脚手架的交互。
inquire 的关键用法就是 prompt 函数,该函数接收一个数组,用于依次询问用户需要做哪些事情
prompt 参数介绍
- message: 终端显示的消息
- name: 答案对用的 key
- type: 消息类型,常用的类型如下
- input/number/password: 需要用户输入文本
- checkbox: 提供一组选项供用户选择(多选)
- list/rawlist: 提供一组选项供用户选择(单选)
- confirm: 询问用户 是或否
- input/number/password: 需要用户输入文本
- default: 默认值
- when: 是否执行该步骤,可以是 bool 值 或函数,由于 prompt 需要一次性所有的操作步骤,而有些步骤是前后连续的,所以需要通过 when 函数的参数(已经获取的答案)来判断是否需要执行该步骤
- choices: 当 type 为 list/rawlist/checkbox 时,需要为此字段配置选项
const answerMap = await inquirer.prompt([ |
该函数会返回一个 promise ,参数是用户输入的所有答案,我么接下来要做的就是分析答案执行创建 package.json 、下载模板、配置项目等等事情了。
{ |
生成项目
模板方案
一般生成项目都是采用以现有的模板为基础,同时解析用户输入的答案创建用户想要的初始化项目。CLI 提供了多样化配置,我们不可能编写 n 多份模板,常规做法是编写一份功能配置完善的模板,借助模板引擎来时先多样化配置,常用的模板引擎有 ejs 、handlebarsjs
{{#eslint}}const createLintingRule = () => ({ |
也需要考虑模板的存放位置,对于简单且不常更新的模板,可以随 CLI 一起打包发布至 npm ,而稍微复杂一点的还是建议将模板和 CLI 分开,因为放在一起有一个明显的缺陷,当模板需要更新的时候,即使是更新一个依赖这种和 CLI 毫无关系的操作,也要被迫的提升 CLI 的版本号,增加用户的使用负担。所以模板一般都存放在远程,比如 github ,然后借助 download-git-repo 下载到本地。
在模板下载并解析完成后,就需要将文件写入到项目中,可以使用 fs.writeFile ,也可以使用 metalsmith ,不过要考虑边界值得情况,比如文件是否已经存在,删除原有文件等等。
插件方案
模板的方案也有明显的缺陷,就是模板需要增加新功能时,需要在原有基础上做修改,随着不断的迭代,模板会变得越来越复杂。可以采用 vue-cli3 的插件方案解决该问题,模板只提供最小最基础的功能,其余的功能皆交给插件方案,比如 @vue/cli-plugin-babel 只负责 babel 相关的功能,而插件的实现其实和模板方案差不多,都是读写文件,编译模板之类的。这样做既方便维护和扩展,也避免了去修改一份庞大的模板项目。
自动执行命令
在项目创建完成之后,需要执行一些常规的初始化命令, 比如 git init 初始化 git 仓库; npm install 安装依赖等等,我们可以使用 node 内置的 process.exce 或者使用 shelljs 之类包来完成。
优化体验
虽然 CLI 是运行在一个简陋的终端里面,但我们也要尽可能的提升 CLI 的使用体验,比如:
- 使用 chalk ,对不同类型的提示设置不同的颜色
- 在需要等待的操作过程中,使用 ora 让命令行输出 loading ,或使用 progress 显示进度条
- 使用 boxen 在命令行中花去 boxes 区块
- 编写通俗易懂、完善的usage
没有提到,也很重要
- 编写 cli 要考虑在不同平台的兼容性问题
- 命令要避免和常见的命令冲突
- 考虑到可扩展性