Electron-vue開發實戰6——開發外掛系統之GUI部分

Molunerfinn發表於2019-03-18

原文首發在我的部落格,歡迎關注!

前言

前段時間,我用electron-vue開發了一款跨平臺(目前支援主流三大桌面作業系統)的免費開源的圖床上傳應用——PicGo,在開發過程中踩了不少的坑,不僅來自應用的業務邏輯本身,也來自electron本身。在開發這個應用過程中,我學了不少的東西。因為我也是從0開始學習electron,所以很多經歷應該也能給初學、想學electron開發的同學們一些啟發和指示。故而寫一份Electron的開發實戰經歷,用最貼近實際工程專案開發的角度來闡述。希望能幫助到大家。

預計將會從幾篇系列文章或方面來展開:

  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的主程式)應該還是能學習到相應的知識的。

如果之前的文章沒閱讀的朋友可以先從之前的文章跟著看。並且如果沒有看過前一篇CLI外掛系統構建的朋友,需要先行閱讀,本文涉及到的部分內容來自上一篇文章。

執行時的require

我們之前構建的外掛系統是基於Node.js端的。對於Electron而言,main程式可以認為擁有Node.js環境,所以我們首先要在main程式裡將其引入。而對於PicGo而言,由於上傳流程已經完全抽離到PicGo-Core這個庫裡了,所以原本存在於Electron端的上傳部分就可以精簡整合成呼叫PicGo-Core的api來實現上傳部分的邏輯了。

而在引入PicGo-Core的時候會遇到一個問題。在Electron端,由於我使用的腳手架是Electron-vue,它會將main程式和renderer程式都通過Webapck進行打包。由於PicGo-Core用於載入外掛的部分使用的是require,在Node.js端很正常沒問題。但是Webpack並不知道這些require是在執行時才需要呼叫的,它會認為這是構建時的「常規」require,也就會在打包的時候把你require的外掛也打包進來。這樣明顯是不合理的,我們是執行時才require外掛的,所以需要做一些手段來「繞開」Webpack的打包機制:

// eslint-disable-next-line
const requireFunc = typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require
const PicGo = requireFunc('picgo')
複製程式碼

關於__non_webpack_require__的說明,可以檢視文件

打包之後會變成如下:

const requireFunc = true ? require : require
const PicGo = requireFunc('picgo')
複製程式碼

這樣就可以避免PicGo-Core內部的requireWebpack也打包進去了。

「前後端」分離

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
  }
}
複製程式碼

通過搜尋然後把結果顯示到介面上就是如下:

Electron-vue開發實戰6——開發外掛系統之GUI部分

沒有安裝的外掛就會在右下角顯示「安裝」兩個字樣。

本地外掛列表

當我們安裝好外掛之後,需要從本地獲取外掛列表。這個部分需要做一些處理。由於外掛是安裝在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程式裡才能拿到完整的資料。

外掛配置相關

當然光有安裝、檢視還不夠,還需要讓外掛管理介面擁有其他功能,比如「解除安裝」、「更新」或者是配置功能,所以在每個安裝成功後的外掛卡片的右下角有個配置按鈕可以彈出相應的選單:

Electron-vue開發實戰6——開發外掛系統之GUI部分

選單這個部分就是用ElectronMenu模組去實現了(我在之前的文章裡已經有涉及,不再贅述),並沒有特別複雜的地方。而這裡比較關鍵的地方,就是當我點選配置plugin-xxx的時候,會彈出一個配置的對話方塊:

Electron-vue開發實戰6——開發外掛系統之GUI部分

這個配置對話方塊內的配置內容來自前文《開發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而言,我給予外掛的「視覺化」入口就放在外掛配置的介面裡——除了給外掛預設的配置選單之外,還給予外掛自己的選單項供使用者使用:

Electron-vue開發實戰6——開發外掛系統之GUI部分

這個實現也很容易,只要外掛在自己的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. 以及沒來得及記錄的那些好文章,感謝你們!

相關文章