认识前端脚手架

什么是 CLI

CLI (Command Line Interface) ,顾名思义是一种通过命令行来交互的工具或者说应用。前端常用的 CLI 有 @angular/cli@vue/clicreate-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 则相当于执行:

  1. npm i create-react-app 安装到临时目录,使用完之后会删除,不占用户存储空间
  2. 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 中:

{
"bin": {
"vue": "bin/vue.js"
}
}

在安装 @vue/cli 后,就可以通过 vue 命令来使用了。命令对应的可执行文件需要在文件首行设置下面代码:

#!/usr/bin/env node

该行代码告诉操作系统,使用 env 来找到 node,并使用 node 来作为程序的解释程序。

scripts

这个可能是所有前端使用率最高的一个字段了,开发者通过设置 scripts 字段来自定义一些脚本

{
"scripts": {
"build": "node ./build.js",
"start": "node ./server.js"
}
}

这样就可以通过 npm run buildnpm start 来执行对应的文件了。不过关于 npm-scripts 还有一些容易遗漏的知识点需要知道:

  • 有些命令是不需要加 run 的,比如 npm start 、npm test 、 npm lint
  • 使用 & 符号并行执行多个 npm scripts ,使用 && 符号串行执行脚本
  • npm script 也是有钩子,分别是 pre 和 post ,对应执行前和执行后
{
"scripts": {
"prebuild": "eslint",
"build": "node ./build.js",
"postbuild": "npm start",
"start": "node ./server.js"
}
}

当执行 npm run build 时,会依次执行 npm run prebuildnpm run buildnpm 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:

{
"scripts": {
"build": "webpack"
}
}

等同于

{
"scripts": {
"build": "./node_modules/.bin/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
node serve.js 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 = {
'save-dev': 'S',
'S': 'save-dev'
};

依托于强大的 npm ,我们可以直接使用已有且成熟的额命令行参数解析库,比如 noptmriminimistyargs-parser 等,其中 mri 解析效率最高, minimist 覆盖参数输入形式最全。

命令行交互

在 web 开发中,可以使用 prompt 显示对话框进行交互,在 node 中也有类似的功能 readline ,它提供了 question 和 prompt 方法构建命令行界面。

const readline = require('readline');
const questions = ['请输入姓名:', '请输入年龄:', '请输入性别:'];
const answers = [];
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: questions[0]
});
rl.prompt();
rl
.on('line', (line) => {
answers.push(line.trim());
while (answers.length === questions.length) {
rl.close();
}
rl.setPrompt(questions[answers.length])
rl.prompt();
})
.on('close', () => {
const result = questions
.map((v, i) => v.replace('请输入', '') + answers[i])
.join(',')
console.log(result);
process.exit(0);
});

命令行交互

上面的交互的形式相信每为学习编码的同行都写过,只不过当初我们学习的时候用的 c++,而现在换成了 node.js 。

对于常见的问答形式,我们使用原生的 readline 还可以应付,但是在 cli 中,不仅仅是只有问答形式的交互,还有单项选择、进度条等等,同上文提到参数解析一样,我们也可以借助现有的库去完成,比如 inquirerenquirerprompts 等等。

在编写 cli 工具的时候,通常选用 tj 大神开发的 commander , 完整的 node.js 命令行解决方案,包含了参数解析、命令行交互等等。

前端脚手架常见功能

  • 通过交互收集用户输入的信息,并创建 package.json
  • 提供 Babel 、TypeScript 、ESLint 、 StyleLint 、CSS 预处理器 这些常见的前端工具
  • 从 github 拉取模板并创建项目

创建项目

通过 npm init -y 快速创建 package.json ,在 bin 字段中添加我们要暴露的命令名称,同时为了适配 npm init <initializer> 的形式使用我们的脚手架,我们的项目要以 create 开头,同时在 bin 字段中添加相关脚本

{
"name": "@onlymisaky/create-project",
"version": "0.0.1",
"bin": {
"@onlymisaky/create-project": ".bin/create-project.js",
"create-project": ".bin/create-project.js",
"pj": ".bin/index.js"
},
"author": "onlymisaky"
}

.bin/create-project.js 负责接收并处理 create-project 传入的参数,调用相关方法创建项目

.bin/index.js 则负责处理 pj 命令相关的功能,由于 demo 先对简单所以 pj 和 create-project 的功能相同,只是调用方式不同。

commander

这里我们使用上文提到 commander 来注册命令

create-project/.bin/create-project.js

#!/usr/bin/env node
const { program } = require('commander');
program
.arguments('<项目名称>')
.action((name) => {
require('../lib/create')(name);
})
.parse();

create-project/.bin/index.js

