前端工程化实战 - 自定义 CLI 插件开发
?? 本文为掘金社区首发签约文章,未获授权禁止转载
前言在上一篇的动态模板之后,我们已经完成了一个常规 CLI 工具需要的基本功能,包括了构建(webpack、rollup)、质量(eslint 校验)、模板(动态模板管理) 等等可以统一管理的模块。
但是仅此还是不够,并未解放更多的生产力,基建任务是需要团队共建,脱离业务或者仅仅靠几个同学是完全不足够的。
在小团队的话,CLI 的开发可以收集大家的需求,定期的去更新 CLI 的工具 or 脚本,但是团队一旦壮大特别是多业务团队的出现,CLI 的开发并不能实时的响应各个团队的需求,提供的功能也不能完全满足所有人的需求。
每个项目、团队都会根据自己自身的业务跟需求有不同的功能定制,重新开发一套 CLI 的话又会有重复的工作,同时也没有一个统一的入口去进行工具类的管理。
所以跟可配置模板一样的需求,我们要将这些定制化的需求下放到各个业务线团队,让有需求的同学自己来开发、维护这些问题,而开发者则需要将 CLI 改造一下,提供规范化的集成入口来达到可配置工具类的要求。
在正式开发功能之前,我们照例对要开发的功能做一个简单设计,并进行初步的技术预研,看看技术能不能满足于功能设计,如果不能完成则需要改变设计方案。
设计 & 预研image.png因为用户并不知晓命令是内置还是第三方,所以需要一个类似 Router 的主入口,包含内置与第三方插件的命令,在用户输入命令之后,调用不同的插件。
为了达到这个效果,我们需要改造之前的 command 命令。
在之前开发中,我们所有 command 命令都是直接硬编码在代码里面,如下所示:
program
.version('0.1.0')
.description('starteslintandfixcode')
.command('eslint')
.action((value)={
execEslint()
})
这样是没有办法去加载第三方插件,我们可以尝试将所有的命令以配置项的形式暴露出来,然后组成命令数组,再遍历获取对应的命令,批量加载。
internally 目录下的是 CLI 的内置命令,包含了之前已经写过的脚本命令,列如 eslint(其他的命令也是一样的写法):
import{execEslint}from'@/index'
exportconsteslintCommand={
version:'0.1.0',
description:'starteslintandfixcode',
command:'eslint',
action:()=execEslint()
}
exportdefault[
eslintCommand
]
这里我们看到已经将所有命令改造成一个对象的形式暴露出去,然后在主入口引入配置批量注册,如下注册内置命令:
importpathfrom"path";
importaliasfrom"module-alias";
alias(path.resolve(__dirname,"../../"));
import{Command}from'commander';
importinternallyCommandfrom'./internally'
constprogram=newCommand(require('../../package').commandName);
interfaceICommand{
version:string
description:string
command:string
action:(value?:any)=void
}
constinitCommand=(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 注册,接下来我们将正式进入开发第三方插件的功能。
注册流程简点的注册流程图如下所示:
输入第三方插件名称安装依赖注册完毕等待使用4b3ccf097ed260c285eff0a25747547.png对于主 CLI 来说,不可能接受太个性化、自定义的插件进来,这样会影响 CLI 的结构,所以我们需要对 CLI 插件的模板做一个约束,除了输出的格式与上述内置插件格式保持一致之外,我们还需要对插件名称、依赖等等做一个约束,不过这点可以以提供一个 CLI 插件模板来约定。
对于我们的 @boty-design/fe-cli 来说,我们将只接受 @boty-design/fe-plugin-*** 命名格式的插件进来。这种规则可以根据团队的命名规范来约定,并不是唯一规范。
所以,添加模板的时候需要做两次校验,第一层校验是通过校验名字,第二层是安装依赖,如果依赖安装失败也不会添加成功。
插件的命名校验,我们可以通过 inquirer 的 validate 函数来校验:
importinquirerfrom'inquirer';
constpromptList=[
{
type:'input',
message:'请输入插件名称:',
name:'pluginName',
default:'fe-plugin-eslint',
validate(v:string){
returnv.includes('fe-plugin-')
},
transformer(v:string){
return`@boty-design/${v}`
}
}
];
exportconstregisterPlugin=()={
inquirer.prompt(promptList).then((answers:any)={
const{pluginName}=answers
console.log(pluginName)
})
}
image.png在通过组件命令校验之后,可以通过 latest-version 来校验是否已经存在发布的包,以免安装失败,注册 command 异常。
image.png再校验插件的 npm 包无误之后,继续用 shelljs 来安装插件对应的依赖:
exportconstnpmInstall=async(packageName:string)={
try{
shelljs.exec(`yarnadd${packageName}`,{cwd:process.cwd()
}catch(error){
loggerError(error)
process.exit(1)
}
}
image.png同样最后我们需要将注册的插件以文件的形式缓存起来,下一次使用的时候读取配置文件,将插件命令注入,提供给用户使用。
exportconstupdatePlugin=async(params:IPlugin)={
const{name}=params
letisExist=false
try{
constpluginConfig=loadFileIPlugin[](`${cacheTpl}/.plugin.json`)
letfile=[{name}]
if(pluginConfig){
isExist=pluginConfig.some(tpl=tpl.name===name)
if(!isExist){
file=[
...pluginConfig,
{name}
]
}
}
writeFile(cacheTpl,'.plugin.json',file)
loggerSuccess(`${isExist?'Update':'Add'}TemplateSuccessful!`)
}catch(error){
loggerError(error)
}
}
实例代码与之前的 tpl 类似,但是保存的内容也更少了,其实也不需要更新,默认每次都安装最新的即可,如果想做的复杂点可以顺便将版本写入,这样后续还可以提供切换版本功能(似乎意义不大)。
image.pngimage.png完成上述所有的开发之后,最后我们来执行命令看看效果:
image.pngboty tEslint 是我们从第三方插件注册得来的,可以从上图看到,已经再注册了第三方 @boty-design/fe-plugin-eslint 插件之后,用户已经可以使用通过插件提供的 tEslint 命令了。
之后就可以让业务同学自行开发想要的插件,丰富 CLI 的插件市场,但是还是需要对插件的模板有一定的规范,随心所欲的结果就是不可控,所以接下来我们进行 CLI 插件模板的开发。
CLI 插件模板插件模板最大的约束只有暴露出与内置命令一样的 command 注册配置,这样可以让开发第三方插件的同学有最大程度功能自定义化。
import{getEslint}from'./eslint'
exportconstexecEslint=async()={
awaitgetEslint()
}
exportconsteslintCommand={
version:'0.1.0',
description:'starteslintandfixcode',
command:'tEslint',
action:()=execEslint()
}
exportdefault[
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 序列化之后才能使用。
exportconstloadFile=T={}(path:string):T|false|undefined={
try{
if(!fs.existsSync(path)){
returnfalse
}
constdata=fs.readFileSync(path,'utf8');
constconfig=JSON.parse(data);
returnconfig
}catch(err){
loggerError(`Errorreadingfilefromdisk:${err}`);
}
}
可以使用 fs-extra 提供的 readJsonSync API 替代 fs 的 readFileSync,减少了 json 序列化的步骤,提高了代码的可读性。
try{
if(!fs.pathExistsSync(rePath)){
returnfalse
}
constdata=fs.readJsonSync(rePath);
loggerSuccess('fileexisted!')
returndata
}catch(err){
loggerError(`Errorreadingfilefromdisk:${rePath}`);
}
而在文件存储的方面帮助就更大了,能减少更多的代码量。fs-extra 提供了更为方便的操作文件的 API,有需求的朋友可以阅读 fs-extra 文档,这里就不做过多的拓展了。
修改缓存目录跟着之前一起走过来的同学们,应该知道在之前的缓存目录是存在项目根路径,那么这个时候存在重新安装、升级的之后缓存目录丢失的情况,所以我们可以借助 node 的 os 模块,将缓存路径存在对应的系统目录中,这样升级之后缓存文件还会存在。
代码示例如下:
exportconstwriteFile=(path:string,fileName:string,file:object,system:boolean=true)={
constrePath=system?`${os.homedir()}/${path}`:path
loggerInfo(rePath)
try{
fs.outputJsonSync(`${rePath}/${fileName}`,file)
loggerSuccess('Writingfilesuccessful!')
}catch(err){
loggerError(`Errorwritingfilefromdisk:${err}`);
}
}
image.png“如上图所示,可以看到大部分的软件都会将缓存存在系统目录中,这样升级或者卸载重装之后,还能够正常使用缓存配置。
”版本检测在注册插件的时候,我们也是利用了 latest-version 检测是否在 npm 上存在版本来判断 npm 包能否正常下载,它本身的功能是检测 npm 包是否是最新的,如果不是最新的话,可以选择更新当前版本。
同时也可以选择性在项目启动的时候就检测一遍版本,例如当 npm 包版本出现 break change,只有强制更新才能继续使用功能,不过这种配置需要看当前业务需求,比如内置命令有重大更新必须 CLI 升级之后才能使用的情况。
import{loggerInfo,loggerWarring}from'@/util';
constpackageInfo=require('../../package.json');
importlatestVersionfrom'latest-version';
constparseVersion=(ver:string)={
returnver.split('.').toString()
}
exportconstcheckVersion=async()={
constlatestVer=awaitlatestVersion('@boty-design/fe-cli');
if(parseVersion(latestVer)parseVersion(packageInfo.version)){
loggerWarring(`Thecurrentversionisthe:${latestVer}`)
}else{
loggerInfo('Thecurrentversionisthelatest:')
}
}
写在最后至此,CLI 已经完成了加载自定义插件的功能,方法可能略简单,但功能还是满足了,后期有想法的同学可以优化(赶工都是 demo 类型,挺多事情,所以整个 CLI 的代码还是很粗糙,仍然有很多细节的地方可以仔细打磨)。
整个 CLI 插件都是利用空闲时间来写的,其实我跟大多数开发一样,白天都是有工作,很忙也避免不了会有加班,基建都是抽空做,所以第一版本肯定是粗糙能用的版本,但在后续使用的人越来越多、也提出了更多的功能要求的时候,就会开始对这个 CLI 进行迭代,在迭代的过程中避免不了会有大量的重构。例如之前 CLI 开发的时候缓存配置存在了 CLI 项目根路径,存在项目升级之后缓存失效的隐患,需要重构修改到缓存配置到系统路径。
不过在功能逐步完善的过程中,提效降本的效果也会越加明显,你的个人能力与团队影响力也会有一定的提升,此时你可能也会获得一个机会去主导基建工作,当然这个机会大部分还是要看团队、业务的具体情况。
但在这个过程中你会对产品设计、代码质量、细节等有更多的思考与感悟,有些事情不自己做一遍是没有很深的体会,如果同学你当前没有更好的想法、目标与锻炼的机会的话,那么可以尝试开发一个 CLI 作为一个起点。
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线