一文搞懂Electron的四种视图容器和它们之间的IPC通信机制

2022-12-20 21:03:26 浏览数 (1)

Electron作为一种基于JS语言搭建的桌面框架,其基础视图容器是包含了Chromium内核的窗口,称为BrowserWindow。对于更复杂的项目,如果需要在窗口内部嵌入第三方业务的页面,则有BrowserView、webView Tag和Iframe三种方案可供选择。

这四类视图容器的实现原理各不相同,和主进程、宿主窗口以及其它兄弟窗口的通信方式也各不相同。官方文档中(截止Electron20版本)的描述较为散乱,本文集中梳理它们各自的特性以及通信方式,并给出推荐的封装模式,以供各位开发者参考。

一、Electron的视图容器层级

1.webContents

Electron的渲染进程是基于Chromium搭建的,下图是Chromium官方文档中关于视图容器的层级划分

其中和Electron关系最紧密的概念是Webcontents,它相当于一个独立的渲染上下文,在Chrome里,每增加一个tab就会创建一个独立的WebContents,它们可以加载各自不同的url,彼此互相独立。

在Electron里,当我们创建一个基础窗口对象,就能够通过它的引用拿到WebContents。

代码语言:javascript复制
const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
    },
  })

win.loadFile('index.html')
console.log(win.webContents)

它是一个EventEmitter对象,可以通过它来发送跨进程消息,监听其它进程发来的事件,这是Electron内建ipc通信的基础。

此外,Electron还给每个webcontents对象提供了一个上下文隔离(Isolated Context)的预加载环境,并且在其中执行开发者指定的preload脚本。它会在渲染器加载页面之前运行, 可以同时访问 DOM 接口和 Node.js 环境,并且可以通过 contextBridge 接口将特权接口暴露给渲染器。

因为Electron封装的跨进程通信对象ipcMain和ipcRenderer都是基于nodejs环境的api,而出于安全性考虑,通常需要在生产环境中关闭渲染进程的node权限(设置窗口的nodeIntegration为false),以防恶意脚本破坏操作系统。但这样一来,主进程和渲染进程的通信就会变得麻烦。

Preload脚本给我们提供了一种折中方案。我们可以在隔离上下文里把通信通道建立完毕,然后把有限的接口暴露到渲染上下文,供业务使用。并且在暴露接口时做一些参数检查,过滤的工作,避免非法脚本到达主进程。

所以,尽管官方提供的一些demo会把ipcRenderer直接引入渲染进程,但在生产环境下,我们要尽量避免这样做。包括下文的所有demo代码里,ipcRenderer都应该是经过preload检查过滤后的对象,而非原始的node对象。

代码语言:javascript复制
// 暴露渲染进程访问的对象,也可以换一个别名
contextBridge.exposeInMainWorld('ipcRenderer', {
      send: async (channel: string, ...args: any) => {
        // 可以在这里做一些业务上的合法性检查和过滤
        ipcRenderer.send(channel, ...args);
      },
      invoke: async (channel: string, ...args: any) => {
      // 可以在这里做一些业务上的合法性检查和过滤
        return await ipcRenderer.invoke(channel, ...args);
      },
});

2. frame

在webcontets之上还运行着若干frame,我们可以在主进程遍历出一个窗口的所有frame对象,如果某个窗口打开了devtool,或者加载了iframe标签,frame对象都会新增。而每个webcontents都有一个mainFrame,就是窗口直接加载的主体对象。

