当前位置: 首页 > 工具软件 > Motrix > 使用案例 >

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

潘皓
2023-12-01

近期准备开始做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

// .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

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

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

到这里就暂停,看下主进程的启动,主进程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事件,发送urlApplication
  • handleOpenFile:监听open-file事件,发送fileApplication
  • 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-confighandler,逻辑如下:

// 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)的逻辑:

// 
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的页面内容,包括这些:

<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就启动完成了。

 类似资料: