原文首發在個人博客,歡迎關注!html
前段時間,我用electron-vue開發了一款跨平臺(目前支持主流三大桌面操做系統)的免費開源的圖牀上傳應用——PicGo,在開發過程當中踩了很多的坑,不只來自應用的業務邏輯自己,也來自electron自己。在開發這個應用過程當中,我學了很多的東西。由於我也是從0開始學習electron,因此不少經歷應該也能給初學、想學electron開發的同窗們一些啓發和指示。故而寫一份Electron的開發實戰經歷,用最貼近實際工程項目開發的角度來闡述。但願能幫助到你們。前端
預計將會從幾篇系列文章或方面來展開:vue
PicGo
是採用electron-vue
開發的,因此若是你會vue
,那麼跟着一塊兒來學習將會比較快。若是你的技術棧是其餘的諸如react
、angular
,那麼純按照本教程雖然在render端(能夠理解爲頁面)的構建可能學習到的東西很少,不過在main端(Electron
的主進程)應該仍是能學習到相應的知識的。node
若是以前的文章沒閱讀的朋友能夠先從以前的文章跟着看。而且若是沒有看過前一篇CLI插件系統構建的朋友,須要先行閱讀,本文涉及到的部份內容來自上一篇文章。react
咱們以前構建的插件系統是基於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內部的require
被Webpack
也打包進去了。vue-cli
Electron
的main
進程和renderer
進程實際上你能夠把它們當作咱們平時Web開發的後端和前端。兩者交流的工具也再也不是Ajax
,而是ipcMain
和ipcRenderer
。固然renderer
自己能作的事情也很多,只不過這樣說一下可能會好理解一點。相應的,咱們的插件系統本來實如今Node.js
端,是一個沒有界面的工具,想要讓它擁有「臉面」,其實也不過是在renderer
進程裏調用來自main
進程裏的插件系統暴露出來的api而已。這裏咱們舉幾個例子來講明。
在之前PicGo上傳圖片須要通過不少步驟:
Base64
編碼。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')
來實現的。它們的效果以下:
並且咱們還能夠經過接入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
進程監聽事件並調用Uploader
的upload
方法: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版本里實現了,因此這些功能咱們只須要經過向上一節裏說的調用ipcRenderer
和ipcMain
來調用相應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) // 將插件信息列表發送回去
})
複製代碼
注意到因爲ipcMain
和ipcRenderer
裏收發數據的時候會自動通過JSON.stringify
和JSON.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
的Menu
模塊去實現了(我在以前的文章裏已經有涉及,再也不贅述),並無特別複雜的地方。而這裏比較關鍵的地方,就是當我點擊配置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版本實現的思路,大同小異。
不過既然是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
實現了以下功能:
showInputBox([option])
調用以後打開一個輸入彈窗,能夠用於接受用戶輸入。showFileExplorer([option])
調用以後打開一個文件瀏覽器,能夠獲得用戶選擇的文件(夾)路徑。upload([file])
調用以後使用PicGo底層來上傳,能夠實現自動更新相冊圖片、上傳成功後自動將URL寫入剪貼板。showNotificaiton(option)
調用以後彈出系統通知窗口。上面api咱們能夠經過諸如guiApi.showInputBox()
、guiApi.showFileExplorer()
等來實現調用。這裏面的例子實現思路都差很少,我簡單以guiApi.showFileExplorer()
來作講解。
當咱們在renderer
界面點擊插件實現的某個菜單以後,其實是經過調用ipcRenderer
向main
進程傳播了一次事件:
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
對象。這樣插件開發者就能夠經過async
和await
來方便獲取這些方法的返回值了:
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的列表。如下羅列一些我以爲比較有用或者有意思的插件:
若是你也想爲PicGo開發插件,歡迎閱讀開發文檔,PicGo有你更精彩哈哈!
本文不少都是我在開發PicGo
的時候碰到的問題、踩的坑。也許文中簡單的幾句話背後就是我無數次的查閱和調試。但願這篇文章可以給你的electron-vue
開發帶來一些啓發。文中相關的代碼,你均可以在PicGo和PicGo-Core的項目倉庫裏找到,歡迎star~若是本文可以給你帶來幫助,那麼將是我最開心的地方。若是喜歡,歡迎關注個人博客以及本系列文章的後續進展。
注:文中的圖片除未特意說明以外均屬於我我的做品,須要轉載請私信
感謝這些高質量的文章: