聊聊 electron 中实现多窗口之间的通信方式

12,047 阅读5分钟

前言

由于自己在使用 electron 开发一个类似微信的桌面客户端App,开发这个App的目的纯粹是为了总结一下自己对于 electron 的积累总结吧,先贴出项目的源码地址:github.com/1111mp/elec… 。目前整个项目的基础架构都实现差不多了,包括整个应用的数据持久化(使用的是 sqlite,因为我比较喜欢用 sequelize)、多语言、主题设置、多窗口之间的通信方式 和 带有底部上滑动画的弹窗通知 等,总得来说项目的质量还可以吧,有兴趣的同学可以提前去看一下代码。第一次写这种文章,望谅解。然后废话不多说,直奔主题吧。

主/渲染进程

electron 的主进程和渲染进程:ipcMain, ipcRenderer 我想这部分应该不用介绍了,就贴出官方文档链接了,不了解的同学可以先去了解一下,然后再接着往下看。

通信方式的实现

基于主/渲染进程的事件收发机制来实现多窗口之间的通信

这种通信方式的原理就是基于主进程 ipcMain 的 on 方法向主进程注册一个事件,然后在窗口页面中通过 send 方法向渲染进程发送事件,然后触发到主进程中之前注册过的事件,事件名都是各自方法的第一个参数,用起来其实就跟 EventBus 一样,以下是官方的例子:

// 在主进程中.
const { ipcMain } = require('electron')
ipcMain.on('asynchronous-message', (event, arg) => {
  console.log(arg) // prints "ping"
  event.reply('asynchronous-reply', 'pong')
})

ipcMain.on('synchronous-message', (event, arg) => {
  console.log(arg) // prints "ping"
  event.returnValue = 'pong'
})

//在渲染器进程 (网页) 中。
const { ipcRenderer } = require('electron')
console.log(ipcRenderer.sendSync('synchronous-message', 'ping')) // prints "pong"

ipcRenderer.on('asynchronous-reply', (event, arg) => {
  console.log(arg) // prints "pong"
})
ipcRenderer.send('asynchronous-message', 'ping')

然后我们来实现多窗口的通信方式,比如我们有两个窗口: A 和 B,然后我们在 B 窗口获取 A 窗口的数据,以下(伪代码):

// 在主进程中.
const { BrowserWindow, ipcMain } = require('electron');

let A = new BrowserWindow({...options}); // options为一些new BrowserWindow的配置
A.loadURL('A.html');

let B = new BrowserWindow({...options}); // options为一些new BrowserWindow的配置
B.loadURL('A.html');

function getDataFromMainWindow(name, callback) {
    ipcMain.once('get-data-success', (_event, error, value) => {
    	callback(error, value)
    });
    A.webContents.send('get-data');
}

// 主进程相当于一个中间层 做转发
ipcMain.on('get-data', event => {
    if (A && A.webContents) {
    	getDataFromA(name, (error, value) => {
          const contents = event.sender;
          if (contents.isDestroyed()) {
          return;
        }
        contents.send('get-success-data', error, value);
        });  
    }
});

// A 页面中
const { ipcRenderer } = require('electron');

// 注册 get-data 事件,name为需要获取的数据的名称
ipcRenderer.on('get-data', (event, name) => {
    let data = getDatafromA(name); // 通过name获取到A页面中的数据 伪代码
    ipcRenderer.send('get-data-success', data);
})

// B 页面中
const { ipcRenderer } = require('electron');

const getData = (name, a) => {
  return new Promise((resolve, reject) => {
    ipcRenderer.once('get-success-data', (event, value) => {
      if (error) {
        return reject(error);
      }
      return resolve(value);
	});
	ipcRenderer.send('get-data');
  })
}

总结一下就是:如果 B 想要获取 A 的数据,B先想主线程发送一个事件get-data,然后主进程收到这个事件之后,想A发送一个事件get-data, 然后页面A中收到这个事件之后,就开始通过参数name去获取到自己页面中对应的数据,然后向主进程发送事件get-success-data,并把对应的数据以参数形式传递过去,然后主进程接收到A发送的事件之后,拿着A给的数据又向B发送一个事件get-success-data,然后B收到这个事件之后就能拿到自己想要的数据了。

到这里你肯定会想,我就想拿个数据还要这么麻烦,而且这个过程不耗时吗,这样的话我的应用不就会表现的很卡。。。我看过很多别人开源的应用,好像基本上都是这么实现的,但是我受不了。

在此基础上的改进

刚刚说了一种通信方式,但是整个过程比较繁琐,性能不能保证,那么有没有办法来优化呢,是有的: 还是上面那个场景,页面 B 获取页面 A 的数据,当页面 A 加载渲染展示的时候,我们可以通过一个事件把一些需要共享的数据提前发送到主进程,然后主进程通过一个变量储存,然后后续如果 B 页面想要获取 A 的对应数据的时候,只需要 B 发送一个事件给主进程,然后主进程直接把自己已经储存的数据给返回给 B就行了。这种方式机制没变,但是从过程线来看,整个过程缩短了 1/2,也属于一种优化,代码就不贴了,大家应该都懂。

但是接下来有个问题了,如果 A 页面的数据有变化了怎么办,B获取到的数据不还是之前旧的数据吗,要解决这个问题,那么肯定需要维护一套页面 A 和主进程数据的同步机制,又是一个坑。 就没有直接点的办法吗,答案是肯定有的。

通过 webContents 的 executeJavaScript 方法实现通信

executeJavaScript 的文档链接:www.electronjs.org/docs/api/we… 这个 api 的用处大致就是可以在主进程中让对应的 webContent 去执行一段 js 脚本。这样一来不就跟移动端混合开发一样了吗,就是 jsBridge 那一套东西,关于jsBridge原理可以参考这个文章 JSBridge的原理,这里就不过多介绍了。当然我们所有需要共享的数据还是放在window对象下,好操作。那么接下来还是那个场景:页面 B 获取页面A的数据:

这时候我们可以这么实现了(伪代码)

// 主进程中
const { ipcMain } = require('electron');

/** 从A的webContent获取挂载在全局window对象上的对应key的数据 */  
async function getDataFromA(keys: string[]) {
  let script = '{';
  if (keys.length === 0) {
  	script = `${keys[0]}}`;
  } else {
  	keys.forEach((key: string, index: number) => {
      if (index === keys.length - 1) {
      	script += `${key}}`;
      } else {
      	script += `${key},`;
      }
    });
  }
  // 这里为什么这么拼 js 脚本 是因为在执行js脚本的时候会把对应的字符串当作变量
  // `   
  //   let res = {}
  //   ${keys}.forEach(key => {
  //     res[key] = window[key]
  //   })   
  //   Promise.resolve(res)
  // `
  // 这种写法执行的时候会报错    
  if (A && A.webContents) {
  	return A.webContents.executeJavaScript(`Promise.resolve(${script});`).then((data) => {
      return data;
    }).catch((err) => {
   	  throw new Error(err);
    });
  } else {
  	throw new Error('Cannot find the mainWindow!');    
  }
}

// 同步方法 支持同时获取多个key的数据
ipcMain.on('get-data', async (event, name) => {
    const data = await mainProcess.getDataFromMainWindow(keys);
    event.returnValue = data;
});

// 异步方法 支持同时获取多个key的数据
ipcMain.handle('get-data-async', async (event, name) => {
    const data = await mainProcess.getDataFromMainWindow(keys);    return Promise.resolve(data);
})

然后在页面 B 中这样就可以获取到数据了

const { ipcRenderer } = require('electron');

let data = ipcRenderer.send('get-data', 'theme')
console.log(data)
// or
ipcRenderer.invoke('get-data', ['theme']).then(res => {
    console.log(res)
})

当然如果 B 页面想要调用 A 页面中的方法也很容易实现:

const { ipcMain } = require('electron');

/** 调用A的webContent全局的funcname方法 */  
function invokeMainWindowFunc({ funcname, args }) {
  if (A && A.webContents) {
  	A.webContents.executeJavaScript(`window.${funcname}(${JSON.stringify(args)})`);
  }
}

// 只需要加上如下代码
ipcMain.on('invoke-func', (event, data) => {
    invokeMainWindowFunc(data)
})

// 然后B中调用
const { ipcRenderer } = require('electron');
ipcRenderer.send('invoke-func', {
    funcname: 'setAppTheme',
    args: {theme: 'dark'}
})

这样实现的话 上面的那些问题都解决了

最后

整个实现的完整的源码都在最开始提到的项目(electron_client)中有,主要在app/main-process/index.ts文件中,不过因为我整个项目在代码层面都做了一层封装,可能需要先了解一些项目代码,才容易看懂。

第一次写文章,比较菜,多多包涵。希望你们看懂了,然后u也有所收获。

如果觉得项目 electron_client 还不错还麻烦 star 一下。谢谢。