前端工程化实战 - 自定义 CLI 插件开发

⚠️ 本文为掘金社区首发签约文章,未获授权禁止转载

前言

在上一篇的动态模板之后,我们已经完成了一个常规 CLI 工具需要的基本功能,包括了构建(webpack、rollup)质量(eslint 校验)模板(动态模板管理) 等等可以统一管理的模块。

但是仅此还是不够,并未解放更多的生产力,基建任务是需要团队共建脱离业务或者仅仅靠几个同学是完全不足够的

在小团队的话,CLI 的开发可以收集大家的需求,定期的去更新 CLI 的工具 or 脚本,但是团队一旦壮大特别是多业务团队的出现,CLI 的开发并不能实时的响应各个团队的需求,提供的功能也不能完全满足所有人的需求。

每个项目、团队都会根据自己自身的业务跟需求有不同的功能定制,重新开发一套 CLI 的话又会有重复的工作,同时也没有一个统一的入口去进行工具类的管理。

所以跟可配置模板一样的需求,我们要将这些定制化的需求下放到各个业务线团队,让有需求的同学自己来开发、维护这些问题,而开发者则需要将 CLI 改造一下,提供规范化的集成入口来达到可配置工具类的要求。

在正式开发功能之前,我们照例对要开发的功能做一个简单设计,并进行初步的技术预研,看看技术能不能满足于功能设计,如果不能完成则需要改变设计方案。

设计 & 预研

image.png

因为用户并不知晓命令是内置还是第三方,所以需要一个类似 Router 的主入口,包含内置与第三方插件的命令,在用户输入命令之后,调用不同的插件。

为了达到这个效果,我们需要改造之前的 command 命令。

在之前开发中,我们所有 command 命令都是直接硬编码在代码里面,如下所示:

program 
 .version('0.1.0') 
 .description('start eslint and fix code') 
 .command('eslint') 
 .action((value) => { 
 execEslint() 
 }) 

这样是没有办法去加载第三方插件,我们可以尝试将所有的命令以配置项的形式暴露出来,然后组成命令数组,再遍历获取对应的命令,批量加载

internally 目录下的是 CLI 的内置命令,包含了之前已经写过的脚本命令,列如 eslint(其他的命令也是一样的写法):

import { execEslint } from '@/index'

export const eslintCommand = {
  version: '0.1.0',
  description: 'start eslint and fix code',
  command: 'eslint',
  action: () => execEslint()
}

export default [
  eslintCommand
]

这里我们看到已经将所有命令改造成一个对象的形式暴露出去,然后在主入口引入配置批量注册,如下注册内置命令:

import path from "path";
import alias from "module-alias";
alias(path.resolve(__dirname, "../../"));

import { Command } from 'commander';

import internallyCommand from './internally'

const program = new Command(require('../../package').commandName);

interface ICommand {
  version: string
  description: string
  command: string
  action: (value?: any) => void
}

const initCommand = (commandConfig: ICommand[]) => {
  commandConfig.forEach(config => {
    const { version, description, command, action } = config
    program
      .version(version)
      .description(description)
      .command(command)
      .action((value) => {
        action(value)
      })
  })
}

initCommand(internallyCommand)

program.parse(process.argv);

最后再用命令行工具尝试一下获取 CLI 命令,从下图可以看出是能够正常注册 command。

image.png

开发自定义注册插件功能

在功能设计完成之后,同时也验证采用遍历配置的方式也可以进行 command 注册,接下来我们将正式进入开发第三方插件的功能。

注册流程

简点的注册流程图如下所示:

  1. 输入第三方插件名称
  2. 安装依赖
  3. 注册完毕等待使用

4b3ccf097ed260c285eff0a25747547.png

对于主 CLI 来说,不可能接受太个性化、自定义的插件进来,这样会影响 CLI 的结构,所以我们需要对 CLI 插件的模板做一个约束,除了输出的格式与上述内置插件格式保持一致之外,我们还需要对插件名称、依赖等等做一个约束,不过这点可以以提供一个 CLI 插件模板来约定。

对于我们的 @boty-design/fe-cli 来说,我们将只接受 @boty-design/fe-plugin-*** 命名格式的插件进来。这种规则可以根据团队的命名规范来约定,并不是唯一规范。

所以,添加模板的时候需要做两次校验,第一层校验是通过校验名字,第二层是安装依赖,如果依赖安装失败也不会添加成功。

插件的命名校验,我们可以通过 inquirervalidate 函数来校验:

import inquirer from 'inquirer';

const promptList = [
  {
    type: 'input',
    message: '请输入插件名称:',
    name: 'pluginName',
    default: 'fe-plugin-eslint',
    validate(v: string) {
      return v.includes('fe-plugin-')
    },
    transformer(v: string) {
      return `@boty-design/${v}`
    }
  }
];

export const registerPlugin = () => {
  inquirer.prompt(promptList).then((answers: any) => {
    const { pluginName } = answers
    console.log(pluginName)
  })
}

image.png

在通过组件命令校验之后,可以通过 latest-version 来校验是否已经存在发布的包,以免安装失败,注册 command 异常。

image.png

再校验插件的 npm 包无误之后,继续用 shelljs 来安装插件对应的依赖:

export const npmInstall = async (packageName: string) => {
  try {
    shelljs.exec(`yarn add ${packageName}`, { cwd: process.cwd() });
  } catch (error) {
    loggerError(error)
    process.exit(1)
  }
}

image.png

同样最后我们需要将注册的插件以文件的形式缓存起来,下一次使用的时候读取配置文件,将插件命令注入,提供给用户使用。

export const updatePlugin = async (params: IPlugin) => {
  const { name } = params
  let isExist = false
  try {
    const pluginConfig = loadFile<IPlugin[]>(`${cacheTpl}/.plugin.json`)
    let file = [{ name }]
    if (pluginConfig) {
      isExist = pluginConfig.some(tpl => tpl.name === name)
      if (!isExist) {
        file = [
          ...pluginConfig,
          { name }
        ]
      }
    }
    writeFile(cacheTpl, '.plugin.json', file)
    loggerSuccess(`${isExist ? 'Update' : 'Add'} Template Successful!`)
  } catch (error) {
    loggerError(error)
  }
}

实例代码与之前的 tpl 类似,但是保存的内容也更少了,其实也不需要更新,默认每次都安装最新的即可,如果想做的复杂点可以顺便将版本写入,这样后续还可以提供切换版本功能(似乎意义不大)。

image.png

image.png

完成上述所有的开发之后,最后我们来执行命令看看效果:

image.png

boty tEslint 是我们从第三方插件注册得来的,可以从上图看到,已经再注册了第三方 @boty-design/fe-plugin-eslint 插件之后,用户已经可以使用通过插件提供的 tEslint 命令了。

之后就可以让业务同学自行开发想要的插件,丰富 CLI 的插件市场,但是还是需要对插件的模板有一定的规范,随心所欲的结果就是不可控,所以接下来我们进行 CLI 插件模板的开发。

CLI 插件模板

插件模板最大的约束只有暴露出与内置命令一样的 command 注册配置,这样可以让开发第三方插件的同学有最大程度功能自定义化。

import { getEslint } from './eslint'

export const execEslint = async () => {
  await getEslint()
}

export const eslintCommand = {
  version: '0.1.0',
  description: 'start eslint and fix code',
  command: 'tEslint',
  action: () => execEslint()
}

export default [
  eslintCommand
]

module.exports = eslintCommand

image.png

这里我是直接迁移了 CLI 的命令,package.json 里面 bin 字段是可以在全局安装完毕之后继续使用 eslint 的命令,而在 main 的属性中定义了 "lib/index.js" 是为了能在 CLI 命令入口的时候 require 对应的注册配置。

这里大家可以发现在最开始开发 CLI 的时候,就已经将所有的命令放在 index 里面,然后再在 bin 引用,这样我们的 CLI 也可以被当作普通 npm 包被其他 code 正常使用,所以这个迁移对我来说,是非常简单的。

插件模板管理

如果开放出来的的第三方插件很多,通过这种方式来维护的插件也就越麻烦,包括在安装 CLI 的时候,第三方插件是默认不会安装的,后续也不能直观的看到有多少种插件发布、更新。

所以为了更方便使用,我们可以造一个伪插件市场来进行管理。

因为这个 CLI 的仓库是在 github 上的 boty organization,那么我们将对应的插件也可以放在里面,然后通过 github 的 api 接口去拉取我们想要的信息。

image.png

如上已经能够通过 github 的 API 拿到我们需要的信息,接下来可以为所欲为,做一切你想要的定制化功能,包括插件的更新、已安装插件的升级、废弃插件的回收等等一切功能。

如果有条件的话,可以开发一个插件管理后台,这样能干的事情就更多了。

项目重构

在之前的 CLI 开发中,为了赶工,代码上很多细节还是很粗糙的,在系列文章不断输出的过程中,同时也在不断对 CLI 添加新的功能,那么有很多之前的功能没有实现好,所以趁着新功能开发,顺便将之前的细节完善一下。

fs-extra

采用 fs-extra 替换 fs 模块。

fs-extra 模块是系统 fs 模块的扩展,提供了更多便利的 API,并继承了fs模块的 API。

