Electron-vue开发实战6——开发插件系统之GUI部分

原文首发在个人博客,欢迎关注!html

前言

前段时间,我用electron-vue开发了一款跨平台(目前支持主流三大桌面操做系统)的免费开源的图床上传应用——PicGo,在开发过程当中踩了很多的坑,不只来自应用的业务逻辑自己,也来自electron自己。在开发这个应用过程当中,我学了很多的东西。由于我也是从0开始学习electron,因此不少经历应该也能给初学、想学electron开发的同窗们一些启发和指示。故而写一份Electron的开发实战经历,用最贴近实际工程项目开发的角度来阐述。但愿能帮助到你们。前端

预计将会从几篇系列文章或方面来展开:vue

  1. electron-vue入门
  2. Main进程和Renderer进程的简单开发
  3. 引入基于Lodash的JSON database——lowdb
  4. 跨平台的一些兼容措施
  5. 经过CI发布以及更新的方式
  6. 开发插件系统——CLI部分
  7. 开发插件系统——GUI部分
  8. 想到再写...

说明

PicGo是采用electron-vue开发的,因此若是你会vue,那么跟着一块儿来学习将会比较快。若是你的技术栈是其余的诸如reactangular,那么纯按照本教程虽然在render端(能够理解为页面)的构建可能学习到的东西很少,不过在main端(Electron的主进程)应该仍是能学习到相应的知识的。node

若是以前的文章没阅读的朋友能够先从以前的文章跟着看。而且若是没有看过前一篇CLI插件系统构建的朋友,须要先行阅读,本文涉及到的部份内容来自上一篇文章。react

运行时的require

咱们以前构建的插件系统是基于Node.js端的。对于Electron而言,main进程能够认为拥有Node.js环境,因此咱们首先要在main进程里将其引入。而对于PicGo而言,因为上传流程已经彻底抽离到PicGo-Core这个库里了,因此本来存在于Electron端的上传部分就能够精简整合成调用PicGo-Core的api来实现上传部分的逻辑了。webpack

而在引入PicGo-Core的时候会遇到一个问题。在Electron端,因为我使用的脚手架是Electron-vue,它会将main进程和renderer进程都经过Webapck进行打包。因为PicGo-Core用于加载插件的部分使用的是require,在Node.js端很正常没问题。可是Webpack并不知道这些require是在运行时才须要调用的,它会认为这是构建时的「常规」require,也就会在打包的时候把你require的插件也打包进来。这样明显是不合理的,咱们是运行时才require插件的,因此须要作一些手段来「绕开」Webpack的打包机制:git

// eslint-disable-next-line
const requireFunc = typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require
const PicGo = requireFunc('picgo')
复制代码

关于__non_webpack_require__的说明,能够查看文档github

打包以后会变成以下:web

const requireFunc = true ? require : require
const PicGo = requireFunc('picgo')
复制代码

这样就能够避免PicGo-Core内部的requireWebpack也打包进去了。vue-cli

「先后端」分离

Electronmain进程和renderer进程实际上你能够把它们当作咱们平时Web开发的后端和前端。两者交流的工具也再也不是Ajax,而是ipcMainipcRenderer。固然renderer自己能作的事情也很多,只不过这样说一下可能会好理解一点。相应的,咱们的插件系统本来实如今Node.js端,是一个没有界面的工具,想要让它拥有「脸面」,其实也不过是在renderer进程里调用来自main进程里的插件系统暴露出来的api而已。这里咱们举几个例子来讲明。

简化原有流程

在之前PicGo上传图片须要通过不少步骤:

  1. 经过uploader来接收图片,并经过pic-bed-handler来指定上传的图床。
  2. 经过img2base64来把图片统一转成Base64编码。
  3. 经过指定的imgUploader(好比qiniu好比weibo等)来上传到指定的图床。

而现在整个底层上传流程系统已经被抽离出来,所以咱们能够直接使用PicGo-Core实现的api来上传图片,只需定义一个Uploader类便可(下面的代码是简化版本):

import {
  app,
  Notification,
  BrowserWindow,
  ipcMain
} from 'electron'
import path from 'path'

// eslint-disable-next-line
const requireFunc = typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require
const PicGo = requireFunc('picgo')
const STORE_PATH = app.getPath('userData')
const CONFIG_PATH = path.join(STORE_PATH, '/data.json')

class Uploader {
  constructor (img, webContents, picgo = undefined) {
    this.img = img
    this.webContents = webContents
    this.picgo = picgo
  }

  upload () {
    const win = BrowserWindow.fromWebContents(this.webContents) // 获取上传的窗口
    const picgo = this.picgo || new PicGo(CONFIG_PATH) // 获取上传的picgo实例
    picgo.config.debug = true // 方便调试
    // for picgo-core
    picgo.config.PICGO_ENV = 'GUI'
    let input = this.img // 传入的this.img是一个数组

    picgo.upload(input) // 上传图片,只用了一句话

    picgo.on('notification', message => { // 上传成功或者失败提示信息
      const notification = new Notification(message)
      notification.show()
    })

    picgo.on('uploadProgress', progress => { // 上传进度
      this.webContents.send('uploadProgress', progress)
    })

    return new Promise((resolve) => { // 返回一个Promise方便调用
      picgo.on('finished', ctx => { // 上传完成的事件
        if (ctx.output.every(item => item.imgUrl)) {
          resolve(ctx.output)
        } else {
          resolve(false)
        }
      })
      picgo.on('failed', ctx => { // 上传失败的事件
        const notification = new Notification({
          title: '上传失败',
          body: '请检查配置和上传的文件是否符合要求'
        })
        notification.show()
        resolve(false)
      })
    })
  }
}

export default Uploader
复制代码

能够看出,因为在设计CLI插件系统的时候咱们有考虑到设计好插件的生命周期,因此不少功能均可以经过生命周期的钩子、以及相应的一些事件来实现。好比图片上传完成就是经过picgo.on('finished', callback)监听finished事件来实现的,而上传的进度与进度条显示就是经过picgo.on('progress')来实现的。它们的效果以下:

upload-process

并且咱们还能够经过接入picgo的生命周期,实现一些之前实现起来比较麻烦的功能,好比上传前重命名:

picgo.helper.beforeUploadPlugins.register('renameFn', {
  handle: async ctx => {
    const rename = picgo.getConfig('settings.rename')
    const autoRename = picgo.getConfig('settings.autoRename')
    await Promise.all(ctx.output.map(async (item, index) => {
      let name
      let fileName
      if (autoRename) {
        fileName = dayjs().add(index, 'second').format('YYYYMMDDHHmmss') + item.extname
      } else {
        fileName = item.fileName
      }
      if (rename) { // 若是要重命名
        const window = createRenameWindow(win) // 建立重命名窗口
        await waitForShow(window.webContents) // 等待窗口打开
        window.webContents.send('rename', fileName, window.webContents.id) // 给窗口发送相应信息
        name = await waitForRename(window, window.webContents.id) // 获取从新命名后的文件名
      }
      item.fileName = name || fileName
    }))
  }
})
复制代码

经过注册一个beforeUploadPlugin,在上传前判断是否须要「上传前重命名」,若是是,就建立窗口并等待用户输入重命名的结果,而后将重命名的name赋值给item.fileName供后续的流程使用。

咱们还能够在beforeTransform阶段通知用户当前正在准备上传了:

picgo.on('beforeTransform', ctx => {
  if (ctx.getConfig('settings.uploadNotification')) {
    const notification = new Notification({
      title: '上传进度',
      body: '正在上传'
    })
    notification.show()
  }
})
复制代码

等等。因此实际上咱们只须要在main进程完成相应的api,那么renderer进程作的事只不过是经过ipcRenderer来经过main进程调用这些api而已了。好比:

  • 当用户拖动图片到上传区域,经过ipcRenderer通知main进程:
this.$electron.ipcRenderer.send('uploadChoosedFiles', sendFiles)
复制代码
  • main进程监听事件并调用Uploaderupload方法:
ipcMain.on('uploadChoosedFiles', async (evt, files) => {
  const input = files.map(item => item.path)
  const imgs = await new Uploader(input, evt.sender).upload() // 因为upload返回的是Promise
  // ...
})
复制代码

就完成了一次「先后端」交互。其余方式上传(好比剪贴板上传)也同理,就再也不赘述。

实现插件管理界面

光有插件系统没有插件也不行,因此咱们须要实现一个插件管理的界面。而插件管理的功能(好比安装、卸载、更新)已经在CLI版本里实现了,因此这些功能咱们只须要经过向上一节里说的调用ipcRendereripcMain来调用相应api便可。

第三方插件搜索

在GUI界面咱们须要一个很重要的功能就是「插件搜索」的功能。因为PicGo的插件统一是发布到npm的,因此其实咱们能够经过npm的api来打到搜索插件的目的:

getSearchResult (val) {
  // this.$http.get(`https://api.npms.io/v2/search?q=${val}`)
  this.$http.get(`https://registry.npmjs.com/-/v1/search?text=${val}`) // 调用npm的搜索api
    .then(res => {
      this.pluginList = res.data.objects.map(item => {
        return this.handleSearchResult(item) // 返回格式化的结果
      })
      this.loading = false
    })
    .catch(err => {
      console.log(err)
      this.loading = false
    })
},
handleSearchResult (item) {
  const name = item.package.name.replace(/picgo-plugin-/, '')
  let gui = false
  if (item.package.keywords && item.package.keywords.length > 0) {
    if (item.package.keywords.includes('picgo-gui-plugin')) {
      gui = true
    }
  }
  return {
    name: name,
    author: item.package.author.name,
    description: item.package.description,
    logo: `https://cdn.jsdelivr.net/npm/${item.package.name}/logo.png`,
    config: {},
    homepage: item.package.links ? item.package.links.homepage : '',
    hasInstall: this.pluginNameList.some(plugin => plugin === item.package.name.replace(/picgo-plugin-/, '')),
    version: item.package.version,
    gui,
    ing: false // installing or uninstalling
  }
}
复制代码

经过搜索而后把结果显示到界面上就是以下:

没有安装的插件就会在右下角显示「安装」两个字样。

本地插件列表

当咱们安装好插件以后,须要从本地获取插件列表。这个部分须要作一些处理。因为插件是安装在Node.js端的,因此咱们须要经过ipcRenderer去向main进程发起获取插件列表的「请求」:

this.$electron.ipcRenderer.send('getPluginList') // 发起获取插件的「请求」
this.$electron.ipcRenderer.on('pluginList', (evt, list) => { // 获取插件列表
  this.pluginList = list
  this.pluginNameList = list.map(item => item.name)
  this.loading = false
})
复制代码

而获取插件列表以及相应信息咱们须要在main端进行,并发送回去:

ipcMain.on('getPluginList', event => {
  const picgo = new PicGo(CONFIG_PATH)
  const pluginList = picgo.pluginLoader.getList()
  const list = []
  for (let i in pluginList) {
   // 处理插件相关的信息
  }
  event.sender.send('pluginList', list) // 将插件信息列表发送回去
})
复制代码

注意到因为ipcMainipcRenderer里收发数据的时候会自动通过JSON.stringifyJSON.parse,因此对于原来的一些属性是function之类没法被序列化的属性,咱们要作一些处理,好比先执行它们获得结果:

const handleConfigWithFunction = config => {
  for (let i in config) {
    if (typeof config[i].default === 'function') {
      config[i].default = config[i].default()
    }
    if (typeof config[i].choices === 'function') {
      config[i].choices = config[i].choices()
    }
  }
  return config
}
复制代码

这样,在renderer进程里才能拿到完整的数据。

插件配置相关

固然光有安装、查看还不够,还须要让插件管理界面拥有其余功能,好比「卸载」、「更新」或者是配置功能,因此在每一个安装成功后的插件卡片的右下角有个配置按钮能够弹出相应的菜单:

菜单这个部分就是用ElectronMenu模块去实现了(我在以前的文章里已经有涉及,再也不赘述),并无特别复杂的地方。而这里比较关键的地方,就是当我点击配置plugin-xxx的时候,会弹出一个配置的对话框:

这个配置对话框内的配置内容来自前文《开发CLI插件系统》里咱们要求开发者定义好的config方法返回的配置项。因为插件开发者定义的config内容是Inquirer.js所要求的格式,便于在CLI环境下使用。可是它和咱们平时使用的form表单的一些格式可能有些出入,因此须要「转义」一下,经过原始的config动态生成表单项:

<div id="config-form">
  <el-form label-position="right" label-width="120px" :model="ruleForm" ref="form" size="mini" >
    <el-form-item v-for="(item, index) in configList" :label="item.name" :required="item.required" :prop="item.name" :key="item.name + index" >
      <el-input v-if="item.type === 'input' || item.type === 'password'" :type="item.type === 'password' ? 'password' : 'input'" v-model="ruleForm[item.name]" :placeholder="item.message || item.name" ></el-input>
      <el-select v-else-if="item.type === 'list'" v-model="ruleForm[item.name]" :placeholder="item.message || item.name" >
        <el-option v-for="(choice, idx) in item.choices" :label="choice.name || choice.value || choice" :key="choice.name || choice.value || choice" :value="choice.value || choice" ></el-option>
      </el-select>
      <el-select v-else-if="item.type === 'checkbox'" v-model="ruleForm[item.name]" :placeholder="item.message || item.name" multiple collapse-tags >
        <el-option v-for="(choice, idx) in item.choices" :label="choice.name || choice.value || choice" :key="choice.value || choice" :value="choice.value || choice" ></el-option>
      </el-select>
      <el-switch v-else-if="item.type === 'confirm'" v-model="ruleForm[item.name]" active-text="yes" inactive-text="no" >
      </el-switch>
    </el-form-item>
    <slot></slot>
  </el-form>
</div>
复制代码

上面是针对config里不一样的type转换成不一样的Web表单控件的代码。下面是初始化的时候处理config的一些工做:

watch: {
  config: {
    deep: true,
    handler (val) {
      this.ruleForm = Object.assign({}, {})
      const config = this.$db.read().get(`picBed.${this.id}`).value()
      if (val.length > 0) {
        this.configList = cloneDeep(val).map(item => {
          let defaultValue = item.default !== undefined
            ? item.default : item.type === 'checkbox'
              ? [] : null // 处理默认值
          if (item.type === 'checkbox') { // 处理checkbox选中值
            const defaults = item.choices.filter(i => {
              return i.checked
            }).map(i => i.value)
            defaultValue = union(defaultValue, defaults)
          }
          if (config && config[item.name] !== undefined) { // 处理默认值
            defaultValue = config[item.name]
          }
          this.$set(this.ruleForm, item.name, defaultValue)
          return item
        })
      }
    },
    immediate: true // 当即执行
  }
}
复制代码

通过上述处理,就能够将本来用于CLI的配置项,近乎「无缝」地迁移到Web(GUI)端了。其实这也是vue-cli3的ui版本实现的思路,大同小异。

实现特有的guiApi

不过既然是GUI软件了,只经过调用CLI实现的功能明显是不够丰富的。所以我也为PicGo实现了一些特有的guiApi提供给插件的开发者,让插件的可玩性更强。固然不一样的软件给予插件的GUI能力是不同的,所以不能一律而论。我仅以PicGo为例,讲述我对于PicGo所提供的guiApi的理解和见解。下面我就来讲说这部分是如何实现的。

因为PicGo本质是一个上传系统,因此用户在上传图片的时候,不少插件底层的东西和功能其实是看不到的。若是要让插件的功能更加丰富,就须要让插件有本身的「可视化」入口让用户去使用。所以对于PicGo而言,我给予插件的「可视化」入口就放在插件配置的界面里——除了给插件默认的配置菜单以外,还给予插件本身的菜单项供用户使用:

这个实现也很容易,只要插件在本身的index.js文件里暴露一个guiMenu的选项,就能够生成本身的菜单:

const guiMenu = ctx => {
  return [
    {
      label: '打开InputBox',
      async handle (ctx, guiApi) {
        // do something...
      }
    },
    {
      label: '打开FileExplorer',
      async handle (ctx, guiApi) {
        // do something...
      }
    },
    // ...
  ]
}
复制代码

能够看到菜单项能够自定义,点击以后的操做也能够自定义,所以给予了插件很大的自由度。能够注意到,在点击菜单的时候会触发handle函数,这个函数里会传入一个guiApi,这个就是本节的重点了。就目前而言,guiApi实现了以下功能:

  1. showInputBox([option]) 调用以后打开一个输入弹窗,能够用于接受用户输入。
  2. showFileExplorer([option]) 调用以后打开一个文件浏览器,能够获得用户选择的文件(夹)路径。
  3. upload([file]) 调用以后使用PicGo底层来上传,能够实现自动更新相册图片、上传成功后自动将URL写入剪贴板。
  4. showNotificaiton(option) 调用以后弹出系统通知窗口。

上面api咱们能够经过诸如guiApi.showInputBox()guiApi.showFileExplorer()等来实现调用。这里面的例子实现思路都差很少,我简单以guiApi.showFileExplorer()来作讲解。

当咱们在renderer界面点击插件实现的某个菜单以后,其实是经过调用ipcRenderermain进程传播了一次事件:

if (plugin.guiMenu) {
  menu.push({
    type: 'separator'
  })
  for (let i of plugin.guiMenu) {
    menu.push({
      label: i.label,
      click () { // 当点击的时候,发送当前的插件名和当前菜单项的名字
        _this.$electron.ipcRenderer.send('pluginActions', plugin.name, i.label)
      }
    })
  }
}
复制代码

因而在main进程,咱们经过监听这个事件,来调用相应的guiApi

