我正在参加「掘金·启航计划」
本节主要知识点是 Electron 中的 webview 标签,学完之后,会带领大家用 Vue + Electron 实现一个简单浏览器,效果如下:
webview 标签的使用
webview 标签是 Electron 提供的一个类似于 web 中 iframe 的容器,可以嵌入另外的页面:
< p > 下面使用 webview 标签嵌入了百度网站 </ p > < webview src = "https://www.baidu.com" > </ webview > </ body >那么展示效果如下:
默认情况下,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 标签:
<iframe src="./embed.html"></iframe>
</body>
那么打印出来的结果是:
current frame: index.html
children: ["embed.html"]
parent null
current frame: embed.html
children: []
parent index.html
可以看到 embed.html
是 index.html
的子 Frame,index.html
是 embed.html
的父 Frame。
使用 webview 标签
但是如果把 iframe 换成 webview 标签:
<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 面板,可以看到这种层次结构:
如果把 iframe 都换成 webview 标签,即:
index.html 里面通过 webview 嵌入了 webview.html
webview.html 里面通过 webview 嵌入了 iframe.html
iframe.html 里面通过 webview 嵌入了 iframe-inside.html
打开控制台 Application 面板,层次结构就消失了:
这就验证了官方文档中的那句话:
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/')
可以发现顺利启动起来了:
改造 App.vue ,编写简易浏览器的页面,用的是传统的 Vue 语法和 CSS 样式,这里不做过多赘述:
<template>
<div class="toolbar">
<div :class="['back', { active: canGoBack }]" @click="goBack"><</div>
<div :class="['forward', { active: canGoForward }]" @click="goForward">></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 标签被 Vue 编译时做了特殊处理了,于是搜索了一下 Vue 的源码,在 packages/runtime-dom/types/jsx.d.ts
中找到了 webview 标签,跟 div、span 这种标签放在了一起:
于是在 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 中加载的网站可以顺利展示出来了:
不过这里有个细节,如果在模板里面 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
到这里,一个简单的浏览器的雏形就有了,不过目前有个比较严重的问题,所有 target 为 _blank
的 a 标签点击都没反应:
这是因为 webview 默认不允许打开新窗口,需要设置 allowpopups 属性才行:
<webview ref="webview" class="webview" src="about:blank" allowpopups></webview>
效果如下:
webview 的功能非常强大,建议大家先阅读一遍官方文档,初步了解 webview 可以提供哪些能力,具体 API 的使用细节可以等到后面用到的时候再研究。