在之前的文件操作中,我们是使用 fs 模块,作为 node 提供的基础模块在业务开发上还是有所限制,比如在读取文件的时候,还需要通过 JSON.parse 序列化之后才能使用。

export const loadFile = <T = {}>(path: string): T | false | undefined => { 
     try { 
         if (!fs.existsSync(path)) { 
         return false 
         } 
         const data = fs.readFileSync(path, 'utf8'); 
         const config = JSON.parse(data); 
         return config 
     } catch (err) { 
         loggerError(`Error reading file from disk: ${err}`); 
     } 
 } 

可以使用 fs-extra 提供的 readJsonSync API 替代 fsreadFileSync,减少了 json 序列化的步骤,提高了代码的可读性。

try {
    if (!fs.pathExistsSync(rePath)) {
      return false
    }
    const data = fs.readJsonSync(rePath);
    loggerSuccess('file existed!')
    return data
  } catch (err) {
    loggerError(`Error reading file from disk: ${rePath}`);
  }

而在文件存储的方面帮助就更大了,能减少更多的代码量。fs-extra 提供了更为方便的操作文件的 API,有需求的朋友可以阅读 fs-extra 文档,这里就不做过多的拓展了。

修改缓存目录

跟着之前一起走过来的同学们,应该知道在之前的缓存目录是存在项目根路径,那么这个时候存在重新安装、升级的之后缓存目录丢失的情况,所以我们可以借助 node 的 os 模块,将缓存路径存在对应的系统目录中,这样升级之后缓存文件还会存在。

代码示例如下:

export const writeFile = (path: string, fileName: string, file: object, system: boolean = true) => {
  const rePath = system ? `${os.homedir()}/${path}` : path
  loggerInfo(rePath)
  try {
    fs.outputJsonSync(`${rePath}/${fileName}`, file)
    loggerSuccess('Writing file successful!')
  } catch (err) {
    loggerError(`Error writing file from disk: ${err}`);
  }
}

image.png

如上图所示,可以看到大部分的软件都会将缓存存在系统目录中,这样升级或者卸载重装之后,还能够正常使用缓存配置。

版本检测

在注册插件的时候,我们也是利用了 latest-version 检测是否在 npm 上存在版本来判断 npm 包能否正常下载,它本身的功能是检测 npm 包是否是最新的,如果不是最新的话,可以选择更新当前版本。

同时也可以选择性在项目启动的时候就检测一遍版本,例如当 npm 包版本出现 break change,只有强制更新才能继续使用功能,不过这种配置需要看当前业务需求,比如内置命令有重大更新必须 CLI 升级之后才能使用的情况。

import { loggerInfo, loggerWarring } from '@/util';
const packageInfo = require('../../package.json');
import latestVersion from 'latest-version';

const parseVersion = (ver: string) => {
  return ver.split('.').toString()
}

export const checkVersion = async () => {
  const latestVer = await latestVersion('@boty-design/fe-cli');
  if (parseVersion(latestVer) > parseVersion(packageInfo.version)) {
    loggerWarring(`The current version is the :  ${latestVer}`)
  } else {
    loggerInfo('The current version is the latest:')
  }
}

写在最后

至此,CLI 已经完成了加载自定义插件的功能,方法可能略简单,但功能还是满足了,后期有想法的同学可以优化(赶工都是 demo 类型,挺多事情,所以整个 CLI 的代码还是很粗糙,仍然有很多细节的地方可以仔细打磨)。

整个 CLI 插件都是利用空闲时间来写的,其实我跟大多数开发一样,白天都是有工作,很忙也避免不了会有加班,基建都是抽空做,所以第一版本肯定是粗糙能用的版本,但在后续使用的人越来越多、也提出了更多的功能要求的时候,就会开始对这个 CLI 进行迭代,在迭代的过程中避免不了会有大量的重构。例如之前 CLI 开发的时候缓存配置存在了 CLI 项目根路径,存在项目升级之后缓存失效的隐患,需要重构修改到缓存配置到系统路径。

不过在功能逐步完善的过程中,提效降本的效果也会越加明显,你的个人能力与团队影响力也会有一定的提升,此时你可能也会获得一个机会去主导基建工作,当然这个机会大部分还是要看团队、业务的具体情况。

但在这个过程中你会对产品设计、代码质量、细节等有更多的思考与感悟,有些事情不自己做一遍是没有很深的体会,如果同学你当前没有更好的想法、目标与锻炼的机会的话,那么可以尝试开发一个 CLI 作为一个起点。

源代码已全部上传到 BOTY-DESIGN,如果有任何建议、疑问欢迎添加微信 cookieboty,一起探学习下。