介绍
最近在编写一个Electron的应用,业务是把某些第三方Web站点关进“Electron的小黑屋”,然后进出小黑屋做一些拦截操作(注意:客户是了解应用的,所以不是恶意拦截),使用BrowserWindow加载网站实现了相关业务。
但是有些代理商,手上有多个商户,使用BrowserWindow只能加载一个商户,变更商户需要进行反复的切换,使用很是不便。
提出:我们需要类似google浏览器一样的tabs,进行多商户操作。
技术分析
- 在Electron中,如何实现tabs?
- 是否能够做到cookies、storage等信息隔离?
- 如何操作每个tab中的Dom对象?如何拦截url的请求?
多Tabs样例源码:electron-multi-tabs
效果图:
上述的源码,是技术预言的样例代码,只作为参考。实际的某些操作并没有包含。
如何实现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-containers、electron-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隔离信息的操作
- 通过 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存在后...
- 上述代码可以看出是在主进程中运行的。在主进程中,无法直接获取相关的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开 发的新人,所以每走一步都会记录下来相关的一些心得。在这里跟大家一起共勉。大家可以进群讨论: