近期准备开始做electron
相关的开发工作,因此借着这个机会就再去了解下electron
。在很久以前的文章中有稍微玩过electron+react+antd
的脚手架,但也只限于快速开发electron
应用,并没有去剖析整个项目结构。因此这次,还是得深入一下。
先前一段时间特别喜欢用开源的Motrix下载器,就是基于electron+vue+aria2
去实现的,所以索性就把源码给clone
了下来。本文就从最基础的开始,以Motrix
的启动逻辑为入口,来研究下一个electron
应用是如何打开的。
首先看一下Motrix
的目录结构,源码基本在src
下,呈现这样的层级关系:
main
:主进程,应用内部逻辑
configs
:内部环境配置core
:软件核心管理逻辑menus
:不同os
下的菜单配置pages
:基础页面ui
:各ui
相关的Manager
逻辑utils
:工具方法库Application.js
:应用入口Launcher.js
:启动器入口index.js/index.dev.js
:程序入口
index.dev.js
相对于index.js
只是另外安装了devtools
renderer
:渲染进程,vue
页面逻辑,目录结构也是vue
默认的,可以参考这篇文章
api
:外部接口assets
:资源文件components
:组件页面pages
:应用页面入口,App.vue+main.js
router
:路由store
:应用内部数据utils
:工具方法库workers
:只有一个tray.worker.js
用来绘制托盘icon
shared
:公用逻辑/工具
aria2
:下载工具jslib
locales
:本地化utils
:公用js
工具方法库从MVC
的角度,main
主进程的逻辑相当于是model
,renderer
渲染进程的逻辑相当于是view
,而至于controller
,可以通过electron
支持下的两个进程的ipc
事件处理机制来呈现。这一点,我们直接看启动逻辑就能明白。
运行yarn run dev
,会启动.election-vue/dev-runner.js
,其中会先初始化renderer
和main
,然后再启动electron
// .election-vue/dev-runner.js
function init () {
greeting()
Promise.all([startRenderer(), startMain()])
.then(() => {
startElectron()
})
.catch(err => {
console.error(err)
})
}
在startRenderer
和startMain
中会读取js
配置的程序入口,编译后运行。两个进程的入口entry
分别是:
src/pages/index/main.js
src/main/index.dev.js
首先看渲染进程,运行的入口在这里:
store.dispatch('preference/fetchPreference')
.then((config) => {
console.info('[Motrix] load preference:', config)
init(config)
})
.catch((err) => {
alert(err)
})
首先会通过preference/fetchPreference
这个action
来获得应用配置,然后调用init
函数启动界面。先看获取配置的逻辑:
// src/renderer/store/modules/preferences.js
const actions = {
fetchPreference ({ dispatch }) {
return new Promise((resolve) => {
api.fetchPreference()
.then((config) => {
dispatch('updatePreference', config)
resolve(config)
})
})
},
}
// src/renderer/api/Api.js
export default class Api {
fetchPreference () {
return new Promise((resolve) => {
this.config = this.loadConfig()
resolve(this.config)
})
}
async loadConfig () {
let result = is.renderer() // electron-is,包含electron相关的IsXXX工具函数
? await this.loadConfigFromNativeStore()
: this.loadConfigFromLocalStorage()
result = changeKeysToCamelCase(result)
return result
}
loadConfigFromLocalStorage () {
const result = {}
return result
}
async loadConfigFromNativeStore () {
const result = await ipcRenderer.invoke('get-app-config')
return result
}
}
可以看到最终获取配置的逻辑落到ipcRenderer.invoke('get-app-config')
。ipcRenderer
相当于是渲染进程里进程间(与Main
主进程)通信的handle
,这里相当于是向主进程invoke
了一个get-app-config
事件。在主进程端的ipcMain
可以注册这个事件的监听,然后返回对应的数据。
ipcRenderer
和ipcMain
的通信,可以查看这两个文档:
到这里就暂停,看下主进程的启动,主进程index.js
会启用一个Launcher
来开始主进程逻辑
// src/main/index.js
global.launcher = new Launcher()
// src/main/Launcher.js
export default class Launcher extends EventEmitter {
constructor () {
super()
this.url = EMPTY_STRING
this.file = EMPTY_STRING
// 只有一个实例可以运行,通过app.requestSingleInstanceLock()获取
this.makeSingleInstance(() => {
this.init()
})
}
init () {
this.exceptionHandler = new ExceptionHandler()
this.openedAtLogin = is.macOS()
? app.getLoginItemSettings().wasOpenedAtLogin
: false
if (process.argv.length > 1) {
// 场景:网页直接下载文件或者url
this.handleAppLaunchArgv(process.argv)
}
logger.info('[Motrix] openedAtLogin:', this.openedAtLogin)
this.handleAppEvents()
}
handleAppEvents () {
this.handleOpenUrl()
this.handleOpenFile()
this.handelAppReady()
this.handleAppWillQuit()
}
}
主进程启动逻辑最终落到这handleAppEvents
里面四个handler
,分别是如下作用:
handleAppReady
:监听ready
事件,初始化Application
实例(global.application
)并为其注册监听事件;监听activate
事件,打开index
页面handleOpenUrl
:监听open-url
事件,发送url
给Application
handleOpenFile
:监听open-file
事件,发送file
给Application
handleAppWillQuit
:监听will-quit
事件,停止Application
election-app
的一系列事件,可以在这个网站查阅具体作用
接下来看下Application
实例的初始化:
// src/main/Application.js
export default class Application extends EventEmitter {
constructor () {
super()
this.isReady = false
this.init()
}
init () {
// 配置管理
this.configManager = this.initConfigManager()
// 本地化
this.locale = this.configManager.getLocale()
this.localeManager = setupLocaleManager(this.locale)
this.i18n = this.localeManager.getI18n()
// 菜单
this.setupApplicationMenu()
// ? Window
this.initWindowManager()
// ? UPnP
this.initUPnPManager()
// 内部engine与client
this.startEngine()
this.initEngineClient()
// 界面Managers
this.initTouchBarManager()
this.initThemeManager()
this.initTrayManager()
this.initDockManager()
this.autoLaunchManager = new AutoLaunchManager()
this.energyManager = new EnergyManager()
// 更新Manager
this.initUpdaterManager()
// 内部协议Manager
this.initProtocolManager()
// 注册应用操作事件的handlers
this.handleCommands()
// 下载进度事件的event
this.handleEvents()
// on/handle event channels
this.handleIpcMessages()
this.handleIpcInvokes()
this.emit('application:initialized')
}
其他的先不说,在handleIpcInvokes
里面注册了get-app-config
的handler
,逻辑如下:
// src/main/Application.js
export default class Application extends EventEmitter {
handleIpcInvokes () {
ipcMain.handle('get-app-config', async () => {
const systemConfig = this.configManager.getSystemConfig()
const userConfig = this.configManager.getUserConfig()
const result = {
...systemConfig,
...userConfig
}
return result
})
}
}
// src/main/core/ConfigManager.js
export default class ConfigManager {
constructor () {
this.systemConfig = {}
this.userConfig = {}
this.init()
}
init () {
this.initSystemConfig()
this.initUserConfig()
}
initSystemConfig () {
this.systemConfig = new Store({
name: 'system',
defaults: {
'all-proxy': EMPTY_STRING
// 这里省略其他的了
}
})
this.fixSystemConfig()
}
initUserConfig () {
this.userConfig = new Store({
name: 'user',
defaults: {
'all-proxy-backup': EMPTY_STRING,
// 这里省略其他的了
}
})
this.fixUserConfig()
}
}
这里用了electron-store
持久化用户配置,详情参考这个链接
最终给到渲染进程的config
,就是systemConfig
和userConfig
合并的结果,因此可以再转到渲染进程查看init(config)
的逻辑:
//
function init (config) {
if (is.renderer()) {
Vue.use(require('vue-electron'))
}
Vue.http = Vue.prototype.$http = axios
Vue.config.productionTip = false
const { locale } = config
const localeManager = getLocaleManager()
localeManager.changeLanguageByLocale(locale)
Vue.use(VueI18Next)
const i18n = new VueI18Next(localeManager.getI18n())
Vue.use(Element, {
size: 'mini',
i18n: (key, value) => i18n.t(key, value)
})
Vue.use(Msg, Message, {
showClose: true
})
Vue.component('mo-icon', Icon)
const loading = Loading.service({
fullscreen: true,
background: 'rgba(0, 0, 0, 0.1)'
})
sync(store, router)
/* eslint-disable no-new */
global.app = new Vue({
components: { App },
router,
store,
i18n,
template: '<App/>'
}).$mount('#app')
global.app.commands = commands
require('./commands')
global.app.trayWorker = initTrayWorker()
setTimeout(() => {
loading.close()
}, 400)
}
这一段代码主要设置Vue
的内部属性并起了Vue
实例赋予global.app
。在其中,加载了App.vue
中id=app
的页面内容,包括这些:
<template>
<div id="app">
<mo-title-bar
v-if="isRenderer"
:showActions="showWindowActions"
/>
<router-view />
<mo-engine-client
:secret="rpcSecret"
/>
<mo-ipc v-if="isRenderer" />
<mo-dynamic-tray v-if="enableTraySpeedometer" />
</div>
</template>
其中,<router-view />
是实质展示了路由为/
的页面,对应到routers
里面就是@/components/Main
以及其下级的task
的路由组件。其他几个分别是:
mo-title-bar
:顶层的最小化、最大化、退出按钮mo-engine-client
:不渲染界面的组件,实质只有js
逻辑,用于管理下载进度mo-ipc
:不渲染界面的组件,实质只有js
逻辑,用于ipc
mo-dynamic-tray
:下载速度显示组件到了这里,整个app
就启动完成了。