在medium上的标题是,Implementing a tabbed BrowserView electron application. 但是想不出一个合适的中文标题。
总之,这是我在尝试的一个小小的项目,项目的需求是,跨平台桌面程序,两个tab,一个tab是browser,另一个tab是pdf reader。需要能够做到进程间通讯,可以用代码控制browser的行为。
之前尝试过pyqt5,pyside6,flutter,都有各自的限制。于是最终使用了electron。个人很不喜欢这个解决方案,因为memory footprint非常大。但是自带browser,确实没有比它更合适的框架了。
项目使用electron builder。并用到了它的一个boilerplate:maxieluan/electron-vite-vue: 🥳 Really simple Electron + Vite + Vue boilerplate. Plus tweaks. (github.com)Lhttps://github.com/maxieluan/electron-vite-vue链接是我的一个fork,在原有的模板基础上加上了tailwindcss支持。
稍微解释一下这个模板,文件夹结构如下:
electron/
|--> main/
|--> index.ts
|--> preload/
|--> index.ts
src/
|--> App.vue
|--> Components/
|--> Assets/
dist/
release/
main.ts内容如下:
import { app, BrowserWindow, shell, ipcMain } from 'electron'
import { release } from 'node:os'
import { join } from 'node:path'
// The built directory structure
//
// ├─┬ dist-electron
// │ ├─┬ main
// │ │ └── index.js > Electron-Main
// │ └─┬ preload
// │ └── index.js > Preload-Scripts
// ├─┬ dist
// │ └── index.html > Electron-Renderer
//
process.env.DIST_ELECTRON = join(__dirname, '..')
process.env.DIST = join(process.env.DIST_ELECTRON, '../dist')
process.env.PUBLIC = process.env.VITE_DEV_SERVER_URL
? join(process.env.DIST_ELECTRON, '../public')
: process.env.DIST
// Disable GPU Acceleration for Windows 7
if (release().startsWith('6.1')) app.disableHardwareAcceleration()
// Set application name for Windows 10+ notifications
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
}
// Remove electron security warnings
// This warning only shows in development mode
// Read more on https://www.electronjs.org/docs/latest/tutorial/security
// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
let win: BrowserWindow | null = null
// Here, you can also use other preload
const preload = join(__dirname, '../preload/index.js')
const url = process.env.VITE_DEV_SERVER_URL
const indexHtml = join(process.env.DIST, 'index.html')
async function createWindow() {
win = new BrowserWindow({
title: 'Main window',
icon: join(process.env.PUBLIC, 'favicon.ico'),
webPreferences: {
preload,
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
// Consider using contextBridge.exposeInMainWorld
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
nodeIntegration: true,
contextIsolation: false,
},
})
if (process.env.VITE_DEV_SERVER_URL) { // electron-vite-vue#298
win.loadURL(url)
// Open devTool if the app is not packaged
win.webContents.openDevTools()
} else {
win.loadFile(indexHtml)
}
// Test actively push message to the Electron-Renderer
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', new Date().toLocaleString())
})
// Make all links open with the browser, not with the application
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https:')) shell.openExternal(url)
return { action: 'deny' }
})
// win.webContents.on('will-navigate', (event, url) => { }) #344
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
win = null
if (process.platform !== 'darwin') app.quit()
})
app.on('second-instance', () => {
if (win) {
// Focus on the main window if the user tried to open another
if (win.isMinimized()) win.restore()
win.focus()
}
})
app.on('activate', () => {
const allWindows = BrowserWindow.getAllWindows()
if (allWindows.length) {
allWindows[0].focus()
} else {
createWindow()
}
})
// New window example arg: new windows url
ipcMain.handle('open-win', (_, arg) => {
const childWindow = new BrowserWindow({
webPreferences: {
preload,
nodeIntegration: true,
contextIsolation: false,
},
})
if (process.env.VITE_DEV_SERVER_URL) {
childWindow.loadURL(`${url}#${arg}`)
} else {
childWindow.loadFile(indexHtml, { hash: arg })
}
})
main process运行一个BrowserWindow,BrowserWindow加载一个vite project,vite project内容即为src文件夹内的代码——一个vue SPA。视乎运行环境,如在开发环境,则加载vite url,如在生产环境,则加载vite build出来的html。
package.json中有如下
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build && electron-builder",
"preview": "vite preview"
},
Build过程是先build vite到dist文件夹,再build electron项目。
我的项目需要两个webview,一个用来加载某个网站,另一个用来加载pdf文件。我需要一个tab bar来切换这两个view。
electron推荐使用browserview代替webview。
Electron有两个process。main process和 renderer process。其中,main process里可以访问BrowserView api,但renderer process不能。src文件夹里的所有内容,vue project,都只能运行在一个BrowserView里,他们运行在renderer process里。因此,不可能在src文件夹里实现tab和多BrowserView切换的功能。
这部分功能在main process,即main/index.ts中实现。
我们看到,main process中有一个BrowserWindow实例。我们为他实例化三个可以切换的BrowserView。一个for vue vite project,一个for 网页,一个用来实现tab bar。
const views = {
default: null,
internet: null,
tabbar: null,
}
async function createWindow() {
const defaultView = new BrowserView({
webPreferences: {
preload,
nodeIntegration: true,
contextIsolation: false,
},
})
const internetView = new BrowserView(
{
webPreferences: {
preload,
nodeIntegration: true,
contextIsolation: false,
},
},
)
// Tab bar doesn't need a preload. Otherwise, it takes a long time to load
const tabbarView = new BrowserView({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
})
views.default = defaultView
views.internet = internetView
views.tabbar = tabbarView
win = new BrowserWindow({
title: 'Main window',
icon: join(process.env.PUBLIC, 'favicon.ico'),
webPreferences: {
preload,
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
// Consider using contextBridge.exposeInMainWorld
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
nodeIntegration: true,
contextIsolation: false,
},
})
if (process.env.VITE_DEV_SERVER_URL) { // electron-vite-vue#298
tabbarView.webContents.loadFile(join(process.env.PUBLIC, 'tabbar.html'))
defaultView.webContents.loadURL(url)
} else {
tabbarView.webContents.loadFile(join(process.env.DIST, 'tabbar.html'))
defaultView.webContents.loadFile(indexHtml)
}
defaultView.webContents.on('did-finish-load', () => {
defaultView?.webContents.send('main-process-message', new Date().toLocaleString())
})
defaultView.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https:')) shell.openExternal(url)
return { action: 'deny' }
})
function handleResize() {
const { width, height } = win.getBounds()
const viewsHeight = height - 50
tabbarView.setBounds({x: 0, y: viewsHeight - 50, width, height: 50})
defaultView.setBounds({x: 0, y: 0, width, height: viewsHeight - 50})
internetView.setBounds({x: 0, y: 0, width, height: viewsHeight - 50})
}
handleResize()
win.on('resize', () => {
handleResize()
})
}
这段代码里,我们实现了三个BrowserView实例,并存储到一个global object里。其中,defaultView加载默认vite项目,tabbar加载一个简单的tab html,如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Tab Bar</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
.tab {
display: inline-block;
padding: 8px;
margin: 0;
border: none;
cursor: pointer;
background-color: #eee;
}
.tab.active {
background-color: #ccc;
}
</style>
</head>
<body>
<button class="tab active" id="default-tab">Default</button>
<button class="tab" id="internet-tab">Internet</button>
</body>
<script>
const { ipcRenderer } = require('electron');
const defaultTabButton = document.querySelector('#default-tab');
const internetTabButton = document.querySelector('#internet-tab');
defaultTabButton.addEventListener('click', () => {
ipcRenderer.send('change-view', 'default');
defaultTabButton.classList.add('active');
internetTabButton.classList.remove('active');
});
internetTabButton.addEventListener('click', () => {
ipcRenderer.send('change-view', 'internet');
internetTabButton.classList.add('active');
defaultTabButton.classList.remove('active');
});
ipcRenderer.send('change-view', 'nothing');
</script>
</html>
tabbar.html放在public文件夹里,会在vite build的过程中copy到dist,参与electron的构建。我们注意到,点击tabbar中的按钮,会通过ipcRenderer发送change-view事件给main process。我们需要在main/index.ts中处理这个事件:
ipcMain.on('change-view', (event, viewName) => {
const view = views[viewName]
if (view) {
// don't remove tabbar
win?.removeBrowserView(views.default)
win?.removeBrowserView(views.internet)
win?.addBrowserView(view)
}
})
一点需要注意的事情:
1. 在BrowserWindow里手动添加BrowserView后,electron不会自动渲染他们。因此运行出来的窗口是空白的,但是调整窗口大小后,会触发渲染,窗口会正常显示UI。我没有理解这里的运行机制,因此用了一个很丑陋的fix。
app.whenReady().then(createWindow).then(() => {
// manually trigger a resize, otherwise, the views will not be rendered. Don't know why
win.setSize(win.getSize()[0], win.getSize()[1] + 1)
win?.addBrowserView(views.default)
win?.addBrowserView(views.tabbar)
})
在创建BrowserWindow后,手动触发一个resize。
2. tabbar不可以加载preload。首先,这是一个非常简单的UI,不需要任何preload。更重要的是,如果加载preload,程序会用好几秒的时间来加载它,用户体验极差。
// Tab bar doesn't need a preload. Otherwise, it takes a long time to load
const tabbarView = new BrowserView({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
})
效果见gif。