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

2,615 阅读7分钟

前端开发中,经常会用到一些 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

image.png

但是如果想避免模块全局安装,可以尝试 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

cli1.png

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)
}

cli2.png

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。 cli4.png

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

  • 选择项目模板
  • 选择是否初始化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
  }
}

1.png 2.png 3.png 效果不错,和 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的效果

image.png

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

4.png

git仓库:github.com/Tinsson/cre…

结束

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

创造不易,希望掘有多多 点赞 + 关注 二连,持续更新中!!!

PS: 文中有任何错误,欢迎掘友指正

往期精彩📌