代码语言:javascript复制
    this.win.webContents.on('did-frame-finish-load',(event, isMainFrame, frameProcessId, frameRoutingId)=>{
     // 每个frame加载完毕后都会触发这个事件
      console.log("aaaaaa did-frame-finish-load", isMainFrame, frameProcessId, frameRoutingId);
       // 遍历窗口所有frame对象,比对routingId,可以找出当前的frame并打印其基础信息
      this.win.webContents.mainFrame.frames.forEach(frame => {
        if(frame.routingId === frameRoutingId){
          const url = new URL(frame.url)
          console.log(“当前frame加载的url ", url);
        }
      })
    })

frame 也有一系列的属性和生命周期钩子,但他并不是EventEmitter,无法通过它和其它进程通信。如果需要跨frame交换消息,需要采取迂回的方案,我们将在后文加以说明。

二、基础窗口BrowserWindow

BrowserWindow是Electron里最基本的视口单位,通过主进程创建和调度,一个BrowserWindow等同于一个独立的Chrome进程。

1. BrowserWindow和主进程的通信

主进程和窗体之间通信几乎是所有业务的刚需,Electron官方提供了基于IpcMain和IpcRenderer的封装,鉴于官方文档已经描述得非常清晰,此处不再罗列代码,只用图总结一下。

从窗口调用主进程分为send和invoke两种模式,前者是单向发送,适用于执行特定操作不关心返回值的场景,后者则会返回一个结果,相当于一来一回,并且是异步的。官方也提供了同步调用接口sendSync,但会造成进程阻塞,实际业务中尽量不要用。

从主进程到窗口,则要借助webcontents的send方法来发送,官方只提供了单向调用的封装,可能是因为主进程是运行在后台的,并没有视图,所以通常情况下不存在由主进程主动发起,并依赖渲染进程返回的场景,但如果实际业务中确实有需求,也可以在send的时候带上唯一标识ID,由渲染进程处理完毕后,携带id发起send,通过两次通信模拟出同样的效果。

2. 两个BrowserWindow之间的通信

由于ipc通信的基础是webcontents,而两个独立的窗口之间无法直接交换渲染上下文的信息,所以需要借助主进程的帮助。如果请求次数少,每次都由主进程转发也问题不大。但如果请求次数多,考虑到多窗口应用的性能问题,最好能够建立窗口对窗口的直接通信。

有两种方式可以实现:

(1) 使用 ipcRenderer.sendTo

该方法支持传入一个webContentsId作为发送目标,发送到特定的渲染上下文,通过它我们可以实现窗口对窗口的直接通信,但首先需要通过主进程来获取另一个窗口的webContentsId。

代码语言:javascript复制
// A窗口
const targetId = await ipcRenderer.invoke(“GetIWindowBId”) //主进程需要通过ipcMain监听该事件并返回窗口B的id
ipcRenderer.sendTo(targetId,"CrossWindow”,”窗口A发给窗口B”)

// B窗口
ipcRenderer.on("CrossWindow",(event,...params)=>{
  console.log("CrossWindow Request from ",event.senderId,...params) // B窗口可以把senderId记录下来,并通过它给A窗口发送消息
  ipcRenderer.sendTo(event.senderId,"CrossWindow”,”窗口B发给窗口A”)
})

一旦两个窗口都获悉对方的webContentsId,后续就可以自由地发送消息了(事件名可以任意指定)

(2) 使用MessagePort

MessagePort并不是Electron提供的能力,而是基于MDN的web标准API,这意味着它可以在渲染进程直接创建。同时Electron提供了nodejs侧的实现,所以它也能在主进程创建。

代码语言:javascript复制
// 在渲染进程
const messageChannel = new MessageChannel();
console.log(messageChannel.port1);
console.log(messageChannel.port2);

// 在主进程
import { MessageChannelMain } from 'electron';
const messageChannel = new MessageChannelMain();
console.log(messageChannel.port1);
console.log(messageChannel.port2);

两侧创建的port对象,在能力上是对称的,由主进程创建的对象,可以通过

win.webContents.postMessage('port', null, [port1])

方法发送给BrowserWindow,在窗口侧需要监听同名事件

ipcRenderer.on('port', e => {})

拿到e.ports[0]对象并保存下来。

主进程只需要把port分发给A和B窗口,两个窗口之后各自持有port1和port2之后,就可以通过他们进行通信了。

细节代码参见官方文档: https://www.electronjs.org/docs/latest/tutorial/message-ports

看起来MessagePort似乎不如sendTo方便,对于简单的窗口通信,一般来说sendTo就足够用了。

但它和ipcRenderer.sendTo的最大区别在于,后者是基于WebContents的,所以只有具备webContents的对象才能使用,但messagePort是web标准,还适用于webWorker或者iframe,这意味着我们可以直接建立A窗口/主进程和B窗口的worker或iframe的通信链路。在特定业务场景下,这是非常方便的能力。在后面介绍iframe的部分,会给出实践。

三、独立视图容器BrowserView

BrowserView也是由主进程创建的独立视图容器,可以内嵌在其它BrowserWindow里,加载另一个url,有点类似于Iframe,但比iframe工作在更底层,拥有独立的webContents。

原理上来说,创建一个BrowserView相当于在Chrome浏览器里增加一个Tab。一个窗口可以内嵌多个BrowserView,创建时可以指定相对宿主窗口的偏移坐标。在需要给业务窗口嵌入第三方子页面的时候,使用BrowserView可以保证子页面的独立性,避免影响到宿主页面的运行。

代码语言:javascript复制
const win = new BrowserWindow({ width: 800, height: 600 })
const view = new BrowserView()
win.setBrowserView(view)
view.setBounds({ x: 0, y: 0, width: 300, height: 300 }) // 指定view相对于宿主窗口的位置
view.webContents.loadURL('https://electronjs.org') //view也有独立的webContents对象

但BrowserView也有局限性,由于它是主进程创建并“贴”在宿主窗口上的,所以它的渲染环境完全独立,游离在宿主页面的dom树之外,意味着一旦创建,宿主页面的其它元素都无法通过设置z-index的方式透显在它上面。

1. BrowserView和主进程通信

因为BrowserView有独立的webcontents,并且可以挂载proload脚本,所以它在ipc通信层面的地位和BrowserWindow完全一样,我们可以通过同样的方式,直接在主进程和它交换消息,无需经过宿主转发。不同的BrowserView之间也可以通过sendTo来互相通信。

2. BrowserView和宿主页面通信

正因为BrowserView的上下文是完全独立的,所以无法直接和宿主页面互通。当它需要和素主页面交换消息的时候,同样需要使用窗口对窗口的方式,交换webContentsid或者MessagePort。这是它和传统内嵌页面iframe的最大的区别。

四、内嵌DOM标签<Iframe>

Iframe的概念相信每个web开发都很熟悉,它和Electron框架无关,是浏览器dom标准里自带的内嵌标签,也是最为基础的内嵌方案。在Electron里,iframe没有webContents,而是以宿主页面contents下面的一个frame的形式存在。

1. Iframe和宿主页面通信

和宿主页面的通信方式,就是我们熟悉的postMessage,完全的web标准,这里不再赘述。

2. Iframe和主进程通信

因为iframe没有独立的webContents,无法直接和主进程建立连接,那么最容易想到的方式,就是通过宿主页面转发,先使用postMessage把所有请求发到外层,再通过ipcRenderer发到主进程,拿到结果之后再发回给iframe。

这样固然可以,但实现起来还是颇为繁琐,而且每个请求都要二次通信,在请求较多的情况下也会影响性能。

前文提到messageChannel的特性在渲染侧和node侧都有对称的实现,那么我们可以把宿主页面作为“中介”,只进行一次端口交换,后续让主进程和iframe直接经由端口来通信。

代码语言:javascript复制
// 主进程
this.win.once('ready-to-show', () => {
        const { port1, port2 } = new MessageChannelMain()
        this.win.webContents.postMessage('sendPort', null, [port1])
        port2.start();//注意,这里一定要调用一次start,否则消息会一直pending而不触发回调
	// 使用port2给iframe发消息,也可以接收iframe发来的消息
        port2.on('message',(event)=>{
          console.log("主进程收到iframe发来的消息",event.data);
        })
        setTimeout(()=>{
          port2.postMessage("主进程发给iframe的消息 ");
        },5000)
    })



// 宿主页面
ipcRenderer.on('sendPort', event => {
  const port2 = event.ports[0]
  const iframe = document.querySelector("iframe");
  // 注意,如果父窗口和iframe跨域了,第二个参数要设成*
  iframe.contentWindow.postMessage("sendPortToIframe", '*', [port2]);
})


// iframe内部
let messagePort;
  window.addEventListener("message", function (event) {
    messagePort = event.ports[0];
    // 监听宿主发来的消息,把port存下来,就可以直接和主进程通信了
    messagePort.onmessage = function (event) {
      console.log('iframe 收到主进程发来的消息',event)
    };
    // 用 port给主进程发消息
    messagePort.postMessage('iframe给主进程发消息');
  });

可以看出,连接建立过程中有三个角色参与,但宿主页面只需要转发一次port,后续就可以抽身而出,不必再关心iframe和主进程的通信了。

经过笔者实践,上述代码基于Electron20版本可以正常运行。只不过iframe创建的时机不一定是宿主窗口的ready-to-show,也有可能是后续切特定路由的时候,那么相应的,new messageChannel的时机也要做出调整,整体而言,流程还是有些繁琐。

而且由于iframe没有类似preload的预加载脚本,这些初始化的代码需要侵入到子业务代码里完成,跨业务的开发协作起来也是比较麻烦的。

五、内嵌视图容器 <webview> Tag

通过前文可以看出,BrowserView和iframe各有各的局限,前者独立于宿主的文档流之外,无法跟随宿主页面的排版规则,也没办法覆盖一些全局的弹窗和浮层,使用上受到很大限制。后者没有独立的运行环境,和其它进程建立通信比较麻烦,而且容易影响到宿主页面的运行。

<webview> Tag折中了二者的机制,它和<iframe>Tag一样,可以嵌入宿主页面的文档流里,但却像BrowserView似的拥有独立的WebContents,并且支持挂载私有的proload脚本。

代码语言:javascript复制
<!DOCTYPE html>
<html lang="">
  <body>
    <div id="drag-area">webview测试</div>
      <webview
    id="testWebview"
    src="file:///xxxx/embedpage.html?subBusinessType=someBusiness"
    style="width: 400px; height: 480px; position: absolute; top: 0; left: 0; z-index: 1000"
    preload="file:///xxxx/testpreload.js"
  ></webview>
  </body>
  <script>
      const webview = document.getElementById('testWebview');
  </script>
</html>

我们通过dom query api拿到的webview对象,会被Electron劫持并替换成一个shadow Dom,它是一个HTMLElement,但同时也具备EmittEvent的功能,可以把它当作一个webContents来使用。

注意,Electron里的<webview>tag是基于chrome app的标准开发的,由于后者已经被Chrome抛弃,所以Electron开发者也无法保证后续版本的可用性。

但因为它实在太过方便,在依赖版本可控的情况下,还是值得一试的。如果未来真的废弃了,也可以把它迁移回iframe,作为降级替代方案。

1. <webview>和宿主窗口通信

因为选中的<webview>对象具有send方法,等同于ipcRenderer.send,使用它可以直接从宿主窗口抛送事件到webview内部,在内部需要通过ipcRenderer.on来监听。

代码语言:javascript复制
// 从宿主到webview

// 宿主侧
webview.send("HostToWebview","hello webview")

// webview侧
ipcRenderer.on("HostToWebview",(event,...params)=>{
   console.log("from host:",...params) })
});

反之,在Webview内部,可以通过ipcRenderer.sendToHost发送事件,在宿主页面通过给webview对象增加ipc-message的事件监听器来接收处理

代码语言:javascript复制
// 从webview到宿主

//  webview侧
ipcRenderer.sendToHost("WebviewToHost","hello host")

// 宿主侧
webview.addEventListener("ipc-message", (event) => {
   console.log("from webview:", event.channel, event.args); 
});

和上面提到的原则一样,webview一侧调用ipcRenderer要限定在proeload里面,避免直接把原生对象暴露到渲染上下文。

2. <webview>和主进程通信

我们知道<webvw>Tag是有独立webConents的,意味着主进程可以直接和它通信,但这里有个特殊之处,它是由宿主窗口在渲染进程里创建的,所以当它创建的时候,主进程并不知道它的存在,需要要由它先发送一个通知。

注意和iframe不同的是,通知的过程可以在webview自己的preload里进行,无需宿主页面转发。

代码语言:javascript复制
// webview侧(通常是在preload里)

// 发送注册请求,subBusinessType可以是一个标识业务类型的字符串,方便主进程区分,也可以省略。
ipcRenderer.invoke("webviewRegister", subBusinessType)
// 监听主进程发来的事件
ipcRenderer.on(“MainToWebview”,, (event, ...params) => {
    console.log("收到住进程的事件”,…params)
})

// 主进程

// 处理注册请求
ipcMain.handle('webviewRegister', (event, subBusinessType:SubBusinessType) => {
 // 通过event拿到processId和frameId,作为后续发送事件的标识。
 // 注意,之所以需要processId,是因为webview和宿主页面跨域的情况下,二者是运行在不同进程里的,需要通过[processId, frameId]二元对来标识,不可省略。 
  console.log('webview注册subBussiness类型', subBusinessType, event.processId, event.frameId); 
  const processId = event.processId; 
  const frameId = event.frameId; // 拿到sender(webview的webContents对象)并且进行发送。 
  event.sender.sendToFrame([processId, frameId],“MainToWebview”,“helloWebview”); 
})

注意到这其中的神奇之处了么?webview的webContents对象可以直接通过事件event的sender属性获取,无需通过宿主的win对象来获得。

如此一来,<webview>就和窗体解藕了,当我们引入一些第三方子业务的时候,主进程不用关心具体是哪个窗口里嵌入了<webview>标签,只需要关心业务本身,做出对应的处理。iframe方案就无法做到这一点。

<webview>还有一个优势,注册的过程可以在preload脚本里执行,而preload脚本由父业务维护。子业务代码加载之前,我们就可以建立好和主进程之间的通道,并且把子业务需要调用的接口,封装成类似于jsApi的形式,暴露到渲染上下文,而无需入侵子业务的任何代码,还可以考虑不同子业务的公共接口复用,从架构来说比iframe要优雅得多。

整体通讯机制如图所示

六、ipc通信的封装模式实践

上文讲到的通信方式,在实际业务中,还需要进行一定的封装才会更便捷。笔者基于最近参与的新版QQ项目,分享介绍一些窗口和主进程之间的ipc通道封装经验。

这里以采用<webview>Tag嵌入的业务窗口和主进程的通信为例(其他的容器对象原理类似),封装的原则主要有两个:

1. 隔离执行环境

前文也强调过,为了应用的安全性(避免被脚本注入攻击等),我们要禁止业务直接用到原生的ipc对象,为此我们需要把执行环境在封装层面隔离开,避免直接暴露给业务代码。

2. 隔离底层细节

业务侧通常不关心通道建立的细节,只希望能够获取数据,执行命令,我们希望把ipc通信封装得尽可能简单简便,方便业务侧理解和使用。

首先我们需要明确需求,当复数个业务存在的情况下,哪些是通用的,哪些是业务私有的,我们使用基类容纳通用的部分,子类继承基类提供私有的部分。

代码语言:javascript复制
// 主进程
class baseApiHelper{
    public handlers = {
	// 假设写日志是一个通用的api
        writelog(ctx:IpcWebviewCtx, logType:string, ...info:any){
            loggerService.log('[ ' ctx.subBusinessType ’ ]’,…info);
        },
 }}

class SomeBusinessApiHelper extends BaseApiHelper{
    public handlers = {
        ...super.handlers, // 继承自基类的通用api
        openFile:async (ctx:IpcWebviewCtx,...params:any)=>{
	    // 省略具体的实现
            return 'mock openFile done';
        }
    }
}

type IpcWebviewCtx = {
    subBusinessType:SubBusinessType,
    processId:number,
    frameId:number,
}

其中IpcWebviewCtx是我们定义的上下文类型,包括子业务的类型标识,发送方的processId和frameId,方便handler函数针对不同的业务做一些特殊处理。

每个Helper都是一个单例,可以使用一些依赖注入框架来管理,也可以简单地new出来并且导出,总之当成单例使用即可。

接下来我们实现一个通用的注册事件,在app启动之后就执行绑定,后续任何子业务<webview>被创建,都会触发注册流程。

为了方便管理,我们把子业务标识和它的发送方id拼装起来,作为该容器私有的channelName,并为它注册监听函数,取得调用的方法名,添加上下文之后分发给hanlder函数处理。

代码语言:javascript复制
    // 主进程
    // 处理全局的webview注册事件
    ipcMain.handle('webviewRegisterSubBussiness', (event, subBusinessType:SubBusinessType) => {
        console.log('webview注册subBussiness类型', subBusinessType, event.processId, event.frameId);
        const processId = event.processId;
        const frameId = event.frameId;

        const channelName = 'ipc-webview-' subBusinessType '-' frameId;

        // 取出对应业务的Helper
        const helper:any = ipcWebviewContainer.get(subBusinessType);

        // 处理来自特定webview的invoke方法,添加上下文之后分配给对应的helper
        ipcMain.handle(channelName, async (event: IpcMainInvokeEvent, eventName: string, ...payload)=>{
            if(helper.handlers[eventName]){
                return await helper.handlers[eventName]({
                    subBusinessType,
                    processId:processId,
                    frameId:frameId
                } as IpcWebviewCtx,
                ...payload);
            }
            return Promise.reject("ipc hanlder not found")
        }) 

        // 处理来自特定webview的send方法,添加上下文之后分配给对应的helper
        ipcMain.on(channelName, (event: IpcMainInvokeEvent, eventName: string, ...payload)=>{
            if(helper.handlers[eventName]){
                helper.handlers[eventName]({
                    subBusinessType,
                    processId:processId,
                    frameId:frameId
                } as IpcWebviewCtx,
                ...payload);
            }else{
                console.warn("ipc hanlder not found")
            }
        })
        return Promise.resolve(true)
    });

而在渲染进程一侧,preload脚本启动后,我们就发送webviewRegisterSubBussiness事件给主进程,并且把调用器暴露到渲染上下文。业务里直接调用ipcApi.invoke或者ipcApi.send,就能执行到对应的方法

代码语言:javascript复制
// webview preload

// 从url里取出页面的业务类型(或者任意其他方式)
const subBusinessType = parseQuery(location.search).subBusinessType;
const channelName = 'ipc-webview-' subBusinessType '-' frameId;

let registerIpcPromiseReslover = ()=>{};
const registerIpcPromise = new Promise((resolve) => {
    registerIpcPromiseReslover = resolve;
  });
ipcRenderer.invoke("webviewRegisterSubBussiness", subBusinessType).then(res=>{
    registerIpcPromiseReslover();
});

contextBridge.exposeInMainWorld('ipcApi',{
    invoke: async (cmd,...params)=>{
        await registerIpcPromise;
        console.log("call ipcApi invoke ",cmd)
        return await ipcRenderer.invoke(channelName,cmd,...params); 
    },
    send: async (cmd,...params)=>{
        await registerIpcPromise;
        console.log("call ipcApi send ",cmd)
        ipcRenderer.send(channelName,cmd,...params); 
    },
})

子业务需要调用的时候,直接使用window对象上的ipcApi就可以了

代码语言:javascript复制
// 子业务
window.ipcApi.send('writelog','info', ‘hello IPC’);
const res = await window.ipcApi.invoke('openFile’, somefileName)

注意,这里创建了一个registerIpcPromise,这是因为注册事件到达主进程是异步的,主进程为业务的私有channel注册处理器也需要一些时间,那么在极端情况下,如果业务代码刚启动就调用了api,有可能主进程还没有完成注册,此时可能会调用失败。为了避免情况,我们用一个promise对象让invoke和send请求等一等,注册完成之后再扭转,保证所有的调用都能够被正确处理。

接下来再处理由主进程抛送的通知。

抛送通知给子业务,触发点一定是在某个主进程模块里,我们提供一个触发器给该模块,让它通过子业务类型拿到对应的触发器,触发事件。

我们把触发器也封装在baseApiHelper里,并且用一个Map来维护,这是为了兼容一个子业务有多个实例的情况(当然实际业务场景下,这种情况应该不会很多,可以酌情简化)

代码语言:javascript复制
// 主进程
class baseApiHelper{
    private emiiterMap = new Map<string,Function>;
    public handlers = {
        ……
    }
    public addEmtter(key:string,emitFunc:Function){
        this.emiiterMap.set(key, emitFunc);
    }
    public removeEmtter(key:string){
        this.emiiterMap.delete(key);
    }
    public emitEvent(eventName:string, ...params:any){
        this.emiiterMap.forEach((emitFunc)=>{
            emitFunc(eventName,...params);
        })
    }
}

在子业务注册的时候,我们收集发送对象sender,放进emiiterMap里(还是上面的demo代码,省略重复部分)

代码语言:javascript复制
    // 主进程 
    // 处理全局的webview注册事件
    ipcMain.handle('webviewRegisterSubBussiness', (event, subBusinessType:SubBusinessType) => {
        console.log('webview注册subBussiness类型', subBusinessType, event.processId, event.frameId);
        const processId = event.processId;
        const frameId = event.frameId;
        const channelName = 'ipc-webview-' subBusinessType '-' frameId;

        const helper:any = ipcWebviewContainer.get(subBusinessType);

        // 处理来自特定webview的invoke方法,添加上下文之后分配给对应的helper
        ……

        // 处理来自特定webview的send方法,添加上下文之后分配给对应的helper
        ……

        // 添加temitter到helper,业务可以通过helper给特定webview发送事件
        helper.addEmtter(processId '-' frameId, (eventName:string, ...params:any)=>{
            event.sender.sendToFrame([processId, frameId],channelName, eventName, ...params);
        })

        return Promise.resolve(200)
    });

而在子业务一侧,去注册对sender事件的监听,并且依次触发业务的监听器就可以了。

代码语言:javascript复制
// webview preload
const eventCbMap = {}
ipcRenderer.on(channelName, (event, eventName, ...params) => {
    eventCbMap[eventName]?.forEach(cb=>{
        cb(...params);
    })
})

contextBridge.exposeInMainWorld('ipcApi',{
    on:(eventName, cb)=>{
        console.log("页面注册监听", eventName)
        if(!eventCbMap[eventName]){
            eventCbMap[eventName] = []
        }
        eventCbMap[eventName].push(cb);
    },
    ……
}

这样一来,通道就建立好了,需要抛事件的模块里,只要拿到对应helper,就可以触发emitter了,业务也可以通过ipcApi.on来绑定监听器,收到通知。

代码语言:javascript复制
// 主进程任意业务模块
const someBusinessApiHelper = ipcWebviewContainer.get<SomeBusinessApiHelper>(SubBusinessType.SomeBusiness);
someBusinessApiHelper.emitEvent('helloIPC',`主进程发给webview`); 

当然注册过的事件都是需要提供卸载逻辑的,可以在注册函数末尾返回一个disposer对象,用于注销监听器。

主进程的也emitter也需要在<webview>生命周期结束后予以卸载,可以选择在webview的beforeunload事件里给主进程发送一个卸载请求,并清理对应helper上的emitter对象,具体的逻辑这里不再赘述。

这样,对子业务的ipc封装就完成了,只需要约定需要哪些能力,由开发在主进程去实现,子业务在自己的代码里就可以通过ipcApi去调用,而无需关心其中的细节。

最后一点,因为<webview> Tag是可以通过渲染进程的脚本创建的,其中的preload属性又指向一个本地脚本,为了安全性,我们应该拦截'will-attach-webview’事件,检查其中的参数,规定只允许挂载我们自己的脚本,避免第三方脚本恶意篡改。也可以对webview里的一些行为做出限制,比如禁止重定向等等,具体可以参阅Electron官方文档。

七、总结

本文介绍了Electron里的四种视图容器的特点以及各自的ipc通信方式。

其中三种子视图的作用接近,都可以用来内嵌第三方业务,实际使用时,可以根据业务场景,选择最合适的方案。

0 人点赞