前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一文搞懂Electron的四种视图容器和它们之间的IPC通信机制

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

原创
作者头像
WendyGrandOrder
发布2022-12-20 21:03:26
7.3K0
发布2022-12-20 21:03:26
举报
文章被收录于专栏:RESTART POiNTERRESTART POiNTER

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通信方式。

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、Electron的视图容器层级
  • 1.webContents
    • 2. frame
    • 二、基础窗口BrowserWindow
      • 2. 两个BrowserWindow之间的通信
        • (1) 使用 ipcRenderer.sendTo
        • (2) 使用MessagePort
    • 三、独立视图容器BrowserView
      • 1. BrowserView和主进程通信
        • 2. BrowserView和宿主页面通信
        • 四、内嵌DOM标签<Iframe>
          • 1. Iframe和宿主页面通信
            • 2. Iframe和主进程通信
            • 五、内嵌视图容器 <webview> Tag
              • 1. <webview>和宿主窗口通信
                • 2. <webview>和主进程通信
                • 六、ipc通信的封装模式实践
                • 七、总结
                相关产品与服务
                容器服务
                腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档