const handlePluginActions = (ipcMain, CONFIG_PATH) => {
  ipcMain.on('pluginActions', (event, name, label) => {
    const picgo = new PicGo(CONFIG_PATH)
    const plugin = picgo.pluginLoader.getPlugin(`picgo-plugin-${name}`)
    const guiApi = new GuiApi(ipcMain, event.sender, picgo) // 实例化guiApi
    if (plugin.guiMenu && plugin.guiMenu(picgo).length > 0) {
      const menu = plugin.guiMenu(picgo)
      menu.forEach(item => {
        if (item.label === label) { // 找到相应的label,执行插件的`handle`
          item.handle(picgo, guiApi)
        }
      })
    }
  })
}
复制代码

guiApi的实现类GuiApi其实特别简单:

import {
  dialog,
  BrowserWindow,
  clipboard,
  Notification
} from 'electron'
import db from '../../datastore'
import Uploader from './uploader'
import pasteTemplate from './pasteTemplate'
const WEBCONTENTS = Symbol('WEBCONTENTS')
const IPCMAIN = Symbol('IPCMAIN')
const PICGO = Symbol('PICGO')
class GuiApi {
  constructor (ipcMain, webcontents, picgo) {
    this[WEBCONTENTS] = webcontents
    this[IPCMAIN] = ipcMain
    this[PICGO] = picgo
  }

  /** * for plugin show file explorer * @param {object} options */
  showFileExplorer (options) {
    if (options === undefined) {
      options = {}
    }
    return new Promise((resolve, reject) => {
      dialog.showOpenDialog(BrowserWindow.fromWebContents(this[WEBCONTENTS]), options, filename => {
        resolve(filename)
      })
    })
  }
}
复制代码

实际上就是去调用一些Electron的方法,甚至是你本身封装的一些方法,返回值是一个新的Promise对象。这样插件开发者就能够经过asyncawait来方便获取这些方法的返回值了:

const guiMenu = ctx => {
  return [
    {
      label: '打开文件浏览器',
      async handle (ctx, guiApi) {
        // 经过await获取用户所选的文件路径
        const files = await guiApi.showFileExplorer({
          properties: ['openFile', 'multiSelections']
        })
        console.log(files)
      }
    }
  ]
}
复制代码

小结

至此,一个GUI插件系统的关键部分咱们就基本实现了。除了整合了CLI插件系统的几乎全部功能以外,咱们还提供了独特的guiApi给插件开发者无限的想象空间,也给用户带来更好的插件体验。能够说插件系统的实现,让PicGo有了更多的可玩性。关于PicGo目前的插件,欢迎查看Awesome-PicGo的列表。如下罗列一些我以为比较有用或者有意思的插件:

  1. vs-picgo 在VSCode里使用PicGo(无需安装GUI!)
  2. picgo-plugin-pic-migrater 能够迁移你的Markdown里的图片地址到你默认指定的图床,哪怕是本地图片也能够迁移到云端!
  3. picgo-plugin-github-plus 加强版GitHub图床,支持了同步图床以及同步删除操做(删除本地图片也会把GitHub上的图片删除)
  4. picgo-plugin-web-uploader 支持PicUploader配置的图床插件
  5. picgo-plugin-qingstor-uploader 支持青云云存储的图床插件
  6. picgo-plugin-blog-uploader 支持掘金、简书和CSDN来作图床的图床插件

若是你也想为PicGo开发插件,欢迎阅读开发文档,PicGo有你更精彩哈哈!

本文不少都是我在开发PicGo的时候碰到的问题、踩的坑。也许文中简单的几句话背后就是我无数次的查阅和调试。但愿这篇文章可以给你的electron-vue开发带来一些启发。文中相关的代码,你均可以在PicGoPicGo-Core的项目仓库里找到,欢迎star~若是本文可以给你带来帮助,那么将是我最开心的地方。若是喜欢,欢迎关注个人博客以及本系列文章的后续进展。

注:文中的图片除未特意说明以外均属于我我的做品,须要转载请私信

参考文献

感谢这些高质量的文章:

  1. 用Node.js开发一个Command Line Interface (CLI)
  2. Node.js编写CLI的实践
  3. Node.js模块机制
  4. 前端插件系统设计与实现
  5. Hexo插件机制分析
  6. 如何实现一个简单的插件扩展
  7. 使用NPM发布与维护TypeScript模块
  8. typescript npm 包例子
  9. 经过travis-ci发布npm包
  10. Dynamic load module in plugin from local project node_modules folder
  11. 跟着老司机玩转Node命令行
  12. 以及没来得及记录的那些好文章,感谢大家!
相关文章
相关标签/搜索