原文发表在个人博客,欢迎关注~html
祝你们2019年猪年新年快乐!本文较长,须要必定耐心看完哦~前端
前段时间,我用electron-vue开发了一款跨平台(目前支持主流三大桌面操做系统)的免费开源的图床上传应用——PicGo,在开发过程当中踩了很多的坑,不只来自应用的业务逻辑自己,也来自electron自己。在开发这个应用过程当中,我学了很多的东西。由于我也是从0开始学习electron,因此不少经历应该也能给初学、想学electron开发的同窗们一些启发和指示。故而写一份Electron的开发实战经历,用最贴近实际工程项目开发的角度来阐述。但愿能帮助到你们。vue
预计将会从几篇系列文章或方面来展开:node
PicGo
是采用electron-vue
开发的,因此若是你会vue
,那么跟着一块儿来学习将会比较快。若是你的技术栈是其余的诸如react
、angular
,那么纯按照本教程虽然在render端(能够理解为页面)的构建可能学习到的东西很少,不过在main端(electron的主进程)应该仍是能学习到相应的知识的。react
若是以前的文章没阅读的朋友能够先从以前的文章跟着看。webpack
说在前面,其实这篇文章写起来真的很难。如何构建一个插件系统,我花了半年的时间。要在一篇或者两篇文章里把这个东西说好是真的不容易。因此可能行文上会有一些瑕疵,后续会不断打磨。git
相信不少人平时更多的是给其余框架诸如Vue
、React
或者Webpack
等写插件。咱们能够把提供插件系统的框架称为「容器」,经过容器暴露出来的API,插件能够挂载到容器上,或者接入容器的生命周期来实现一些更定制化的功能。github
好比Webpack
本质上是一个流程系统,它经过Tapable暴露了不少生命周期的钩子,插件能够经过接入这些生命周期钩子实现流水线做业——好比babel
系列的插件把ES6
代码转义成ES5
;SASS
、LESS
、Stylus
系列的插件把预处理的CSS
代码编译成浏览器可识别的正常CSS
代码等等。web
咱们要实现一个插件系统,本质上也是实现这么一个容器。这个容器以及对应的插件须要具有以下基本特征:vue-cli
第一点应该很容易理解。若是一个插件系统由于没有第三方插件的存在就没法运行,那么这个插件系统有什么用呢?不过有别于第三方插件,不少插件系统有本身内置的插件,好比vue-cli
、Webpack
的一系列内置插件。这个时候插件系统自己的一些功能就会由内置的插件去实现。
第二点,插件的独立性是指插件自己运行时不会 主动 影响其余插件的运做。固然某个插件能够依赖于其余插件的运行结果。
第三点,插件若是不能配置不能管理,那么从安装插件阶段就会遇到问题。因此容器须要有设计良好的入口给予插件注册。
接下来的部分,我将结合PicGo-Core与PicGo来详细说明CLI插件系统与GUI插件系统如何构建与实现。
其实CLI插件系统能够认为是无GUI的插件系统,也就是运行在命令行或者不带有可视化界面的插件系统。为何咱们开发Electron的插件系统,须要扯到CLI插件系统呢?这里须要简单回顾一下Electron的结构:
能够看到除了Renderer
的界面渲染,大部分的功能是由Main
进程提供的。对于PicGo而言,它的底层应该是一个上传流程系统,以下:
因此理论上它的底层应该在Node.js端就能实现。而Electron的Renderer
进程只是实现了GUI界面,去调用底层Node.js端实现的流程系统提供的API而已。相似于咱们平时在开发网页时候的先后端分离,只不过如今这个后端是基于Node.js实现的插件系统。基于这个思路,我开始着手PicGo-Core的实现。
一般来讲一个插件系统都有本身的一个生命周期,好比Vue
有beforeCreate
、created
、mounted
等等,Webpack
有beforeRun
、run
、afterCompile
等等。这个也是一个插件系统的灵魂所在,经过接入系统的生命周期,赋予了插件更多的自由度。
所以咱们能够先来实现一个生命周期类。代码能够参考Lifecycle.ts。
生命周期流程能够参考上面的流程图。
class Lifecycle {
// 整个生命周期的入口
async start (input: any[]): Promise<void> {
try {
await this.beforeTransform(input)
await this.doTransform(input)
await this.beforeUpload(input)
await this.doUpload(input)
await this.afterUpload(input)
} catch (e) {
console.log(e)
}
}
// 获取原始输入,转换前
private async beforeTransform (input) {
// ...
}
// 将输入转换成Uploader可上传的格式
private async doTransform (input) {
// ...
}
// Uploader上传前
private async beforeUpload (input) {
// ...
}
// Uploader上传
private async doUpload (input) {
// ...
}
// Uploader上传完成后
private async afterUpload (input) {
// ...
}
}
复制代码
在实际使用中,咱们能够经过:
const lifeCycle = new LifeCycle()
lifeCycle.start([...])
复制代码
来运行整个上传流程的生命周期。不过到这里咱们尚未看到任何跟插件相关的东西。这是为了实现咱们说的第一个条件: 容器在没有 第三方插件 接入的状况下也能 实现基本功能。
不少时候咱们须要将一些事件以某种方式传递出去。就像发布订阅模型同样,由容器发布,由插件订阅。这个时候咱们能够直接让Lifecycle
这个类继承Node.js自带的EventEmmit
:
class Lifecycle extends EventEmitter {
constructor () {
super()
}
// ...
}
复制代码
那么Lifecycle
也就拥有了EventEmitter
的emit
和on
方法了。对于容器来讲,咱们只须要emit
事件出去便可。
好比在PicGo-Core
里,上传的整个流程都会往外广播事件,通知插件当前进行到什么阶段,而且将当前的输入或者输出在广播的时候发送出去。
private async beforeTransform (input) {
// ...
this.emit('beforeTransform', input) // 广播事件
}
复制代码
插件能够自由选择监听想要监听的事件。好比插件想要知道上传结束后的结果(伪代码):
plugin.on('finished', (output) => {
console.log(output) // 获取output
})
复制代码
在开发PicGo-Core的时候,有一些颇有用的事件。在这里我也想分享出来,虽然不是全部插件系统都会有这样的事件,可是结合本身和项目的实际须要,他们有的时候颇有用。
平时咱们上传或者下载文件的时候,都会注意一个东西:进度条。一样,在PicGo-Core里也暴露了一个事件,叫作uploadProgress
,用于告诉用户当前的上传进度。不过在PicGo-Core,上传进度是从beforeTransform
就开始算了,为了方便计算,划分了5个固定的值。
private async beforeTransform (input) {
this.emit('uploadProgress', 0) // 转换前,进度0
}
private async doTransform (input) {
this.emit('uploadProgress', 30) // 开始转换,进度30
}
private async beforeUpload (input) {
this.emit('uploadProgress', 60) // 开始上传,进度60
}
private async afterUpload (input) {
this.emit('uploadProgress', 100) // 上传完毕,进度100
}
复制代码
若是上传失败的话就返回-1
:
async start (input: any[]): Promise<void> {
try {
await this.beforeTransform(input)
await this.doTransform(input)
await this.beforeUpload(input)
await this.doUpload(input)
await this.afterUpload(input)
} catch (e) {
console.log(e)
this.emit('uploadProgress', -1)
}
}
复制代码
经过监听这个事件,PicGo就能作出以下的上传进度条:
若是上传出了问题,或者有些信息须要经过系统级别的通知告诉用户的话,能够发布notification
事件。经过监听这个事件能够调用系统通知来发布。插件也能够发布这个事件,让PicGo监听。如上图上传成功后右上角的通知。
上部分讲到了生命周期中的事件广播,能够发现事件广播是只管发无论结果的。也就是PicGo-Core只管发布这个事件,至于有没有插件监听,监听后作了什么都不用关心。(怎么有点像UDP同样)。可是实际上不少时候咱们须要接入生命周期作一些事情的。
就拿上传流程来讲,我要是想要上传前压缩图片,那么监听beforeUpload
事件是作不到的。由于在beforeUpload
事件里就算你把图片已经压缩了,恐怕上传的流程早就走完了,emit
事件出去后生命周期照旧运行。
所以咱们须要在容器的生命周期里实现一个功能,可以让插件接入它的生命周期,在执行完当前生命周期的插件的动做后,才把结果送往下一个生命周期。能够发现,这里有一个「等待」插件执行的动做。所以PicGo-Core使用最简易而直观的async
函数配合await
来实现「等待」。
咱们先不用考虑插件是如何注册的,后文会说到。咱们先来实现怎么让插件接入生命周期。
下面以生命周期beforeUpload
为例:
private async beforeUpload (input) {
this.ctx.emit('uploadProgress', 60)
this.ctx.emit('beforeUpload', input)
// ...
await this.handlePlugins(beforeUploadPlugins.getList(), input) // 执行并「等待」插件执行结束
}
复制代码
能够看到咱们经过await
等待生命周期方法handlePlugins
(下文会说明如何实现)的执行结束。而咱们运行的插件列表是经过beforeUploadPlugins.getList()
(下文会说明如何实现)获取的,说明这些是只针对beforeUpload
这个生命周期的插件。而后将输入input
传入handlePlugins
让插件们调用便可。
如今咱们实现一下handlePlugins
:
private async handlePlugins (plugins: Plugin[], input: any[]) {
await Promise.all(plugins.map(async (plugin: Plugin) => {
await plugin.handle(input)
}))
}
复制代码
咱们经过Promise.all
以及await
来「等待」全部插件执行。这里须要注意的是,每一个PicGo插件须要实现一个handle
方法来供PicGo-Core
调用。能够看到,这里实现咱们说的第二个特征: 插件具备独立性。
从这里也能看到咱们经过async
和await
构建了一个可以「等待」插件执行结束的环境。这样就解决了光是经过广播事件没法接入插件系统的生命周期的问题。
不,等等,这里还有一个问题。beforeUploadPlugins.getList()
是哪来的?上面只是一个示例代码。实际上PicGo-Core根据上传流程里的不一样生命周期预留了五种不一样的插件:
分别在上传的5个周期里调用。虽然这5种插件调用的时机不同,可是它们的实现是一样的:有一样的注册机制、一样的方法用于获取插件列表、获取插件信息等等。因此咱们接下去来实现一个生命周期的插件类。
这个是插件系统里很关键的一环,这个类的实现了插件应该以什么方式注册到咱们的插件系统里,以及插件系统如何获取他们。这块的代码能够参考 LifecyclePlugins.ts。
如下是实现:
class LifecyclePlugins {
// list就是插件列表。以对象形式呈现。
list: {
[propName: string]: Plugin
}
constructor () {
this.list = {} // 初始化插件列表为{}
}
// 插件注册的入口
register (id: string, plugin: Plugin): void {
// 若是插件没有提供id,则不予注册
if (!id) throw new TypeError('id is required!')
// 若是插件没有handle的方法,则不予注册
if (typeof plugin.handle !== 'function') throw new TypeError('plugin.handle must be a function!')
// 若是插件的id重复了,则不予注册
if (this.list[id]) throw new TypeError(`${this.name} duplicate id: ${id}!`)
this.list[id] = plugin
}
// 经过插件ID获取插件
get (id: string): Plugin {
return this.list[id]
}
// 获取插件列表
getList (): Plugin[] {
return Object.keys(this.list).map((item: string) => this.list[item])
}
// 获取插件ID列表
getIdList (): string[] {
return Object.keys(this.list)
}
}
export default LifecyclePlugins
复制代码
对于插件而言最重要的是register
方法,它是插件注册的入口。经过register
注册后,会在Lifecycle
内部的list
以id:plugin
形式里写入这个插件。注意到,PicGo-Core要求每一个插件须要实现一个handle
的方法,用于以后在生命周期里调用。
这里用伪代码说明一下插件要如何注册:
beforeTransformPlugins.register('test', {
handle (ctx) {
console.log(ctx)
}
})
复制代码
这里咱们就注册了一个id
叫作test
的插件,它是一个beforeTransform
阶段的插件,它的做用就是打印传入的信息。
而后在不一样的生命周期里,调用LifeCyclePlugins.getList()
的方法就能获取这个生命周期对应的插件的列表了。
若是仅仅是实现一个可以在Node.js项目里运行的插件系统,上面两个部分基本就够了:
不过一个良好的CLI插件系统还须要至少以下的部分(至少我以为):
此处能够参考vue-cli3这个工具。
所以咱们至少还须要以下的部分:
这上面的几个部分都跟生命周期类自己没有特别强的耦合关系,因此能够没必要将它们都放到生命周期类里实现。
相对的,咱们抽离出一个Core
做为核心,将上述这些类包含到这个核心类中,核心类负责命令行命令的注册、插件的加载、优化日志信息以及调用生命周期等等。
最后再将这个核心类暴露出去,供使用者或者开发者使用。这个就是PicGo-Core的核心 PicGo.ts 的实现。
PicGo自己的实现并不复杂,基本上只是调用上述几个类实例的方法。
不过注意到这里有一个以前一直没有提到的东西。PicGo-Core除了核心PicGo以外的几个子类里,基本上在constructor
构建函数阶段都会传入一个叫作ctx
的参数。这个参数是什么?这个参数是PicGo这个类自身的this
。经过传入this
,PicGo-Core的子类也能使用PicGo核心类暴露出来的方法了。
好比Logger
类实现了美观的命令行日志输出:
那么在其余子类里想要调用Logger
的方法也很容易:
ctx.log.success('Hello world!')
复制代码
其中ctx
就是咱们上面说的,PicGo自身的this
指针。
咱们接下去介绍的每一个类具体的实现。
先从这个类开始提及是由于这个类是最简单并且侵入性最小的一个类。有它没它都行,可是有它天然是锦上添花。
PicGo实现美化日志输出的库是chalk,它的做用就是用来输出花花绿绿的命令行文字:
用起来也很简单:
const log = chalk.green('Success')
console.log(log) // 绿色字体的Success
复制代码
咱们打算实现4种输出类型,success、warn、info和error:
因而建立以下的类:
import chalk from 'chalk'
import PicGo from '../core/PicGo'
class Logger {
level: {
[propName: string]: string
}
ctx: PicGo
constructor (ctx: PicGo) { // 将PicGo的this传入构造函数,使得Logger也能使用PicGo核心类暴露的方法
this.level = {
success: 'green',
info: 'blue',
warn: 'yellow',
error: 'red'
}
this.ctx = ctx
}
// 实际输出函数
protected handleLog (type: string, msg: string | Error): string | Error | undefined {
if (!this.ctx.config.silent) { // 若是不是静默模式,静默模式不输出log
let log = chalk[this.level[type]](`[PicGo ${type.toUpperCase()}]: `)
log += msg
console.log(log)
return msg
} else {
return
}
}
// 对应四种不一样类型
success (msg: string | Error): string | Error | undefined {
return this.handleLog('success', msg)
}
info (msg: string | Error): string | Error | undefined {
return this.handleLog('info', msg)
}
error (msg: string | Error): string | Error | undefined {
return this.handleLog('error', msg)
}
warn (msg: string | Error): string | Error | undefined {
return this.handleLog('warn', msg)
}
}
export default Logger
复制代码
以后再将Logger
这个类挂载到PicGo核心类上:
import Logger from '../lib/Logger'
class PicGo {
log: Logger
constructor () {
// ...
this.log = new Logger(this) // 把this传入Logger,也就是Logger里的ctx
}
// ...
}
复制代码
这样其余挂载到PicGo核心类上的类就能使用ctx.log
来调用log里的方法了。
不少时候咱们的所写的系统也好、插件也好,或多或少须要一些配置以后才能更好地使用。好比vue-cli3
的vue.config.js
,好比hexo
的_config.yml
等等。而PicGo也不例外。默认状况下它能够直接使用,可是若是想要作些其余操做,天然就须要配置了。因此配置文件是插件系统很重要的一个组成部分。
以前我在Electron版的PicGo上使用了lowdb做为JSON配置文件的读写库,体验不错。为了向前兼容PicGo的配置,写PicGo-Core的时候我依然采用了这个库。关于lowdb的一些具体用法,我在以前的一篇文章里有说起,有兴趣的能够看看——传送门。
因为lowdb作的是相似MySQL同样的持久化配置,它须要磁盘上一个具体的JSON文件做为载体,因此没法经过建立一个配置对象去初始化配置。所以一切都从这个配置文件展开:
PicGo-Core采用一个默认的配置文件:homedir()/.picgo/config.json
,若是在实例化PicGo没提供配置文件路径那么就会使用这个文件。若是使用者提供了具体的配置文件,那么就会使用所提供的配置文件。
下面来实现一下PicGo初始化的过程:
import fs from 'fs-extra'
class PicGo extends EventEmitter {
configPath: string
private lifecycle: Lifecycle
// ...
constructor (configPath: string = '') {
super()
this.configPath = configPath // 传入configPath
this.init()
}
init () {
if (this.configPath === '') { // 若是不提供配置文件路径,就使用默认配置
this.configPath = homedir() + '/.picgo/config.json'
}
if (path.extname(this.configPath).toUpperCase() !== '.JSON') { // 若是配置文件的格式不是JSON就返回错误日志
this.configPath = ''
return this.log.error('The configuration file only supports JSON format.')
}
const exist = fs.pathExistsSync(this.configPath)
if (!exist) { // 若是不存在就建立
fs.ensureFileSync(`${this.configPath}`)
}
// ...
}
// ...
}
复制代码
那么在实例化PicGo的时候就是以下这样:
const PicGo = require('picgo')
const picgo = new PicGo() // 不提供配置文件就用默认配置文件
// 或者
const picgo = new PicGo('./xxx.json') // 提供配置文件就用所提供的配置文件
复制代码
有了配置文件以后,咱们只须要实现三个基本操做:
通常来讲咱们的系统都会有一些默认的配置,PicGo也不例外。咱们能够选择把默认配置写到代码里,也能够选择把默认配置写到代码里。由于PicGo的配置文件有持久化的需求,因此把一些关键的默认配置写入配置文件是合理的。
初始化配置的时候会用到lowdb的一些知识,这里就不展开了:
import lowdb from 'lowdb'
import FileSync from 'lowdb/adapters/FileSync'
const initConfig = (configPath: string): lowdb.LowdbSync<any> => {
const adapter = new FileSync(configPath, { // lowdb的adapter,用于读取配置文件
deserialize: (data: string): Function => {
return (new Function(`return ${data}`))()
}
})
const db = lowdb(adapter) // 暴露出来的db对象
if (!db.has('picBed').value()) { // 若是没有picBed配置
db.set('picBed', { // 就生成一个默认图床为SM.MS的配置
current: 'smms'
}).write()
}
if (!db.has('picgoPlugins').value()) { // 同理
db.set('picgoPlugins', {}).write()
}
return db // 将db暴露出去让外部使用
}
复制代码
那么在PicGo初始化阶段就能够将configPath
传入,来实现配置的初始化,以及获取配置。
init () {
// ...
let db = initConfig(this.configPath)
this.config = db.read().value() // 将配置文件内容存入this.config
}
复制代码
一旦初始化配置以后,要获取配置就很容易了:
import { get } from 'lodash'
getConfig (name: string = ''): any {
if (name) { // 若是提供了配置项的名字
return get(this.config, name) // 返回具体配置项结果
} else {
return this.config // 不然就返回完整配置
}
}
复制代码
这里用到了lodash
的get
方法,主要是为了方便获取以下状况:
好比配置内容长这样:
{
"a": {
"b": true
}
}
复制代码
往常咱们要获取a.b
须要:
let b = this.config.a.b
复制代码
万一遇到a
不存在的时候,那么上面那句话就会报错了。由于a
不存在,那么a.b
就是undefined.b
天然会报错了。而用lodash
的get
方法则能够避免这个问题,而且能够很方便的获取:
let b = get(this.config, 'a.b')
复制代码
若是a
不存在,那么获取到的结果b
也不会报错,而是undefined
。
有了上面的铺垫,写入内容也很简单。经过lowdb
提供的接口,写入配置以下:
const saveConfig = (configPath: string, config: any): void => {
const db = initConfig(configPath)
Object.keys(config).forEach((name: string) => {
db.read().set(name, config[name]).write()
})
}
复制代码
咱们能够用:
saveConfig(this.configPath, { a: { b: true } })
复制代码
或者:
saveConfig(this.configPath, { 'a.b': true })
复制代码
上面两种写法都会生成以下配置:
{
"a": {
"b": true
}
}
复制代码
能够看到明显后者更简洁点。这多亏了lowdb里由lodash提供的set
方法。
至此咱们已经将配置文件相关的操做实现完了。其实能够把这堆操做封装成一个类的,PicGo-Core在一开始实现的时候以为东西很少不复杂,因此只是抽成了一个小工具来调用的。固然这个不是关键,关键在于实现了配置文件的相关操做后,你的系统和这个系统的插件都能所以受益。系统能够把跟配置文件相关的操做的API暴露给插件使用。接下去咱们一步步来完善这个插件系统。
暂时没想好这个类要取的名字是啥,代码里我写的是pluginHandler
,那么就叫它插件操做类吧。这个类主要目的就三个:
npm
安装插件 —— installnpm
卸载插件 —— uninstallnpm
更新插件 —— update用npm
来分发插件,这是大多数Node.js插件系统会选择的解决方案。毕竟在没有本身的插件商店(好比VSCode)的基础上,npm
就是一个自然的「插件商店」。固然发布到npm
之上好处还有不少,好比能够十分方便地来对插件进行安装、更新和卸载,好比对Node.js用户来讲是0成本的上手。这也是pluginHandler
这个类要作的事。
pluginHandler
相关的实现思路来自feflow,特此感谢。
平时咱们安装一个npm模块的时候,很简单:
npm install xxxx --save
复制代码
不过咱们是在当前项目目录的上来安装的。PicGo因为引入了配置文件,因此咱们能够直接在配置文件所在的目录里进行插件的安装,这样若是你要卸载PicGo,只要把。可是每次都让用户打开PicGo的配置文件所在的路径去安装插件未免太累了。这样也不优雅。
相对的,若是咱们全局安装了picgo
以后,在文件系统任何一个角落里只须要经过picgo install xxx
就能安装一个picgo
的插件,而不须要定位到PicGo的配置文件所在的文件夹,这样用户体验会好很多。这里你们能够类比vue-cli3
安装插件的步骤。
为了实现这个效果,咱们须要经过代码的方式去调用npm
这个命令。那么Node.js要如何经过代码去实现命令行调用呢?
这里咱们可使用cross-spawn来实现跨平台的、经过代码来调用命令行的目的。
spawn
这个方法Node.js原生也有(在child_process里),不过cross-spawn
解决了一些跨平台的问题。使用上是同样的。
const spawn = require('cross-spawn')
spawn('npm', ['install', '@vue/cli', '-g'])
复制代码
能够看到,它的参数是以数组的形式传入的。
而咱们要实现的插件操做,除了主要命令install
、update
、uninstall
不同以外,其余的参数都是同样的。因此咱们抽离出一个execCommand
的方法来实现它们背后的公共逻辑:
execCommand (cmd: string, modules: string[], where: string, proxy: string = ''): Promise<Result> {
return new Promise((resolve: any, reject: any): void => {
// spawn的命令行参数是以数组形式传入
// 此处将命令和要安装的插件以数组的形式拼接起来
// 此处的cmd指的是执行的命令,好比install\uninstall\update
let args = [cmd].concat(modules).concat('--color=always').concat('--save')
const npm = spawn('npm', args, { cwd: where }) // 执行npm,并经过 cwd指定执行的路径——配置文件所在文件夹
let output = ''
npm.stdout.on('data', (data: string) => {
output += data // 获取输出日志
}).pipe(process.stdout)
npm.stderr.on('data', (data: string) => {
output += data // 获取报错日志
}).pipe(process.stderr)
npm.on('close', (code: number) => {
if (!code) {
resolve({ code: 0, data: output }) // 若是没有报错就输出正常日志
} else {
reject({ code: code, data: output }) // 若是报错就输出报错日志
}
})
})
}
复制代码
关键的部分基本都已经在代码里给出了注释。固然这里仍是有一些须要注意的地方。注意这句话:
const npm = spawn('npm', args, { cwd: where }) // 执行npm,并经过 cwd指定执行的路径——配置文件所在文件夹
复制代码
里面的{cwd: where}
,这个where
是会从外部传进来的值,表示这个npm
命令会在哪一个目录下执行。这个也是咱们要作这个插件操做类最关键的地方——不用让用户主动打开配置文件所在目录去安装插件,在系统任何地方均可以轻松安装PicGo的插件。
接下去咱们实现一下install
方法,这样另外两个就能够类推了。
async install (plugins: string[], proxy: string): Promise<void> {
plugins = plugins.map((item: string) => 'picgo-plugin-' + item)
const result = await this.execCommand('install', plugins, this.ctx.baseDir, proxy)
if (!result.code) {
this.ctx.log.success('插件安装成功')
this.ctx.emit('installSuccess', {
title: '插件安装成功',
body: plugins
})
} else {
const err = `插件安装失败,失败码为${result.code},错误日志为${result.data}`
this.ctx.log.error(err)
this.ctx.emit('failed', {
title: '插件安装失败',
body: err
})
}
}
复制代码
别看代码不少,关键就一句const result = await this.execCommand('install', plugins, this.ctx.baseDir, proxy)
,剩下的都是日志输出而已。好了,插件也安装完了,如何加载呢?
上面说了,咱们会将插件安装在配置文件所在目录里。值得注意的是,因为npm
的特色,若是目录里有个叫作package.json
的文件,那么安装插件、更新插件等操做会同时修改package.json
文件。所以咱们能够经过读取package.json
文件来得知当前目录下有什么PicGo的插件。这也是Hexo的插件加载机制里的很重要的一环。
pluginLoader
相关的实现思路来自hexo,特此感谢。
关于插件的命名,PicGo这里有个约束(这也是不少插件系统选择的方式),必须以picgo-plugin-
开头。这样才能方便插件加载类识别它们。
这里有一个小坑。若是咱们配置文件所在的目录里没有package.json
的话,那么执行安装插件的命令会有报错信息。可是咱们不想让用户看到这个报错,因而在初始化插件加载类
的时候,须要判断一下这个文件存不存在,若是不存在那么咱们就要建立一个:
class PluginLoader {
ctx: PicGo
list: string[]
constructor (ctx: PicGo) {
this.ctx = ctx
this.list = [] // 插件列表
this.init()
}
init (): void {
const packagePath = path.join(this.ctx.baseDir, 'package.json')
if (!fs.existsSync(packagePath)) { // 若是不存在
const pkg = {
name: 'picgo-plugins',
description: 'picgo-plugins',
repository: 'https://github.com/Molunerfinn/PicGo-Core',
license: 'MIT'
}
fs.writeFileSync(packagePath, JSON.stringify(pkg), 'utf8') // 建立这个文件
}
}
// ...
}
复制代码
接下来咱们要实现最关键的load
方法了。咱们须要以下步骤:
package.json
来找到全部合法的插件require
来加载插件picgoPlugins
配置来判断插件是否被禁用register
方法来实现插件注册import PicGo from '../core/PicGo'
import fs from 'fs-extra'
import path from 'path'
import resolve from 'resolve'
load (): void | boolean {
const packagePath = path.join(this.ctx.baseDir, 'package.json')
const pluginDir = path.join(this.ctx.baseDir, 'node_modules/')
// Thanks to hexo -> https://github.com/hexojs/hexo/blob/master/lib/hexo/load_plugins.js
if (!fs.existsSync(pluginDir)) { // 若是插件文件夹不存在,返回false
return false
}
const json = fs.readJSONSync(packagePath) // 读取package.json
const deps = Object.keys(json.dependencies || {})
const devDeps = Object.keys(json.devDependencies || {})
// 1.获取插件列表
const modules = deps.concat(devDeps).filter((name: string) => {
if (!/^picgo-plugin-|^@[^/]+\/picgo-plugin-/.test(name)) return false
const path = this.resolvePlugin(this.ctx, name) // 获取插件路径
return fs.existsSync(path)
})
for (let i in modules) {
this.list.push(modules[i]) // 把插件push进插件列表
if (this.ctx.config.picgoPlugins[modules[i]] || this.ctx.config.picgoPlugins[modules[i]] === undefined) { // 3.判断插件是否被禁用,若是是undefined则为新安装的插件,默认不由用
try {
this.getPlugin(modules[i]).register() // 4.调用插件的`register`方法进行注册
const plugin = `picgoPlugins[${modules[i]}]`
this.ctx.saveConfig( // 将插件设为启用-->让新安装的插件的值从undefined变成true
{
[plugin]: true
}
)
} catch (e) {
this.ctx.log.error(e)
this.ctx.emit('notification', {
title: `Plugin ${modules[i]} Load Error`,
body: e
})
}
}
}
}
resolvePlugin (ctx: PicGo, name: string): string { // 获取插件路径
try {
return resolve.sync(name, { basedir: ctx.baseDir })
} catch (err) {
return path.join(ctx.baseDir, 'node_modules', name)
}
}
getPlugin (name: string): any { // 经过插件名获取插件
const pluginDir = path.join(this.ctx.baseDir, 'node_modules/')
return require(pluginDir + name)(this.ctx) // 2.经过require获取插件并传入ctx
}
复制代码
load
这个方法是整个插件系统加载的最关键的部分。光看上面的步骤和代码可能没办法很好理解。咱们下面用一个具体的插件例子来讲明。
假设我写了一个picgo-plugin-xxx
的插件。个人代码以下:
// 插件系统会传入picgo的ctx,方便插件调用picgo暴露出来的api
// 因此咱们须要有一个ctx的参数用于接收来自picgo的api
module.exports = ctx => {
// 插件系统会调用这个方法来进行插件的注册
const register = () => {
ctx.helper.beforeTransformPlugins.register('xxx', {
handle (ctx) { // 调用插件的 handle 方法时也会传入 ctx 方便调用api
console.log(ctx.output)
}
})
}
return {
register
}
}
复制代码
咱们从前文已经大概知道插件运行流程:
beforeTransform
,那么这个阶段就去获取beforeTransformPlugins
这些插件beforeTransformPlugins
这些插件由ctx.helper.beforeTransformPlugins.register
方法注册,并能够经过ctx.helper.beforeTransformPlugins.getList()
获取beforeTransformPlugins
的handle
方法,并传入ctx
供插件使用注意上面的第三步,ctx.helper.beforeTransformPlugins.register
这个方法是在何时被调用的?答案就是在本小节介绍的插件的加载阶段,pluginLoader
调用了每一个插件的register
方法,那么在插件的register
方法里,咱们写了:
ctx.helper.beforeTransformPlugins.register('xxx', {
handle (ctx) { // 调用插件的 handle 方法时也会传入 ctx 方便调用api
console.log(ctx.output)
}
})
复制代码
也就是在这个时候,ctx.helper.beforeTransformPlugins.register
这个方法被调用。
因而乎,在生命周期开始以前,整个插件以及每一个生命周期的插件已经预先被注册了。因此在生命周期开始运做的时候,只须要经过getList()
就能够获取注册过的插件,从而执行整个流程了。
也所以,我之前在跑Hexo
生成博客的时候曾经遇到的问题就获得解释了。我之前安装过一些Hexo
的插件,可是不知道为何老是没法生效。后来发现是安装的时候没有使用--save
,致使它们没被写入package.json
的依赖字段。而Hexo
加载插件的第一步就是从package.json
里获取合法的插件列表,若是插件不在package.json
里,哪怕在node_modules
里有,也不会生效了。
有了插件,接下去咱们讲讲如何在命令行调用和配置了。
PicGo的命令行操做类主要依赖于两个库:commander.js和Inquirer.js。这两个也是作Node.js命令行应用很经常使用的库了。前者负责命令行解析、执行相关命令。后者负责提供与用户交互的命令行界面。
好比你能够输入:
picgo use uploader
复制代码
这个时候由commander.js
去解析这句命令,告诉咱们这个时候调用的是use
这个命令,参数是uploader
,那么就进入Inquirer.js
提供的交互式界面了:
若是你用过诸如vue-cli3
或者create-react-app
等相似的命令行工具必定相似的状况很熟悉。
首先咱们写一个命令行操做类,用于暴露api给其余部分注册命令,此处源码能够参考Commander.ts。
import PicGo from '../core/PicGo'
import program from 'commander'
import inquirer from 'inquirer'
import { Plugin } from '../utils/interfaces'
const pkg = require('../../package.json')
class Commander {
list: {
[propName: string]: Plugin
}
program: typeof program
inquirer: typeof inquirer
private ctx: PicGo
constructor (ctx: PicGo) {
this.list = {}
this.program = program
this.inquirer = inquirer
this.ctx = ctx
}
// ...
}
export default Commander
复制代码
而后咱们在PicGo-Core的核心类里将其实例化:
import Commander from '../lib/Commander'
class PicGo extends EventEmitter {
// ...
cmd: Commander
constructor (configPath: string = '') {
super()
this.cmd = new Commander(this)
// ...
}
// ...
复制代码
这样其余部分就可使用ctx.cmd.program
来调用commander.js
以及使用ctx.cmd.inquirer
来调用Inquirer.js
了。
这两个库的使用,网络上有不少教程了。此处简单举个例子,咱们从PicGo最基本的功能——命令行上传图片开始提及。
为了与以前的插件结构统一,咱们把命令注册也写到handle
函数里。
import PicGo from '../../core/PicGo'
import path from 'path'
import fs from 'fs-extra'
export default {
handle: (ctx: PicGo): void => {
const cmd = ctx.cmd
cmd.program // 此处是一个commander.js实例
.command('upload') // 注册命令 upload
.description('upload, go go go') // 命令的描述
.arguments('[input...]') // 命令的参数
.alias('u') // 命令的别名 u
.action(async (input: string[]) => { // 命令执行的函数
const inputList = input // 获取输入的input
.map((item: string) => path.resolve(item))
.filter((item: string) => {
const exist = fs.existsSync(item) // 判断输入的地址存不存在
if (!exist) {
ctx.log.warn(`${item} is not existed.`) // 若是不存在就返回警告信息
}
return exist
})
await ctx.upload(inputList) // 上传图片(调用生命周期的start函数)
})
}
}
复制代码
这样咱们若是经过某种方式把命令注册进去:
import PicGo from '../../core/PicGo'
import upload from './upload'
// ...
export default (ctx: PicGo): void => {
ctx.cmd.register('upload', upload) // 此处的注册逻辑跟lifecyclePlugins一致。
// ...
}
复制代码
当代码写到这里,可能你们以为已经大功告成了。实际上还差了最后一步,咱们缺乏一个入口来接纳咱们输入的命令。就好比如今咱们写完了命令,也写完了命令的注册,而后咱们要怎么在命令行里使用呢?
这个时候要简单说下package.json
里的两个字段bin
和main
。其中main
字段指向的文件,是你const xxx = require('xxx')
的时候拿到的东西。而bin
字段指向的文件,就是你在全局安装了以后,能够在命令行里直接输入的命令。
举个例子,PicGo-Core的bin
字段以下:
// ...
"bin": {
"picgo": "./bin/picgo"
},
复制代码
那么用户若是全局安装了picgo,就能够经过picgo
这个命令来使用picgo了。相似安装@vue/cli
以后,可使用vue
这个命令同样。
那么咱们来看看./bin/picgo
作了啥。源码在这里。
#!/usr/bin/env node
const path = require('path')
const minimist = require('minimist')
let argv = minimist(process.argv.slice(2)) // 解析命令行
let configPath = argv.c || argv.config || '' // 查看是否提供了configPath
if (configPath !== true && configPath !== '') {
configPath = path.resolve(configPath)
} else {
configPath = ''
}
const PicGo = require('../dist/index')
const picgo = new PicGo(configPath) // 实例化picgo
picgo.registerCommands() // 注册命令
try {
picgo.cmd.program.parse(process.argv) // 调用commander.js解析命令
} catch (e) {
picgo.log.error(e)
if (process.argv.includes('--debug')) {
Promise.reject(e)
}
}
复制代码
关键部分就在picgo.cmd.program.parse(process.argv)
这句话,这句话调用了commander.js
来解析process.argv
,也就是命令行里命令以及参数。
那么咱们在开发阶段就能够用./bin/picgo upload
这样来调用命令,而在生产环境下,也就是用户全局安装后,就能够经过picgo upload
这样来调用命令了。
前文提到了,配置项是插件系统里很重要的一个组成部分。不一样插件系统的配置项处理不太同样。好比Hexo
提供了_config.yml
供用户配置,vue-cli3
提供了vue.config.js
供用户配置。PicGo也提供了config.json
供用户配置,不过在此基础上,我想提供一个更方便的方式来让用户直接在命令行里完成配置,而不须要专门打开这个配置文件。
好比咱们能够经过命令行来选择当前上传的图床是什么:
$ picgo use
? Use an uploader (Use arrow keys)
smms
❯ tcyun
weibo
github
qiniu
imgur
aliyun
(Move up and down to reveal more choices)
复制代码
这种在命令行里的交互,须要以前提到的Inquirer.js
来辅助咱们达到这个效果。
它的用法也很简单,传入一个prompts
(能够理解为一个问题数组),而后它会将问题的结果再以对象的形式返回出来,咱们一般将这个结果记为answer
。
而PicGo为了简化这个过程,只须要插件提供一个config
方法,这个方法只需返回一个合法的prompts
问题数组,而后PicGo会自动调用Inquirer.js
去执行它,并自动将结果写入配置文件里。
举个例子,PicGo内置的Imgur
图床的config
代码以下:
const config = (ctx: PicGo): PluginConfig[] => {
let userConfig = ctx.getConfig('picBed.imgur')
if (!userConfig) {
userConfig = {}
}
const config = [
{
name: 'clientId',
type: 'input',
default: userConfig.clientId || '',
required: true
},
{
name: 'proxy',
type: 'input',
default: userConfig.proxy || '',
required: false
}
]
return config // 这个config就是一个合法的prompts数组
}
export default {
// ...
config
}
复制代码
而后咱们用代码实现可以在命令行里调用它,源码传送门:
如下代码有所精简
import PicGo from '../../core/PicGo'
import { PluginConfig } from '../../utils/interfaces'
// 处理uploader的config数组,而后写入配置文件
const handleConfig = async (ctx: PicGo, prompts: PluginConfig, name: string): Promise<void> => {
const answer = await ctx.cmd.inquirer.prompt(prompts)
let configName = `picBed.${name}`
ctx.saveConfig({
[configName]: answer
})
}
export default {
handle: (ctx: PicGo): void => {
const cmd: typeof ctx.cmd = ctx.cmd
cmd.program
.command('set') // 注册一个set命令
.alias('config') // 别名 config
.description('configure config of picgo')
.action(async () => {
try {
let prompts = [ // prompts问题数组
{
type: 'list',
name: 'uploader',
choices: ctx.helper.uploader.getIdList(), // 获取Uploader列表
message: `Choose a(n) uploader`,
default: ctx.config.picBed.uploader || ctx.config.picBed.current
}
]
let answer = await ctx.cmd.inquirer.prompt(prompts) // 等待inquirer处理用户的输入
const item = ctx.helper.uploader.get(answer.uploader) // 获取用户选择的uploader
if (item.config) { // 若是uploader提供了config方法
await handleConfig(ctx, item.config(ctx), answer.uploader) //处理该config方法暴露出的prompts数组
}
ctx.log.success('Configure config successfully!')
} catch (e) {
ctx.log.error(e)
if (process.argv.includes('--debug')) {
Promise.reject(e)
}
}
})
}
}
复制代码
上面是针对Uploader的config方法进行的配置处理,对于其余插件也是同理的,就再也不赘述。这样咱们就实现了可以经过命令行快速对配置文件进行配置,用户体验又是++。
讲了那么多,咱们都是在本地书写的插件系统,如何发布让别人可以安装使用呢?关于往npm发布模块有不少相关文章,好比参考这篇文章。我在这里想讲的是如何发布一个既能在命令行使用,又能够经过好比const picgo = require('picgo')
在Node.js项目里使用API调用的库。
其实这个上面的部分里也提到了。咱们在发布一个npm库的时候一般是在package.json
里的main
字段指定这个库的入口文件。那么这样使用者就能够经过好比const picgo = require('picgo')
在Node.js项目里使用。
若是咱们想要让这个库安装以后可以注册一个命令,那么咱们能够在bin
字段里指定这个命令已经对应的入口文件。好比:
// ...
"bin": {
"picgo": "./bin/picgo"
},
复制代码
这样咱们在全局安装以后就会在系统里注册一个叫作picgo
的命令了。
固然这个时候bin
和main
的入口文件一般是不同的。bin
的入口文件须要作好解析命令行的功能。因此一般咱们会使用一些命令行解析的库例如minimist
或者commander.js
等等来解析命令行里的参数。
至此,一个CLI插件系统的关键部分咱们就基本实现了。那么咱们在Electron项目里,能够在main
进程里使用咱们所写的插件系统,并经过这个插件暴露的API来打造应用的插件系统了。下一篇文章会详细讲述如何把CLI插件系统整合进Electron,实现GUI插件系统,并加入一些额外的机制,使得在GUI上的插件系统更加灵活而强大。
本文不少都是我在开发PicGo
的时候碰到的问题、踩的坑。也许文中简单的几句话背后就是我无数次的查阅和调试。但愿这篇文章可以给你的electron-vue
开发带来一些启发。文中相关的代码,你均可以在PicGo和PicGo-Core的项目仓库里找到,欢迎star~若是本文可以给你带来帮助,那么将是我最开心的地方。若是喜欢,欢迎关注个人博客以及本系列文章的后续进展。
注:文中的图片除未特意说明以外均属于我我的做品,须要转载请私信
感谢这些高质量的文章: