前言
前段时间,我用electron-vue开发了一款跨平台(目前支持主流三大桌面操作系统)的免费开源的图床上传应用——PicGo,在开发过程中踩了不少的坑,不仅来自应用的业务逻辑本身,也来自electron本身。在开发这个应用过程中,我学了不少的东西。因为我也是从0开始学习electron,所以很多经历应该也能给初学、想学electron开发的同学们一些启发和指示。故而写一份Electron的开发实战经历,用最贴近实际工程项目开发的角度来阐述。希望能帮助到大家。
预计将会从几篇系列文章或方面来展开:
- electron-vue入门
- Main进程和Renderer进程的简单开发
- 引入基于Lodash的JSON database——lowdb
- 跨平台的一些兼容措施
- 通过CI发布以及更新的方式
- 开发插件系统——CLI部分
- 开发插件系统——GUI部分
- 想到再写…
说明
PicGo
是采用electron-vue
开发的,所以如果你会vue
,那么跟着一起来学习将会比较快。如果你的技术栈是其他的诸如react
、angular
,那么纯按照本教程虽然在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
的打包机制:
1 2 3
| const requireFunc = typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require const PicGo = requireFunc('picgo')
|
关于__non_webpack_require__
的说明,可以查看文档。
打包之后会变成如下:
1 2
| const requireFunc = true ? require : require const PicGo = requireFunc('picgo')
|
这样就可以避免PicGo-Core内部的require
被Webpack
也打包进去了。
「前后端」分离
Electron
的main
进程和renderer
进程实际上你可以把它们看成我们平时Web开发的后端和前端。二者交流的工具也不再是Ajax
,而是ipcMain
和ipcRenderer
。当然renderer
本身能做的事情也不少,只不过这样说一下可能会好理解一点。相应的,我们的插件系统原本实现在Node.js
端,是一个没有界面的工具,想要让它拥有「脸面」,其实也不过是在renderer
进程里调用来自main
进程里的插件系统暴露出来的api而已。这里我们举几个例子来说明。
简化原有流程
在以前PicGo上传图片需要经过很多步骤:
- 通过uploader来接收图片,并通过pic-bed-handler来指定上传的图床。
- 通过img2base64来把图片统一转成
Base64
编码。 - 通过指定的
imgUploader
(比如qiniu
比如weibo
等)来上传到指定的图床。
而如今整个底层上传流程系统已经被抽离出来,因此我们可以直接使用PicGo-Core实现的api来上传图片,只需定义一个Uploader类即可(下面的代码是简化版本):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| import { app, Notification, BrowserWindow, ipcMain } from 'electron' import path from 'path'
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.config.debug = true picgo.config.PICGO_ENV = 'GUI' let input = 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) => { 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
的生命周期,实现一些以前实现起来比较麻烦的功能,比如上传前重命名:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| 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
阶段通知用户当前正在准备上传了:
1 2 3 4 5 6 7 8 9
| 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
进程:
1
| this.$electron.ipcRenderer.send('uploadChoosedFiles', sendFiles)
|
main
进程监听事件并调用Uploader
的upload
方法:
1 2 3 4 5
| ipcMain.on('uploadChoosedFiles', async (evt, files) => { const input = files.map(item => item.path) const imgs = await new Uploader(input, evt.sender).upload() })
|
就完成了一次「前后端」交互。其他方式上传(比如剪贴板上传)也同理,就不再赘述。
实现插件管理界面
光有插件系统没有插件也不行,所以我们需要实现一个插件管理的界面。而插件管理的功能(比如安装、卸载、更新)已经在CLI版本里实现了,所以这些功能我们只需要通过向上一节里说的调用ipcRenderer
和ipcMain
来调用相应api即可。
第三方插件搜索
在GUI界面我们需要一个很重要的功能就是「插件搜索」的功能。由于PicGo的插件统一是发布到npm的,所以其实我们可以通过npm的api来打到搜索插件的目的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| getSearchResult (val) { this.$http.get(`https://registry.npmjs.com/-/v1/search?text=${val}`) .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 } }
|
通过搜索然后把结果显示到界面上就是如下:
没有安装的插件就会在右下角显示「安装」两个字样。
本地插件列表
当我们安装好插件之后,需要从本地获取插件列表。这个部分需要做一些处理。由于插件是安装在Node.js端的,所以我们需要通过ipcRenderer
去向main
进程发起获取插件列表的「请求」:
1 2 3 4 5 6
| 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
端进行,并发送回去:
1 2 3 4 5 6 7 8 9
| 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
之类无法被序列化的属性,我们要做一些处理,比如先执行它们得到结果:
1 2 3 4 5 6 7 8 9 10 11
| 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
动态生成表单项:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| <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
的一些工作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| 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') { 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
的选项,就可以生成自己的菜单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const guiMenu = ctx => { return [ { label: '打开InputBox', async handle (ctx, guiApi) { } }, { label: '打开FileExplorer', async handle (ctx, guiApi) { } }, ] }
|
可以看到菜单项可以自定义,点击之后的操作也可以自定义,因此给予了插件很大的自由度。可以注意到,在点击菜单的时候会触发handle
函数,这个函数里会传入一个guiApi
,这个就是本节的重点了。就目前而言,guiApi
实现了如下功能:
showInputBox([option])
调用之后打开一个输入弹窗,可以用于接受用户输入。showFileExplorer([option])
调用之后打开一个文件浏览器,可以得到用户选择的文件(夹)路径。upload([file])
调用之后使用PicGo底层来上传,可以实现自动更新相册图片、上传成功后自动将URL写入剪贴板。showNotificaiton(option)
调用之后弹出系统通知窗口。
上面api我们可以通过诸如guiApi.showInputBox()
、guiApi.showFileExplorer()
等来实现调用。这里面的例子实现思路都差不多,我简单以guiApi.showFileExplorer()
来做讲解。
当我们在renderer
界面点击插件实现的某个菜单之后,实际上是通过调用ipcRenderer
向main
进程传播了一次事件:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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) if (plugin.guiMenu && plugin.guiMenu(picgo).length > 0) { const menu = plugin.guiMenu(picgo) menu.forEach(item => { if (item.label === label) { item.handle(picgo, guiApi) } }) } }) }
|
而guiApi
的实现类GuiApi其实特别简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| 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 }
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
来方便获取这些方法的返回值了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const guiMenu = ctx => { return [ { label: '打开文件浏览器', async handle (ctx, guiApi) { const files = await guiApi.showFileExplorer({ properties: ['openFile', 'multiSelections'] }) console.log(files) } } ] }
|
小结
至此,一个GUI插件系统的关键部分我们就基本实现了。除了整合了CLI插件系统的几乎所有功能之外,我们还提供了独特的guiApi
给插件开发者无限的想象空间,也给用户带来更好的插件体验。可以说插件系统的实现,让PicGo
有了更多的可玩性。关于PicGo
目前的插件,欢迎查看Awesome-PicGo的列表。以下罗列一些我觉得比较有用或者有意思的插件:
- vs-picgo 在VSCode里使用PicGo(无需安装GUI!)
- picgo-plugin-pic-migrater 可以迁移你的Markdown里的图片地址到你默认指定的图床,哪怕是本地图片也可以迁移到云端!
- picgo-plugin-github-plus 增强版GitHub图床,支持了同步图床以及同步删除操作(删除本地图片也会把GitHub上的图片删除)
- picgo-plugin-web-uploader 支持PicUploader配置的图床插件
- picgo-plugin-qingstor-uploader 支持青云云存储的图床插件
- picgo-plugin-blog-uploader 支持掘金、简书和CSDN来做图床的图床插件
如果你也想为PicGo开发插件,欢迎阅读开发文档,PicGo有你更精彩哈哈!
本文很多都是我在开发PicGo
的时候碰到的问题、踩的坑。也许文中简单的几句话背后就是我无数次的查阅和调试。希望这篇文章能够给你的electron-vue
开发带来一些启发。文中相关的代码,你都可以在PicGo和PicGo-Core的项目仓库里找到,欢迎star~如果本文能够给你带来帮助,那么将是我最开心的地方。如果喜欢,欢迎关注我的博客以及本系列文章的后续进展。
注:文中的图片除未特地说明之外均属于我个人作品,需要转载请私信
参考文献
感谢这些高质量的文章:
- 用Node.js开发一个Command Line Interface (CLI)
- Node.js编写CLI的实践
- Node.js模块机制
- 前端插件系统设计与实现
- Hexo插件机制分析
- 如何实现一个简单的插件扩展
- 使用NPM发布与维护TypeScript模块
- typescript npm 包例子
- 通过travis-ci发布npm包
- Dynamic load module in plugin from local project node_modules folder
- 跟着老司机玩转Node命令行
- 以及没来得及记录的那些好文章,感谢你们!