【极客日常】通过motrix启动逻辑初探electron的项目结构

近期准备开始做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主进程的逻辑相当于是modelrenderer渲染进程的逻辑相当于是view,而至于controller,可以通过electron支持下的两个进程的ipc事件处理机制来呈现。这一点,我们直接看启动逻辑就能明白。

运行yarn run dev,会启动.election-vue/dev-runner.js,其中会先初始化renderermain,然后再启动electron

1
2
3
4
5
6
7
8
9
10
11
12
// .election-vue/dev-runner.js
function init () {
greeting()

Promise.all([startRenderer(), startMain()])
.then(() => {
startElectron()
})
.catch(err => {
console.error(err)
})
}

startRendererstartMain中会读取js配置的程序入口,编译后运行。两个进程的入口entry分别是:

  • 渲染进程:src/pages/index/main.js
  • 主进程:src/main/index.dev.js

首先看渲染进程,运行的入口在这里:

1
2
3
4
5
6
7
8
store.dispatch('preference/fetchPreference')
.then((config) => {
console.info('[Motrix] load preference:', config)
init(config)
})
.catch((err) => {
alert(err)
})

首先会通过preference/fetchPreference这个action来获得应用配置,然后调用init函数启动界面。先看获取配置的逻辑:

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
// 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可以注册这个事件的监听,然后返回对应的数据。
ipcRendereripcMain的通信,可以查看这两个文档:

  • ipcRenderer模块
  • ipcMain模块

到这里就暂停,看下主进程的启动,主进程index.js会启用一个Launcher来开始主进程逻辑

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
// 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事件,发送urlApplication
  • handleOpenFile:监听open-file事件,发送fileApplication
  • handleAppWillQuit:监听will-quit事件,停止Application

election-app的一系列事件,可以在这个网站查阅具体作用
接下来看下Application实例的初始化:

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
// 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-confighandler,逻辑如下:

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
// 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,就是systemConfiguserConfig合并的结果,因此可以再转到渲染进程查看init(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
// 
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.vueid=app的页面内容,包括这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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就启动完成了。

版权声明
本文为博客HiKariのTechLab原创文章,转载请标明出处,谢谢~~~