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. 以及沒來得及記錄的那些好文章,感謝大家!
相關文章
相關標籤/搜索