原文首发在个人博客,欢迎关注!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~若是本文可以给你带来帮助,那么将是我最开心的地方。若是喜欢,欢迎关注个人博客以及本系列文章的后续进展。
注:文中的图片除未特意说明以外均属于我我的做品,须要转载请私信
感谢这些高质量的文章: