Electron开发的应用,如何实现多tabs的模式

2,808 阅读5分钟

介绍

最近在编写一个Electron的应用,业务是把某些第三方Web站点关进“Electron的小黑屋”,然后进出小黑屋做一些拦截操作(注意:客户是了解应用的,所以不是恶意拦截),使用BrowserWindow加载网站实现了相关业务。

但是有些代理商,手上有多个商户,使用BrowserWindow只能加载一个商户,变更商户需要进行反复的切换,使用很是不便。

提出:我们需要类似google浏览器一样的tabs,进行多商户操作。

技术分析

  • 在Electron中,如何实现tabs?
  • 是否能够做到cookies、storage等信息隔离?
  • 如何操作每个tab中的Dom对象?如何拦截url的请求?

多Tabs样例源码:electron-multi-tabs

效果图: image.png

上述的源码,是技术预言的样例代码,只作为参考。实际的某些操作并没有包含。

如何实现tabs

在查询资料后了解到,有提供插件Electron-Tabs实现了相关功能。但是发现该插件停更且被弃用。原因是Electron官方提供了BrowserView来实现相关的tab功能。所以选择BrowserView来实现。

cookies、storage等信息如何隔离

查询资料了解到,设置如下代码:

 const view = new BrowserView({
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      partition: `persist:${viewId}`, // 实现存储隔离
      preload: path.join(__dirname, 'preload.js'), // 添加 preload 脚本
    },
  })

其中 partition的配置,实现了相关内容的隔离。

如何操作每个tab中的Dom对象

这里需要了解,Electron中的主进程和渲染进程的概念,从而了解相关代码在何时执行的,从而保证业务的执行。

做比对分析

在开始开发前,我从GitHub上查看了下,有如下两个被我纳入选项electron-tab-containerselectron-tabs。但是看了后,决定自己编写。

electron-tab-containers看着很不错,问题是他加载了一个本地需要启动的web站点,我加载一个三方站点,肯定没这个需求。其次我懒,不想改动别的东西,也希望简单点,所以就自己写了一个。

electron-tabs被抛弃了,所以就不用了。

electron-multi-tabs 下载后,直接npm i ,然后npm run start(这是一个最简的多tabs的Demo,没有任何业务代码)。就能看到效果。实际应用肯定没有那么简单,需要做代码的打包,项目的build(build时需要考虑win7、mac、win10等系统)等,具体可以加群细聊。

多Tab实现的原理

实现相关功能时,需要了解这个多Tab的实现原理,看代码:

// 全局的变量参数
let mainWindow;  // 主进程的唯一窗口,所有tab都被它加载
let views = new Map();  // 所有的view 对象
let activeViewId = null; // 活动的view对象

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  });

  mainWindow.loadFile('index.html');

  // 创建第一个标签页
  createNewTab();
}

这是被加载的html

  <div class="tabs-container">
    <button class="scroll-button left" id="scroll-left">‹</button>
    <div class="tabs-scroll">
      <div id="tabs">
        <!-- 将新建标签按钮移到这里,作为 tabs 的子元素 -->
        <button id="new-tab-btn">+</button>
      </div>
    </div>
    <button class="scroll-button right" id="scroll-right">›</button>
  </div>
// 创建一个tab
function createNewTab() {
  const viewId = Date.now().toString();
  const view = new BrowserView({
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      partition: `persist:${viewId}`, // 实现存储隔离
      preload: path.join(__dirname, 'preload.js') // 添加 preload 脚本
    }
  });

  mainWindow.addBrowserView(view);
  views.set(viewId, view);
  
  const [width, height] = mainWindow.getContentSize();
  view.setBounds({ x: 0, y: 40, width, height: height - 40 });

  view.webContents.loadURL('https://www.baidu.com');
  setActiveTab(viewId);
  return viewId;
}
// 重置可视窗口
function setActiveTab(viewId) {
  views.forEach((view, id) => {
    if (id === viewId) {
      view.setBounds({ x: 0, y: 40, width: mainWindow.getBounds().width, height: mainWindow.getBounds().height - 40 });
    } else {
      view.setBounds({ x: 0, y: 40, width: 0, height: 0 });
    }
  });
  activeViewId = viewId;
}

setActiveTab函数,就是把激活的BrowserView显示出来,其他的BrowserView的高和宽都是0,则可视窗口不可见。其中height - 40,就是给顶部的多tab,留出高度显示。

直白讲:view.setBounds来控制那个view显示,那些隐藏。所以需要控制tabs的数量,不能无限的加载,内存扛不住的。

cookies、storage隔离信息的操作

  1. 通过 partition 把信息隔离后,如何操作指定tab的cookies? 看代码:
    // 使用正确的 BrowserView 实例获取 cookies
    currentView.webContents.session.cookies
      .get({})
      .then((cookieList) => {
        // 处理localStorage变化
        data.channelAuth = JSON.stringify(cookieList)
        console.log(`all data:`, data)
        if (data) {
          sendLoginInfo(data)
        }
      })
      .catch((error) => {
        console.error('get cookies fail:', error)
      })

通过获取当前的BrowserView对象,然后webContents.session.cookies获取相关的cookies对象。这个cookies对象不受域名限制,会把站点下,所有的cookies都获取。所以获取这个的时机,需要根据业务决定。如登录完成后,或者storage中某个key存在后...

  1. 上述代码可以看出是在主进程中运行的。在主进程中,无法直接获取相关的storage的信息。需要渲染进程户获取某些storage,通讯告知主进程。看代码:

view.webContents.executeJavaScript 模式:

// 在页面中执行下列代码
view.webContents
        .executeJavaScript(
          `
          // 等待DOM完全加载
          function initLoginPage() {
              // 通知主进程,storage变更了
              window.electron.ipcRenderer.send('localStorage-changed', {
                channelAcct: 'xxxxx',
                cooperationChannel: 'xxxxx',
                channelExt: '',
                root_life_account_id: localStorage.getItem('xxxxx'),
                channelAuth: ''
              });
          `
        )
        .then((result) => {
          console.log('登录页面初始化成功:', result)
        })
        .catch((error) => {
          console.error('登录页面初始化失败:', error)
        })

上面这个模式,是不同的view之间隔离的,开发人员可以根据不同的View编写不通的处理。

preload.js模式:

// 修改 changeLocalStorage 函数
function changeLocalStorage() {
  if (localStorage.getItem('xxxxx') !== null) {
    ipcRenderer.send('localStorage-changed', {
      channelAcct: 'XXXX',
      cooperationChannel:  'XXXX',
      channelExt: '',
      root_life_account_id: localStorage.getItem('xxxxx'),
      channelAuth: '',
    })
  }
}

上述模式,无法知道那个View与主进程交互,所以是所有的view都会被通知,具体如何的控制,还需要根据业务具体控制。

tab中的Dom对象操作实践

如果是使用BrowserWindow加载站点,则直接在preload.js 操作网站的Dom就可以了,因为是在渲染进程中执行的。但是多tabs后,BrowserWindow加载的是我们自己写的index.html。而被我们加载的第三方网站,在BrowserView中执行。

所以操作Dom的使用样例如下:

// 在页面中执行下列代码
view.webContents
        .executeJavaScript(
          `
          // 等待DOM完全加载
          // 这里编写相关的JS操作。。。。。
          `
        )
        .then((result) => {
          console.log('操作成功:', result)
        })
        .catch((error) => {
          console.error('操作失败失败:', error)
        })

对的,也是使用 webContents.executeJavaScript 来执行JS。

tab中请求的拦截

拦截的处理分:请求前的拦截、响应后的拦截。看代码:


  // 添加请求头拦截器
  view.webContents.session.webRequest.onBeforeSendHeaders(
    {
      urls: ['*://*/*'],
    },
    (details, callback) => {
      if (details.url.includes('url')) {
        // 保存当前请求头
        const headers = details.requestHeaders

        // 获取请求的头信息,我们模拟请求,则不会被鉴权拦截
      }
      callback({ requestHeaders: details.requestHeaders })
    }
  )

  // 某个url加载后的拦截。并通知主进程该干活了
  view.webContents.session.webRequest.onHeadersReceived(
    {
      urls: ['*://*/*'], // 拦截所有URL,你也可以设置特定的URL模式
    },
    (details, callback) => {
      // 加载完成左侧菜单,发送消息
      if (details.url.includes(menusUrl)) {
        console.log('onHeadersReceived', details.url)
        // 发送事件给主进程
        mainWindow.webContents.send('home-menu-load', details.url)
      }
      // 调用 callback 函数传递修改后的 details 对象
      callback({ responseHeaders: details.responseHeaders })
    }
  )

可以看见,这些拦截也是基于View对象进行的,所以也是tab分离的。

如何在Electron中发起请求

根据上面几章的描述,我们知道,先要拦截相关的请求,并获取请求头。然后,使用webContents.executeJavaScript 来执行JS代码。 样例如下:


  // 添加请求头拦截器
  view.webContents.session.webRequest.onBeforeSendHeaders(
    {
      urls: ['*://*/*'],
    },
    (details, callback) => {
      if (details.url.includes(url)) {
        // 保存当前请求头
        const headers = details.requestHeaders

        // 使用相同的请求头获取数据
        view.webContents
          .executeJavaScript(
            `
            fetch('${details.url}', {
              method: 'GET',
              headers: ${JSON.stringify(headers)},
              credentials: 'include'
            })
            .then(response => response.json())
            .then(data => {
              if (data && data.data && data.data.account_name) {
                window.electron.ipcRenderer.send('update-tab-title', {
                  viewId: '${view.webContents.id}',
                  title: data.data.account_name
                });
              }
              return data;
            })
            .catch(error => {
              console.error('请求失败:', error);
            });
          `
          )
          .catch((error) => {
            console.error('执行脚本失败:', error)
          })
      }
      callback({ requestHeaders: details.requestHeaders })
    }
  )

上述给出了一个样例,通过站点提供的某个url,获取的信息,设置我们tab的名称。其中拦截了某个url请求的请求头。

总结

我也是一个Electron开 发的新人,所以每走一步都会记录下来相关的一些心得。在这里跟大家一起共勉。大家可以进群讨论:

image.png