Electron Tabbed BrowserView

在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)Licon-default.png?t=N2N8https://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。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值