likes
comments
collection
share

Electron 中的 webview 实战 —— 手写简易浏览器

作者站长头像
站长
· 阅读数 15

本节主要知识点是 Electron 中的 webview 标签,学完之后,会带领大家用 Vue + Electron 实现一个简单浏览器,效果如下:

Electron 中的 webview 实战 —— 手写简易浏览器

webview 标签的使用

webview 标签是 Electron 提供的一个类似于 web 中 iframe 的容器,可以嵌入另外的页面:

<body>
  <p>下面使用 webview 标签嵌入了百度网站</p>
  <webview src="https://www.baidu.com"></webview>
</body>

那么展示效果如下:

Electron 中的 webview 实战 —— 手写简易浏览器

默认情况下,Electron 是不启用 webview 标签的,需要在创建 Window 的时候在 webPreferences 里面设置 webviewTag 为 true 才行:

win = new BrowserWindow({
  width: 800,
  height: 600,
  webPreferences: {
    webviewTag: true, // 需要添加此行
  },
})

webview 与 iframe 的区别

webview 是 chromium 浏览器中的概念,它跟 iframe 是非常类似的,但又不一样,绝大部分开发者搞不懂它们之间的区别,这里为大家详细介绍。首先官方对 webview 标签的解释为:

For the most part, Blink code will be able to treat a similar to an . However, there is one important difference: the parent frame of an is the document that contains the element, while the root frame of a has no parent and is itself a main frame. It will likely live in a separate frame tree.

其实已经说得很明白了,webview 和 iframe 的不同点在于:

  • iframe 的父 frame 是包含 iframe 标签的页面
  • webview 是没有父 frame 的,自己本身就是一个 mainFrame

这是什么意思呢?接下来通过两个案例来进一步说明:

简单案例

我们写个简单的案例来验证一下,首先在主进程里面写:

let win
app.whenReady().then(() => {
  win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: { webviewTag: true },
  })
  win.loadFile(path.join(__dirname, '../renderer/index.html'))
  setTimeout(printFrames, 2000)
})

应用启动后,延迟两秒打印当前页面的所有 frames 信息(用 framesInSubtree 方法):

function printFrames() {
  const frames = win.webContents.mainFrame.framesInSubtree
  const print = (frame) => frame && frame.url && path.basename(frame.url)
  frames.forEach((it) => {
    console.log(`current frame: ${print(it)}`)
    console.log(`   children: ${JSON.stringify(it.frames.map((it) => print(it)))}`)
    console.log(`   parent`, print(it.parent), '\n')
  })
}

使用 iframe 标签

如果 index.html 页面用的是 iframe 标签:

<body>
  <iframe src="./embed.html"></iframe>
</body>

那么打印出来的结果是:

current frame: index.html
   children: ["embed.html"]
   parent null

current frame: embed.html
   children: []
   parent index.html

可以看到 embed.htmlindex.html 的子 Frame,index.htmlembed.html 的父 Frame。

使用 webview 标签

但是如果把 iframe 换成 webview 标签:

<body>
  <webview src="./embed.html"></webview>
</body>

那么打印出来的结果是:

current frame: index.html
   children: []
   parent null 

current frame: embed.html
   children: []
   parent null 

也就是说,embed.html 和 index.html 不存在父子关系,这两个 Frame 是彼此独立的。

嵌套案例

为了更清晰的演示,构造下面的嵌套案例:

  • index.html 里面通过 iframe 嵌入了 webview.html
  • webview.html 里面通过 iframe 嵌入了 iframe.html
  • iframe.html 里面通过 iframe 嵌入了 iframe-inside.html

打开控制台 Application 面板,可以看到这种层次结构:

Electron 中的 webview 实战 —— 手写简易浏览器

如果把 iframe 都换成 webview 标签,即:

  • index.html 里面通过 webview 嵌入了 webview.html
  • webview.html 里面通过 webview 嵌入了 iframe.html
  • iframe.html 里面通过 webview 嵌入了 iframe-inside.html

打开控制台 Application 面板,层次结构就消失了:

Electron 中的 webview 实战 —— 手写简易浏览器

这就验证了官方文档中的那句话:

has no parent and is itself a main frame. It will likely live in a separate frame tree.

webview 标签没有父 Frame,它会创建独立的 frame 树(并且有自己的 webContents 对象,这个概念后续会专门介绍)。

实现简易浏览器

webview 标签可创建一个浏览器沙箱环境来加载第三方网站,Electron 提供了丰富的 API 能够拦截各种事件,因此非常适合今天开发简易浏览器的场景。

首先新建 browser-simple/main 目录用于存放主进程文件,这里使用 pnpm + vite + vue 进行前端页面的开发,可以进入 browser-simple 路径下执行下面的命令:

$ pnpm create vite

在交互式命令行环境中选择 Vue 框架和 JavaScript 语言,项目名称叫 renderer,那么最终会自动生成项目文件:

browser-simple
├── main
│   └── index.js
└── renderer
    ├── README.md
    ├── index.html
    ├── package.json
    ├── pnpm-lock.yaml
    ├── src
    │   ├── App.vue
    │   ├── main.js
    │   └── style.css
    └── vite.config.js

进入 renderer 目录下启动前端项目:

$ pnpm run dev

VITE v4.0.4  ready in 741 ms

➜  Local:   http://127.0.0.1:5173/
➜  Network: use --host to expose
➜  press h to show help

编写 main/index.js 主进程文件,加载 Vue 项目页面:

mainWindow = new BrowserWindow({
  width: 1200,
  height: 1000,
  webPreferences: {
    webviewTag: true,
  },
})
mainWindow.loadURL('http://127.0.0.1:5173/')

可以发现顺利启动起来了:

Electron 中的 webview 实战 —— 手写简易浏览器

改造 App.vue ,编写简易浏览器的页面,用的是传统的 Vue 语法和 CSS 样式,这里不做过多赘述:

<template>
  <div>
    <div class="toolbar">
      <div :class="['back', { active: canGoBack }]" @click="goBack">&lt;</div>
      <div :class="['forward', { active: canGoForward }]" @click="goForward">&gt;</div>
      <input v-model="url" placeholder="Please enter the url" @keydown.enter="go" />
      <div class="go" @click="go">Go</div>
    </div>
    <webview ref="webview" class="webview" src="about:blank"></webview>
  </div>
</template>

可以看到,DOM 结构是非常简单的,顶部工具条放前进/后退按钮,网址输入框和前往按钮,下面就是在 webview 标签。

但是当启动项目之后,控制台发现 webview 标签竟然变成了注释:

Electron 中的 webview 实战 —— 手写简易浏览器

非常奇怪,怀疑是 Electron 的 webview 标签被 Vue 编译时做了特殊处理了,于是搜索了一下 Vue 的源码,在 packages/runtime-dom/types/jsx.d.ts 中找到了 webview 标签,跟 div、span 这种标签放在了一起:

Electron 中的 webview 实战 —— 手写简易浏览器

于是在 Vue 文档的 web-components 章节中找到了 isCustomElement 选项,可以通过该选项设置自定义元素,不让 Vue 进行编译处理:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag === 'webview',
        },
      },
    }),
  ],
})

重启之后,发现 webview 标签可以顺利在 DOM 中显示了,接下来就是具体的逻辑实现了,最关键的就是:点击 Go 按钮之后,让 webview 加载 input 输入框中的网站,这里用到了 webview 的 loadURL 方法:

<script setup>
import { ref } from 'vue'
const url = ref('')
const webview = ref(null)
function go() {
  webview.value.loadURL(url.value)
}
</script>

此时在浏览器中输入网址,然后点击 Go 按钮(或者键盘回车),可以发现 webview 中加载的网站可以顺利展示出来了:

Electron 中的 webview 实战 —— 手写简易浏览器

不过这里有个细节,如果在模板里面 webview 不加 src 属性的话,会出问题的,调用 loadURL 的时候报错:

node:electron/js2c/isolated_bundle:17 Uncaught Error: The WebView must be attached to the DOM and the dom-ready event emitted before this method can be called.
    at WebViewElement.getWebContentsId (node:electron/js2c/isolated_bundle:17:695)
    at e.<computed> [as loadURL] (node:electron/js2c/isolated_bundle:21:3433)

所以如果不想让 webview 默认加载某个网站,可以初始化为 about:blank或者 data:text/plain

那如何实现前进和后退功能呢?这就需要用到 webview 标签的事件能力了,Electron 提供了非常多的事件,例如:

  • dom-ready
  • page-title-updated
  • page-favicon-updated
  • did-start-loading
  • did-stop-loading
  • did-start-navigation
  • did-navigate
  • ...

具体 API 的含义和使用方法可以参考官方文档,在此结合前进后退功能,展示部分 API 的使用:

<script setup>
import { ref, onMounted } from 'vue'
const url = ref('')
const webview = ref(null)
const webviewDomReady = ref(false)
const canGoBack = ref(false)
const canGoForward = ref(false)

onMounted(() => {
  const el = webview.value
  if (!el) return
  el.addEventListener('dom-ready', () => {
    webviewDomReady.value = true
    updateNavigationState()
  })
  el.addEventListener('did-start-loading', (event) => {
    updateNavigationState()
  })
  el.addEventListener('did-stop-loading', (event) => {
    updateNavigationState()
  })
  el.addEventListener('did-start-navigation', (event) => {
    updateNavigationState()
    if (event.url.startsWith('http')) {
      url.value = event.url
    }
  })
})

const updateNavigationState = () => {
  if (!webview.value) return
  if (!webviewDomReady.value) return
  canGoBack.value = webview.value.canGoBack()
  canGoForward.value = webview.value.canGoForward()
}


const goBack = () => {
  const el = webview.value
  if (el.canGoBack()) el.goBack()
}

const goForward = () => {
  const el = webview.value
  if (el.canGoForward()) el.goForward()
}
</script>

上面的代码并不复杂,主要是监听了几个事件,然后绑定相关变量,从而更新按钮状态,里面有几个关键点:

  • 大部分的 webview 方法需要在 dom-ready 之后才能调用
  • did-start-navigation 事件中可以拿到跳转的 URL

Electron 中的 webview 实战 —— 手写简易浏览器

到这里,一个简单的浏览器的雏形就有了,不过目前有个比较严重的问题,所有 target 为 _blank 的 a 标签点击都没反应:

Electron 中的 webview 实战 —— 手写简易浏览器

这是因为 webview 默认不允许打开新窗口,需要设置 allowpopups 属性才行:

<webview ref="webview" class="webview" src="about:blank" allowpopups></webview>

效果如下:

Electron 中的 webview 实战 —— 手写简易浏览器

webview 的功能非常强大,建议大家先阅读一遍官方文档,初步了解 webview 可以提供哪些能力,具体 API 的使用细节可以等到后面用到的时候再研究。