#!/usr/bin/env node
const { program } = require('commander');
const pkg = require('../package.json');
program
.version(pkg.version)
.description(pkg.description)
.usage('<command> [options]');
program
.command('create <项目名称>')
.description('创建一个新项目')
.action((name) => {
require('./../lib/create')(name);
});
program.parse();

arguments 接收参数,command 注册命令,在执行命令时( action ) 调用 lib/create 创建项目。

inquire

在 lib/create 中通过 inquire 完成用户和脚手脚手架的交互。
inquire 的关键用法就是 prompt 函数,该函数接收一个数组,用于依次询问用户需要做哪些事情

prompt 参数介绍

  • message: 终端显示的消息
  • name: 答案对用的 key
  • type: 消息类型,常用的类型如下
    • input/number/password: 需要用户输入文本
      • checkbox: 提供一组选项供用户选择(多选)
      • list/rawlist: 提供一组选项供用户选择(单选)
      • confirm: 询问用户 是或否
  • default: 默认值
  • when: 是否执行该步骤,可以是 bool 值 或函数,由于 prompt 需要一次性所有的操作步骤,而有些步骤是前后连续的,所以需要通过 when 函数的参数(已经获取的答案)来判断是否需要执行该步骤
  • choices: 当 type 为 list/rawlist/checkbox 时,需要为此字段配置选项
const answerMap = await inquirer.prompt([
{
name: 'name',
message: '项目名称',
type: 'input',
default: projectName
},
{
name: 'description',
message: '项目描述',
type: 'input',
},
{
name: 'author',
message: '作者',
type: 'input',
},
{
name: 'features',
message: '检查项目所需的功能',
type: 'checkbox',
choices: [
{
name: 'Babel',
value: 'babel',
short: 'Babel',
link: 'https://babeljs.io/',
checked: true,
},
{
name: 'TypeScript',
value: 'ts',
short: 'TS',
link: 'https://www.tslang.cn/',
},
{
name: 'ESLint',
value: 'eslint',
short: 'ESLint',
link: 'https://eslint.org/',
checked: true,
},
{
name: 'StyleLint',
value: 'stylelint',
short: 'StyleLint',
link: 'https://stylelint.io/',
},
{
name: 'CSS 预处理器',
value: 'css-preprocessor',
short: 'CSS 预处理器',
}
],
},
{
name: 'eslint',
message: '选择预设的 ESLint 规则',
when: (answers) => {
return answers.features.includes('eslint');
},
type: 'list',
choices: [
{
name: 'ESLint with error prevention only',
value: 'base',
short: 'Basic'
},
{
name: 'ESLint + Airbnb config',
value: 'airbnb',
short: 'Airbnb'
},
{
name: 'ESLint + Standard config',
value: 'standard',
short: 'Standard'
},
{
name: 'ESLint + Prettier',
value: 'prettier',
short: 'Prettier'
},
],
},
{
name: 'stylelint',
message: '选择预设的 StyleLint 规则',
when: (answers) => {
return answers.features.includes('stylelint');
},
type: 'list',
choices: [
{
name: 'StyleLint with error prevention only',
value: 'base',
short: 'Basic'
},
{
name: 'StyleLint + Standard config',
value: 'standard',
short: 'Standard'
},
],
},
{
name: 'git',
message: '是否创建 git 仓库',
type: 'confirm',
default: true,
},
{
name: 'git-msg',
message: 'git message',
type: 'input',
when: (answers) => {
return answers.git;
},
default: 'initial project',
}
]);

console.log(answerMap);

该函数会返回一个 promise ,参数是用户输入的所有答案,我么接下来要做的就是分析答案执行创建 package.json 、下载模板、配置项目等等事情了。

{
name: 'my-project',
description: 'pj创建的项目',
author: 'onlymisaky',
features: ['babel', 'ts', 'eslint', 'stylelint', 'css-preprocessor'],
eslint: 'airbnb',
stylelint: 'standard',
git: true,
'git-msg': 'initial project'
}

生成项目

模板方案

一般生成项目都是采用以现有的模板为基础,同时解析用户输入的答案创建用户想要的初始化项目。CLI 提供了多样化配置,我们不可能编写 n 多份模板,常规做法是编写一份功能配置完善的模板,借助模板引擎来时先多样化配置,常用的模板引擎有 ejs 、handlebarsjs

{{#eslint}}const createLintingRule = () => ({
test: /\.(js|vue)$/,
loader: 'eslint-loader',
enforce: 'pre',
include: [resolve('src'), resolve('test')],
options: {
formatter: require('eslint-friendly-formatter'),
emitWarning: !config.dev.showEslintErrorsInOverlay
}
}){{/eslint}}

也需要考虑模板的存放位置,对于简单且不常更新的模板,可以随 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 要考虑在不同平台的兼容性问题
  • 命令要避免和常见的命令冲突
  • 考虑到可扩展性
请我喝杯咖啡
请我喝杯咖啡