前端开发中,经常会用到一些 CLI 工具, ts-node 转换ts文件, babel-cli 来解析ES6+语法,还有初始化SPA项目的脚手架, create-react-app 、 vue-cli 、 vite 等等,这些cli工具通过自动化脚本节约了开发者在配置 webpack , tsconfig , babel 上面的时间,实现了快捷开发。但是你真的了解cli工具吗,下面就通过打造一个自定义的cli工具来讲讲其中的原理。

CLI使用小窍门

会写 react 的朋友肯定有用过 create-react-app ,脚手架的常见使用步骤会分成两步,第一步是全局安装,第二步执行cli,比如下面命令:

# npm安装:
npm install -g create-react-app
# 或者yarn安装:
yarn global add create-react-app
# 执行cli:
create-react-app my-project
复制代码

如何打造属于自己的CLI工具_java

但是如果想避免模块全局安装,可以尝试 npx , npx会先检查本地依赖包有没有可执行文件,如果找不到就会去远程仓库下载,并且会下载到临时文件,在使用完就删除,是不是有种阅后即焚的赶脚,比如:

# npx安装:
npx create-react-app my-project
复制代码

从npm的 6.1 版本开始,使用 npm init 或者 yarn create 命令可以直接省略 create- 前缀,比如 create-react-app 就变成如下:

# npm安装:
npm init react-app my-project
# yarn安装:
yarn create react-app my-project
复制代码

新的CLI项目

CLI项目起名并不一定要 create-* 前缀,比如vue-cli,但是 create-xxx 确实让人更好理解,一般脚手架项目推荐用create,所以这里用来演示的demo就取名为 create-tst 。

mkdir create-tst && cd create-tst
npm init --yes
复制代码

--yes 跳过提示,创建默认配置的package.json

接下来再创建一个接收命令,并执行脚本的js文件,常规做法是建一个 bin 目录放对应的js脚本文件,并在 package.json 中配置 "bin" 字段信息。

mkdir bin && cd bin
touch create-tst.js
复制代码

如何打造属于自己的CLI工具_java_02

create-tst.js的内容如下:

#!/usr/bin/env node

require = require('esm')(module /*, options*/);
require('../src/cli').cli(process.argv);
复制代码

首先是使用本地的node命令,然后 esm 库是为了后面的执行代码能够支持ES6+语法,具体的cli逻辑一般会放在 src/cli.js 里执行, process.argv 是之后命令行执行的所有参数。

在讲cli.js的逻辑之前,这里先列一下整体的目录结构:

|____bin
| |____create-app.js  命令行入口
|____package.json
|____templates
| |____TypeScript     js模板
| |____JavaScript     ts模板
|____src
 |____main.js        执行create任务
 |____cli.js         处理用户输入
复制代码

还有相关的依赖(下文会提到每个依赖的用处):

依赖库作用npm地址
arg解析原始的命令行参数,返回对象传送门
chalk让命令行输出支持高亮加粗传送门
esm让node6+都支持ES6语法传送门
execa执行其他的命令行语句传送门
inquirer支持复杂的交互提示,比如选择,确认传送门
listr管理执行任务,支持串行、并行传送门
ncp异步执行文件复制传送门
pkg-install在js中安装npm依赖包传送门

交互式的UI

执行脚本前,需要先在当前目录下执行一次 npm link 命令,这个命令会在全局环境下生成一个符号链接文件,文件的名字是package.json中制定的模块名,本地就可以执行 create-tst 命令

解析入参

在src目录下建一个cli.js文件,可以把命令行执行输入的参数打印出来看看:

export async function cli(args) {
 console.log(args)
}
复制代码

如何打造属于自己的CLI工具_java_03

cli.js输出了一个数组,有五个元素,第一个和第二个都是固定的,后面三个都是用户自定义输入的参数,所以我们只用解析除了前两个外的所有参数,这时候就可以用arg来解析了。

// 解析输入参数
const parseArgsIntoOptions = (rawArgs) => {
 const args = arg({
   '--git': Boolean, // 解析成布尔值
   '--yes': Boolean,
   '--install': Boolean,
   '-g': '--git', // 参数映射,-g 等同于 --git
   '-y': '--yes',
   '-i': '--install',
 }, {
   argv: rawArgs.slice(2)
 })
 return {
   skipPrompts: args['--yes'] || false,
   initGit: args['--git'] || false,
   template: args._[0],
   runInstall: args['--install'] || false
 }
}
复制代码

当然,用 commander 工具来解析也是一个不错的选择。

如果arg第一个对象参数里没有配置,就会统一进入args._数组内,比如javascript

GUI选择配置

使用过vue-cli的朋友肯定知道创建项目的时候界面会提供vue不同的版本,或者自定义配置,这复杂的交互就需要用到上面说到的inquirer。 如何打造属于自己的CLI工具_java_04

这里我们就把通过提示获取相关配置的逻辑写成一个通用函数,如果用户不使用默认配置或没输入相关参数,就会出现提示:

  • 选择项目模板

  • 选择是否初始化git

  • 选择是否安装npm依赖

const promptForOptions = async (options) => {
 const defaultTemplate = 'JavaScript';
 if (options.skipPrompts) {
   return {
     ...options,
     template: options.template || defaultTemplate
   }
 }

 const questions = [];
 if (!options.template) {
   // 1. 选择项目模板
   questions.push({
     type: 'list',
     name: 'template',
     message: '请选择当前新建项目的模板',
     choices: ['JavaScript', 'TypeScript'],
     default: defaultTemplate
   })
 }

 if (!options.initGit) {
   // 2. 选择是否初始化git
   questions.push({
     type: 'confirm',
     name: 'git',
     message: '是否初始化git仓库',
     default: false
   })
 }

 if (!options.runInstall) {
   // 3. 选择是否安装npm依赖
   questions.push({
     type: 'confirm',
     name: 'install',
     message: '是否安装依赖',
     default: false
   })
 }

 const answers = await inquirer.prompt(questions)

 return {
   ...options,
   template: options.template || answers.template,
   git: options.initGit || answers.git,
   install: options.runInstall || answers.install
 }
}
复制代码

如何打造属于自己的CLI工具_java_05 如何打造属于自己的CLI工具_java_06 如何打造属于自己的CLI工具_java_07 效果不错,和 vue-cli 差不多

最后改下一下cli函数,引入一个 createSpaApp ,具体的逻辑下面会提到。

import createSpaApp from './main'

function cli(args) {
 let options = parseArgsIntoOptions(args)
 options = await promptForOptions(options)
 createSpaApp(options)
}
复制代码

核心功能

有了配置后就可以考虑如何让配置的参数生效了,假设用户把所有配置都打开,那么就要做三件事情:

  1. 新建模板文件

  2. 初始化git仓库

  3. 安装npm依赖

新建模板文件

新建模板文件其实就是直接复制预设的模板文件到目标目录,目标目录就是用户当前执行命令行的目录,所以这里写一个copy函数。

import ncp from 'ncp'
import { promisify } from 'util'

const copy = promisify(ncp)
// 复制文件
const copyTemplateToTarget = async (options) => {
 return copy(options.templateDir, options.targetDir, {
   clobber: false // 直接覆盖已有文件
 })
}

...
try {
 // 检查文件是否存在于当前目录中
 await access(templateDir, fs.constants.F_OK);
} catch (e) {
 console.error('%s Invalid template name', chalk.red.bold('ERROR'));
 process.exit(1);
}
复制代码

在真正执行copy函数前,还得考虑模板文件是否存在,如果子弹都没有,那就直接退出了。

预设的模板根目录要和上面的选项能够对应起来,模板内容这里就不提供了,可以自行定义。

初始化git仓库

git仓库初始化一般直接运行 git init 即可,所以这里也按着思路,直接利用 execa 来运行git命令行,这里使用async语法让执行逻辑更加清晰:

import execa from 'execa'

const initGit = async (options) => {
 const result = await execa('git', ['init'], {
   cwd: options.targetDir
 })
 if (result.failed) {
   return Promise.reject(new Error('Failed to initialize git'))
 }
 return
}
复制代码

安装npm依赖

前端项目初始化后,可以做的更加自动化一点,直接帮用户把npm包也给安装了,这里也有个现成的工具叫 pkg-install ,支持yarn安装,promise的用法。

import { projectInstall } from 'pkg-install';

await projectInstall({
 prefer: 'yarn',
 cwd: options.targetDir
})
复制代码

串行任务

定义好了各个选项对应的功能后,就要把配置对应的功能接上,这里用 listr 进行任务管理,最终的执行函数如下:

export default async function createSpaApp(options) {
 options = {
   ...options,
   targetDir: options.targetDir || process.cwd()
 }
 // 预设模板目录
 const templateDir = path.resolve(
   new URL(import.meta.url).pathname,
   '../../templates',
   options.template
 )
 options.templateDir = templateDir;

 try {
   // 检查文件是否存在于当前目录中
   await access(templateDir, fs.constants.F_OK);
 } catch (e) {
   console.error('%s Invalid template name', chalk.red.bold('ERROR'));
   process.exit(1);
 }

 const tasks = new Listr([
   {
     title: 'Copy project files',
     task: () => copyTemplateToTarget(options)
   },
   {
     title: 'Initialize git',
     task: () => initGit(options),
     enabled: () => options.git
   },
   {
     title: 'Install dependencies',
     task: () =>
       projectInstall({
         prefer: 'yarn',
         cwd: options.targetDir
       })
     ,
     skip: () => {
       !options.runInstall
         ? 'Pass --install to automatically install dependencies'
         : undefined
     }
   }
 ])

 await tasks.run()
 console.log('%s Project ready', chalk.green.bold('DONE'));
 return true
}
复制代码

listr提供了页面的进度显示,每一个步骤都会有个loading的效果

如何打造属于自己的CLI工具_java_08

最终的效果就是下面的样子,有点低配版 create-react-app 那味儿了

如何打造属于自己的CLI工具_java_09

结束

CLI工具不一定只能拿来做脚手架的事情,还能干很多自动化运维、语法解析、文件监听等等便于开发的事情,这里只是简单通过一个demo来演示其中的原理。