Skip to content

界面

如果把一个 GUI 应用比作一个房屋的话,窗口、标题栏和边框就是房屋的墙、房顶和地板,界面就是房屋内各类生活用品,比如桌椅板凳、电视电话等。
要想建设一个功能复杂且用户体验优秀的 GUI 应用,开发者势必会在界面上花费大量的时间和精力。

页面内容

获取 webContents 实例

webContents 是 Electron 的核心模块,负责渲染和控制应用内的 Web 界面。
在一个 Electron 应用中,90% 以上的用户交互逻辑都发生在 webContents 内,它包含大量的事件和实例方法

另外,在 Web 开发过程中还有一个非常重要的元素--iframe 子页面,Electron 也为此提供了访问和控制子页面的对象 webFrame。

如果你已经拥有了一个窗口对象,那么只要通过该窗口对象的 webContent 属性就能获得该窗口的 webContent 实例,代码如下:

1
let webContent = win.webContents

如果你只是想获得当前处于激活状态下的窗口的 webContents 实例,那么你可以用如下方法获取:

1
2
const { webContents } = require('electron')
let webContent = webContents.getFocusedWebContents()

只有窗口处于激活状态时才可以用 getFocusedWebContents 来获取 webContents 实例,未激活状态调用此方法,则将返回 null

在渲染进程 A 中调用 getFocusedWebContents 获取到的可能并不是窗口 A 的 webContents 实例。
而在主进程中,如果在窗口创建之初调用 getFocusedWebContents 往往会得到 null,因为窗口还没激活

在渲染进程中获取当前窗口的 webContents 实例,代码如下:

1
2
const { remote } = require('electron')
let webContent = remote.getCurrentWebContents()

另外,每创建一个窗口,Electron 会为相应的 webContents 设置一个整型的 id,你也可以通过这个 id 来获取窗口的 webContents 实例,此时需要你在创建窗口时记录下 webContents 的 id 以备将来使用(注意这个 id 属性时只读属性,不可为其赋值),代码如下:

1
2
const { webContents } = require('electron')
let webContent = webContents.fromId(yourId)

在万不得已时,你还可以遍历应用内所有的 webContents 对象,根据 webContents 的特征比如网页标题、网页内 dom 内容及网页 URL 的差异,来获取 webContents 实例。
此时要尤其注意,如果 webContents 相应的事件尚未触发,你可能无法获取网页的 dom、url、title 等内容,代码如下:

1
2
3
4
5
6
7
const { webContents } = require('electron')
let webContentArr = webContents.getAllWebContents()
for (let webContent of webContentArr) {
    if (webContent.getURL().includes('baidu')) {
        console.log('do what you want')
    }
}

窗口类型也拥有类似的方法,比如通过 BrowserWindow.getFocusedWindow() 获取当前激活的窗口;
通过 remote.getCurrentWindow() 获取当前渲染进程关联的窗口;
通过 BrowserWindow.fromId(id) 根据窗口 ID 获取窗口实例;
通过 BrowserWindow.getAllWindows() 获取所有窗口。

页面加载事件及触发顺序

webContents 对象可以监听 Web 页面的很多事件。
有时候开发者想在页面加载过程中执行一段业务逻辑,却不知道把业务逻辑注册在哪个事件里合适,下面我们就按一般情况下的事件发生顺序,来讲解 webContents 加载页面的主要事件的含义。

以下排序并非严格意义上的执行顺序,以 page-title-updated 事件为例,如果在页面加载完成后使用 document.title = 'your title'来更改页面的标题,同样也会触发 page-title-updated 事件,在页面尚未加载完成时发生页面跳转行为,也会触发 did-start-loading 事件。

下表所示的事件发生顺序只是按普通网页事件触发顺序描述,不涉及特殊情况

顺序 事件 说明
1 did-start-loging 页面加载过程中的第一个事件。如果该事件在浏览器中发生,那么意味着此时浏览器 tab 页的旋转图标开始旋转,如果页面发生跳转,也会触发该事件
2 page-title-updated 页面标题更新事件,事件处理函数的第二个参数为当前的页面标题
3 dom-ready 页面中的 dom 加载完成时触发
4 did-frame-finish-load 框架加载完成时触发。页面中可能会有多个 iframe,所有该事件可能会被触发多次,当前页面为 mainFrame
5 did-finish-load 当前页面加载完成时触发。注意,此事件在 dis-frame-finish-load 之后触发
6 page-favicon-updated 页面 icon 图标更新时触发
7 did-stop-loading 所有内容加载完成时触发。如果该事件在浏览器中发生,那么意味着此时浏览器 tab 页的旋转图标停止旋转

扩展

dom-ready 事件的背后其实就是网页的 DOMContentLoaded 事件,如果页面中没有 script 标签,那么页面并不会等待 CSS 加载完成才触发 dom-ready 事件,而是一旦页面上的文本内容加载完成即触发 dom-ready 事件。

如果页面中有 script 标签,那么页面需要等待 script 标签加载并解析完成才能触发 dom-ready 事件。

如果页面中有 script 标签,且 script 标签前面还有 CSS 资源,那么页面要等待 script 标签前面的 CSS 资源加载、解析完成,然后 script 标签加载、解析完成,才能触发 dom-ready 事件

如果页面中还存在 iframe,那么此事件的触发并不意味着 iframe 框架已经加载完成了, 它们之间没有直接关系

页面跳转事件

webContents 可以监听一系列与页面跳转有关的事件,其中凡事以 navigate 命令的事件,一般都是由客户端控制的跳转,比如用户点击了某个链接或者 JavaScript 设置了 window.location 属性;
凡是以 redirect 命名的事件,一般都是由服务端控制的跳转,比如服务端响应了 302 跳转命令。我们简单列举一下 webContents 相关的跳转事件,如表 6-2 所示。

表 6-2 Electron 页面跳转事件

事件 说明
will-redirect 当服务端返回了一个 301 或者 302 跳转后,浏览器正准备跳转时,触发该事件。Electron 可以通过 event.preventDefault() 取消此事件,禁止跳转继续执行
did-redirect-navigation 当服务端返回了一个 301 或者 302 跳转后,浏览器开始跳转时,触发该事件。Electron 不能取消此事件。此事件一般发生在 will-redirect 之后
dis-start-navigation 用户点击了某个跳转链接或者 JavaScript 设置了 window.location.href 属性,页面(包含页面内任何一个 frame 子页面)发生页面跳转之时,会触发该事件。此事件一般发生在 will-navigate 之后
will-navigate 用户点击了某个跳转链接或者 JavaScript 设置了 window.location.href 属性,页面发生跳转之前,触发该事件。然而当调用 webContents.loadURL 和 webContents.back 时并不会触发该事件。此外,当更新 window.location.hash 或者用户点击了一个锚点链接时,也并不会触发该事件
did-navigate-in-page 当更新 window.location.hash 或者用户点击了一个锚点链接时,触发该事件
dis-frame-navigate 主页面(主页面 main frame 也是一个frame)和自页面跳转完成时触发。当更新 window.location.hash 或者用户点击了锚点链接时,不会触发该事件
did-navigate 主页面跳转完成时触发该事件(子页面不会)。当更新 window.location.hash 或者用户点击了一个锚点链接时,并不会触发该事件

扩展:
浏览器请求 Web 服务时,Web 服务返回的状态码可以控制浏览器的跳转行为,其中最典型的就是 301 和 302 跳转。
301 跳转代表永久性转移,即你请求的地址已经被永久性地转移到了一个新地址;
302 代表临时性转移,即你的请求的地址被临时性地转移到了一个新地址

单页应用中的页内跳转

用现代前端框架开发的 Web 应用很多时候都是单页应用,这类单页应用往往会使用两种方式完成页内跳转: hash 模式和 history 模式

扩展:
hash 模式时利用 window.location.hash 来完成页内跳转的,跳转后改变 URL 路径,改变后的 URL 路径内包含#,就像一个锚点链接路径一样。

history 模式是利用 window.history.pushState 来完成页内跳转的,跳转后也会改变 URL 路径,但改变后的路径不包含 #,和正常的 URL 路径并无区别。
为了防止服务端误认为它是一个新请求,需要在服务端做相应的配置才能避免出错(让服务端接收到类似请求后返回单页应用的主页地址即可)

这两种模式下会怎样触发浏览器跳转事件呢?我们来做个试验,在 App.vue 的 mounted 钩子函数中注册相关事件,代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const { remote } = require('electron')
let webContent = remote.getCurrentWebContents()
webContent.on('did-start-navigation', _ => {
    console.log('did-start-navigation')
})
webContent.on('will-navigate', _ => {
    console.log('will-navigate')
})
webContent.on('did-navigate-in-page', _ => {
    console.log('did-navigate-in-page')
})

上面代码中,我们注册了 did-start-navigationwill-navigatedid-navigate-in-page 三个事件。
按前文所述,开发者应尽量避免在渲染进程中监听此类事件,此处仅为简化演示代码,不应该用于生产环境中。

然后我们再修改 Vue 项目的路由代码(文件路径为 router/index.js):

1
2
3
4
5
const router = new VueRouter({
    mode: 'history',
    base: process.env.BASE_URL,
    routes
})

上面代码中,我们在创建 VueRouter 实例时传入了一个配置对象,此配置对象的 mode 属性即为控制页内跳转模式的属性,此处我们先将其设置为 history

运行程序,点击跳转链接,发现开发者工具控制台输出如下内容:

1
2
did-start-navigation
did-navigate-in-page

这说明在 history 模式下,页面发生了页内跳转

接着我们修改路由程序把模式改为 hash,再执行同样的逻辑,开发者工具控制台输出的内容,查看 window.location.href 的值,此时它已经变成了 http://localhost:8080/#/about(包含 #,证明已经是通过 hash 模式完成的了)

由此我们可以得出,无论是 history 模式还是 hash 模式都只触发页内跳转事件。
虽然 history 模式下还需要修改服务器配置以满足要求,但客户端 Electron 并不需要做额外设置,即可完成页内跳转

页面缩放

我们可以通过 webContents 的 setZoomFactor 方法来设置页面的缩放比例,此方法接收一个缩放比例的参数,如果参数值大于 1,则放大网页,反之则缩小网页,参数值为 1 时,网页呈现原始状态,即不进行缩放。
你可以通过 getZoomFactor 方法来获取当前网页的缩放比例,代码如下所示:

1
2
3
4
5
const { remote } = require('electron')
let webContents = remote.getCurrentWebContents()
webContents.setZoomFactor(0.3)
let factor = webContents.getZoomFactor()
console.log(factor)  // 输出 0.3

此处还有一种缩放网页的方法,即使用 webContents 的 setZoomLevel 方法来设置网页缩放等级。
此方法接收一个缩放等级参数 level,最终的缩放比例等于 level 乘以 1.2,如果 level 是 0 则不进行缩放。
你可以通过 getZoomLevel 获取当前网页的缩放等级,代码如下;

1
2
3
4
5
const { remote } = require('electron')
let webContents = remote.getCurrentWebContents()
webContents.setZoomLevel(-6)
let level = webContents.getZoomLevel()
console.log(level)   // 输出 -6

默认情况下用户可以通过 Ctrl + Shift + = 快捷键来放大网页,Ctrl + - 快捷键来缩小网页

如果需要控制用户缩放网页的等级范围,你可以通过 setVisualZoomLevelLimits 方法来设置网页的最小和最大缩放等级。
该方法接收两个参数,第一个参数为最小缩放等级,第二个参数为最大缩放等级,此处等级数据与 setZoomLevel 方法参数的含义相同

渲染海量数据元素

在开发 Web 应用时,如果想在一个页面上渲染大量的 Dom 元素,往往会造成页面的卡顿,甚至使页面失去响应(具体 Dom 元素的个数依据客户端电脑配置不同而略有差异)。
Web 前端开发者往往会通过选择专有的技术或调整用户的使用方式来解决这个问题,比如使用 Canvas 技术在一个小画布上渲染大量的元素,或使用分页技术来分步渲染大量的元素等。

扩展:
Canvas 是一种使用 JavaScript 与 HTML 在页面上绘制 2D 图形的技术,与之相应的是使用 XML 描述 2D 图形的 SVG 技术

它们都可以在页面上绘制矩形、图形、多边形、线条、文字,并且提供了操作颜色、路径、滤镜等的支持。两者都可以很方便地嵌入到网页 Dom 树中。

我们可以把 Canvas 理解为一个图像标记,一旦 Canvas 图形绘制完成,浏览器就不会再继续关注它了。如果其图形描述属性发生变化,那么整个场景都需要重新绘制。
Canvas 基于分辨率,绘制的是位图,能够以 png 或 jpg 的格式保存为图片文件,它适合在较小的画布上绘制大量的元素,且有着较强的频繁重绘能力。

SVG 是基于 XML 的,开发者可以为 SVG Dom 树中的任意一个元素附加 JavaScript 事件处理器。
SVG Dom 树中的每个被绘制的图形均被视为对象。如果 SVG 对象的属性发生变化,浏览器能够自动重绘图形。
SVG 绘制的 2D 图形是不依赖分辨率的矢量图形,它适合在大型渲染区域绘制少量的元素,或绘制需要复杂事件交互逻辑的 2D 图形

如果需要在一个较小的画布上绘制大量的元素,建议使用 Canvas 技术,我们使用下列代码在界面上绘制了 100 万个矩形

1
<canvas id="canvas" width="1000" height="800"></canvas>
1
2
3
4
5
6
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
ctx.fillStyle = 'green'
for (let i = 0; i < 1000000; i++) {
    ctx.fillRect(i * 10, i * 10, 10, 10)
}

浏览器在几毫秒内就能完成这 100 万个元素的渲染工作,相当高效。实际上,当被绘制的元素的位置超出画布大小之后绘制工作就不再占用渲染资源了。
假设我们把 Canvas 的大小增加 100 万倍,让画布有足够的空间绘制元素,再次执行以上代码,你会发现 Canvas 就拒绝工作了(在这么大的画布上即使只绘制一个元素,同样也能导致 Canvas 崩溃)

目前并没有什么直接的技术手段来解决这个问题,Web 应用的开发者可能会采用类似分页的技术来分步渲染这些元素,但这个方案有时很难被桌面应用的用户接收,因为传统的桌面应用即使需要展示大量的数据,一般也不会要求用户翻页,基本都是把所有元素放置在一个含有滚动条的窗口中呈现。

扩展:
使用 Canvas 绘制线条时经常会遇到线条变粗且颜色变淡的现象,这是由于使用 Canvas 绘制线条时是从线条的中线向两边伸展完成绘制的。
当你在 2px 的位置绘制一条竖线时,中线在 2px 的位置,左边缘在 1.5px 位置,右边缘在 2.5px 的位置,但实际上计算机的最小像素是 1px,所以 Canvas 会取一个折中的方法,即分别向左右再延伸 0.5px,颜色深度变成原来的一半,所以实际效果看起来变成了宽为 2px 的模糊线条。
绘制横线也有同样的渲染过程。解决这个问题的办法就是绘制线条时宽度直接减小或增加 0.5 个像素。绘制 1 个像素边框的矩形的代码如下;

1
ctx.strokeRect(0.5, 0,5, 100, 100)

另外,推荐大家使用 PixiJS 库(https://github.com/pixijs/pixijs)。这个库对 WebGL API 进行了二次封装,并与 Canvas 技术完美兼容。
因其拥有强大的硬件加速能力,所以性能表现优异,诸如谷歌、YouTube、Adobe 等国际巨头都是这个开源项目的用户。

传统桌面应用开发者自由相应的界面组件来完成这个任务,但对于 Electron 开发者来说,还是需要在前端技术中寻找答案。

一个可行的方案为: 当界面加载完成后,只渲染一屏数据,但为这一屏数据制作一个足够长的滚动条,接着监听滚动条的滚动事件。
当用户向下滚动滚动条时,更新这一屏的数据,把头部的几行内容丢弃掉,尾部创建几行新的内容;
当用户向上滚动滚动条时,把尾部的几行数据丢弃掉,为头部增加几行数据。这样看起来就像数据随着用户的操作滚动了一样。

这个方案有几个细节需要注意:

  • 让一个容器出现一个滚动条很容易,只要给当前容器添加一个足够高的 Dom 元素即可。但具体有多高呢?这需要开发者根据总的数据行数及每行数据的高度计算得到
  • 因为容器的滚动条是通过一个额外的 Dom 元素创建的,首屏数据是没有滚动条的,所以当用户在数据区域滚动鼠标滚轮时,要控制容器的滚动条,使数据区域与滚动条看起来像一个整体
  • 当滚动条滚动时到底增加或删减几行数据合适呢?这需要先获取用户滚动的距离,然后用这个距离除以滚动条的总高度,得到滚动距离在总高度中的占比,然后让数据总行数乘以这个占比,得到你需要增加或删减的行数
  • 你会发现上面的计算是有误差的,不过每关系,这个误差只会在滚动条滚动到容器最底部或最顶部的时候才会产生影响,你只要在滚动到最底部或最顶部的时候修正这个误差即可,即那一刻把剩余的数据全部显示出来
  • 当窗口放大或缩小时可能会导致首屏数据区域发生变化,如果发生了变化则需要重绘数据。

监听用户滚动条滚动的代码如下(当用户在数据区域滚动鼠标滚轮时,也会触发 scrollDom 的 onscroll 事件)

1
2
3
4
5
6
7
8
let scrollDom = document.querySelector('#scollDom')  // 此 Dom 元素负责创建一个足够长的滚动条    
let dataDom = document.querySelector('#dataDom')   // 此 Dom 元素承载着一屏的数据   
scrollDom.onscroll = () => {
    // 用户滚动滚动条的逻辑
}
dataDom.onwheel = (e) => {
    scrollDom.scrollTop = scrollDom.scrollTop = e.deltaY
}

相应的 HTML 代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<div class="TableBox">
    <div class="DataTable">
        <table>
            <thead>
            <tr>
                <td>your column1</td>
                <td>your column2</td>
                <td>your column3</td>
            </tr>
            </thead>
            <tbody id="dataDom">
                <!-- 此处为动态添加的数据 -->
            </tbody>
        </table>
    </div>
    <div class="RightScroller" id="scrollDom">
        <div>
            <!-- 此元素高度动态计算 -->
        </div>
    </div>
</div>

如果你对界面的个性化定制要求不高,那么推荐你使用 cheetah-grid(https://github.com/TonyGermaneri/canvas-data-grid)这个开源项目来完成类似的需求,这个项目除了是使用 Cnavas 技术渲染首屏数据之外,其他逻辑与前面介绍的基本一致。

完成上述工作后,就可以在页面中加载十万行以上的数据了,页面各方面表现都很优异,与传统技术开发的桌面应用别无二致

页面容器

webFrame

大部分情况下,窗口与其对应的 webContents 实例就能满足大多数基本的用户交互需求了,但有一些特殊情况还需要在窗口内包含更多的子页面,这时就需要用到页面容器了。
接下来我们先了解第一种页面容器 -- webFrame

webFrame 是最常见的页面容器。我们在开发 HTML 页面时,经常会用 iframe 标签在页面中嵌套子页面。 在 Electron 应用中,每有一个 iframe 就对应着一个 webFrame 实例。
即使一个页面中如果没有任何子页面,它本身也是一个 webFrame 实例,即主 webFrame,也就是 mainFrame

webFrame 类型和实例只能在渲染进程中使用,通过如下代码得到的就是当前渲染进程内的 mainFrame 对象。

1
const { webFrame } = require('electron')

得到 mainFrame 实例后,你可以通过 webFrame.findFrameByName(name) 方法、webFrame.findFrameByRoutingId(routingId) 方法或 webFrame.fristChild 属性、webFrame.nextSibling 属性找到你需要的页内 webFrame,然后完成针对具体 webFrame 的操作

重点:
Electron 有一个 BUG: 只有相同域下的 iframe 才可以用 findFrameByName 或 firstChild 之类的方法获取到。(https://github.com/electron/electron/issues/18371)

另外,你不能单独缩放 webFrame 内的网页,这是 Electron 的另外一个 BUG(https://github.com/electron/electron/issues/20799)

这就是在 Electron 应用内使用 webFrame 的两个局限性

routingId 是 webFrame 实例的整型属性,你可以通过 webFrame.routingId 得到的值。
通过 routingId 你可以给具体的 webFrame 子页面发送消息 -- webContents.sendToFrame(frameId, channel, arg)

did-frame-anvigate 事件就是页面中任何一个 webFrame 实例跳转完成后触发的。

你还可以使用 webContents.isLoadingMainFrame() 方法来判断 mainFrame 是否已经加载完成了。

如果不做特殊处理,iframe 子页面没办法使用 require 函数引入其他 js 库,浏览器会报如下错误:

1
Uncaught ReferenceError: require is not defined

以下为一种处理方式(此代码应写在父页面中的适当位置):

1
2
3
4
5
let iframe = document.querySelector('#yourIframeId')
iframe.onload = function () {
    let iframeWin = iframe.contentWindow
    iframeWin.require = window.require
}

上面代码在子页面加载完成后,把父页面的 requre 方法同步给了子页面,你也可以用在子页面内撰写 JavaScript 代码获取父窗口的 require 方法来解决此问题

更简单的办法是,创建窗口时把 nodeIntegrationInSubFrames 属性设置为 true,这个属性的含义是: 是否允许在子页面或子窗口中集成 Node.js。代码如下:

1
2
3
4
5
6
let win = new BowserWindow({
    webPreferences: {
        nodeIntegration: true,
        nodeIntegrationInSubFrames: true
    }
})

webview

webview 是 Electron 独有的标签,开发者可以通过标签在网页中嵌入另外一个网页的内容(被嵌入的网页可以是自己的网页,也可以是任意第三方网页),代码如下:

1
2
<webview id="foo" src="https://www.github.com/" style="width: 80%">
</webview>

如你所见,它与普通的 DOM 标签并没有太大区别,也可以设置 id 和 style 属性。此外,它还有一些专有的属性,比如:

  • nodeintegration: 使 webview 具有使用 Node.js 访问系统资源的能力
  • nodeintegrationinsubframes: 使 webview 内的子页面(iframe)也具有使用 Node.js 访问系统资源的能力
  • plugins: 使 webview 内的页面可以使用浏览器插件
  • httpreferrer: 设置请求 webview 页面时使用怎样的 httpreferrer 头
  • useragent: 设置请求 webview 页面时使用怎样的 useragent 头

此外还有很多其他的专有属性,可以访问官方文档参阅

webview 标签默认是不可用的,如果需要使用此标签,那么在创建窗口时,需要设置 webviewTag 属性:

1
2
3
4
5
6
7
8
let win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
        webviewTag: true,  // 启用 webView 标签
        nodeIntegration: true
    }
})

只有把 webPreferences 的属性 webviewTag 设置为 true,你才可以在此窗口内使用标签

重点:
目前 webview 标签及其关联的事件和方法尚不稳定,其 API 很有可能在未来被修改或删掉,Electron 官方不推荐使用。
这是 webview 最大的局限性,也是 webview 标签默认不可用的原因之一(另一个原因是使用 webview 标签加载第三方内容可能带来潜在的安全风险)

BrowserView

前面介绍的两种页面容器均有缺陷,但在页面中嵌入其他页面的需求又时常出现,比如开发一个简单的浏览器,标签栏、地址栏、搜索框肯定在主页面(mainFrame)中,用户请求浏览的页面肯定是一个子页面。
那么该用什么技术满足此类需求呢?推荐使用 BrowserView

Browserview 被设计成一个子窗口的形式,它依托于 BrowserWindow 存在,可以绑定到 BrowserWindow 的一个具体的区域,可以随 BrowserWindow 的放大缩小而放大缩小,随 BrowserWindow 的移动而移动。
BrowserView 看起来就像是 BrowserWindow 里的一个元素一样。

下面我们来看一段创建 BrowserView 的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
let view = new BrowserView({
    webPreferences: {
        preload
    }
})
win.setBrowserView(view)
let size = win.getSize()
view.setBounds({
    x: 0,
    y: 80,
    width: size[0],
    height: size[1] - 80
})
view.setAutoResize({
    width: true,
    height: true
})
view.webContents.loadURL(url)

代码中,win 是一个 BrowserWindow 对象,它通过 setBrowserView 为自己设置了一个 BrowserView 容器,
然后 BrowserView 容器通过 setBounds 绑定到这个窗口的具体区域,
接着它通过 setAutoResize 设置自己的宽度和高度上自适应父窗口的宽度和高度的变化,
也就是说,当父窗口宽度和高度变化时,BrowserView 容器也会随之调整自己的宽度和高度
最后 BrowserView 容器对象通过其 webContents 属性加载了一个 URL 地址

上面的代码中,我们只给父窗口留出了顶部 80 个像素高的一块区域,允许父窗口在这块区域中绘制界面,其他区域都交给了 BrowserView 容器对象。
我们要开发一个简易的浏览器的需求,浏览器的标签栏、地址栏、搜索框就应该放在这高度为 80 像素的区域中,用户访问的页面应该放在这高度为 80 像素的区域中,用户访问的页面应该放在 BrowserView 中。

如果需要支持多标签页,开发者就要在运行过程中动态地创建多个 BrowserView 来缓存和展现用户打开的多个标签页。
用户切换标签页时,通过控制相应 BrowserView 容器对象的显隐来满足用户的需求。

为了满足此需求,我们不应该用 win.setBrowserView 为窗口设置 BrowserView,而应该用 win.addBrowserView
这么做时因为 setBrowserView 会判断当前窗口是否已经设置过了 BrowserView 对象,如果设置过,那么此操作会替换掉原有的 BrowserView 对象。
而 addBrowserView 可以为窗口设置多个 BrowserView 对象

另外,BrowserView 对象并不像 BrowserWindow 对象那样拥有 hid 和 show 的实例方法。
如果需要隐藏一个 BrowserView,可以利用 win.removeBrowserView(view) 显式地把它从窗口中移除掉,需要显式的时候,再利用 win.addBrowserView(view) 把它加回来。
此操作并不会造成 BrowserView 重新渲染,可以放心使用

除了这个方法之外,你还可以通过如下 CSS 代码显示和隐藏 BrowserView:

1
2
view.webContents.insertCSS('html {display: block}')    // 显示
view.webContents.insertCSS('html {display: none}')  // 隐藏

如你所见,BrowserView 对象也包含 webContents 属性。
我们操作界面的大部分方法和事件都在 webContents 中,你可以像控制 BrowserWindow 一样控制 BrowserView 的界面内容和交互操作

因此在技术选型时,推荐使用 BrowserView 来满足页面容器的需求

脚本注入

通过 preload 参数注入脚本

脚本注入是 Electron 最有趣的功能,它允许开发者把一段 JavaScript 代码注入到目标网页中,而这段 JavaScript 代码看起来就好像是那个网页开发者自己开发的一样

这段代码除了可以访问此网页的任意内容,比如 Dom、Cookie(包括标记了 HttpOnly 属性的 Cookie)、服务端资源(包括 HTTP API)之外,更让人惊喜的是,这段代码还有能力通过 Node.js 访问系统资源。洗面领略一下脚本注入的威力。

创建窗口时,只要给窗口的 webPreferences.preload 参数设置具体的脚本路径,即可把这个脚本注入到目标网页中,代码如下:

1
2
3
4
5
6
let win = new BrowserWindow({
    webPreferences: {
        preload: yourJsFilePath,
        nodeIntegration: true
    }
})

此处提供的脚本路径应为脚本文件的绝对路径

由于开发者不能事先确定应用程序被用户安装到了哪个路径下,所以程序必须在程序运行时动态地获取注入脚本的绝对路径,开发者可以通过主进程的 app 对象获取应用程序所在的目录,代码所示:

1
2
3
4
const { app } = require('electron')
let path = require('path')
let appPath = app.getAppPath()
let yourJsPath = path.join(appPath, 'yourPreload.js')

如果你使用 vue-electron 环境,app.getAppPath() 指向应用程序的编译路径,本案例中为: D:\project\electron_in_action\chapter6\dist_electron\。你同样可以通过全局变量 __dirname 得到这个路径。
Electron 还可以通过 app.getPath() 方法来获得操作系统常用的路径

如果你希望得到 Vue 项目下 public 目录的绝对路径,可以通过全局变量 __static 来实现(public 目录下的内容不会被 webpack 打包处理),代码如下:

1
2
let path = require('path')
let yourJsPath = path.join(__static, 'yourPreload.js')

下面我们通过脚本注入的方式把百度的 Logo 更换为 Google 的 Logo,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 主进程创建窗口并注入脚本的代码        
let path = require('path')
lt preload = path.join(__static, 'preload.js')
win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
        webviewTag: true,
        nodeIntegration: true,
        preload
    }
})
win.loadURL('https://www.baidu.com/')
1
2
3
4
// 被注入的脚本代码,preload.js
window.onload = function() {
    document.querySelector('img').src = 'https://www.google.com.hk/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png'
}

运行结果如图 6-2 所示:

图6-2通过注入脚本替换网页Logo 图6-2 通过注入脚本替换网页Logo

同样,如果你使用 BrowserView 或者 webview 标签来嵌入第三方页面,它们都能通过类似的机制来给第三方注入脚本

重点:
无论页面是否开启了 webPreferences.nodeIntegration,注入的脚本都有能力访问 Node.js 的 API,但此开关开启与否有较大的差异。

当 webPreferences.nodeIntegration 处于开启状态时,不但注入的脚本可以访问 Node.js 的 API,第三方网页也具有了这个权力。
我们在开发者工具中输入如下测试代码:

1
2
let fs = require('fs')
console.log(fs)

结果输出:

1
> {appendFile: ?, appendFileSynv: ?, access: ?, acessSync: ?, chown: ?,...}

说明第三方网页此时也具有访问 Node.js API 的能力。
如果第三方网页的开发者知道你在 Electron 中国呢访问他们的页面,而且知道你开启了 webPreferences.nodeIntegration,那么第三方网页的开发者完全可以任意操纵你的用户的电脑。

而当 webPreferences.nodeIntegraion 处于关闭状态时,你注入的脚本仍有访问 Node.js 的 API 的能力,第三方网页却没有这个能力了。
我们再在开发者工具中运行上面的测试代码,结果输出:

1
> Uncaught Error: [MODULE_MISS] "fs" is not exists!

说明第三方网页没有访问 Node.js API 的能力了。Electron 是如何做到这种控制的呢?打开源码调试工具,发现注入的脚本变成了如下形式:

1
2
3
4
5
6
7
(function (exports, require, module, __filename, __dirname, process, global, Buffer) {
    return function (exprots, require, module, __filename, __dirname) {
        window.onload = function() {
            document.querySelector('img').src = 'https://www.google.com.hk/images/branding/googlelogo/1x/googlelog_color_272x92dp.png'
        }
    }.call(this, exports, require, module, __filename, __dirname)
})

从上面代码可以看出,Electron 通过匿名函数把注入的脚本封装在一个局部作用域内,因此访问 Node.js API 的能力也被封禁在这个局部作用域内了,外部代码就无法使用这个局部作用域内的对象或方法了。这就起到了安全防范的作用。

与 nodeIntegration 属性类似的还有 nodeIntegrationInWorker 和 nodeIntegrationInSubFrames 属性。
nodeIntegrationInWorker 表示是否允许应用内的 Web Worker 县城访问 Node.js API,nodeIntegrationInSubFrames 表示是否允许子页面访问 Node.js API

扩展:
我们知道浏览器中的 JavaScript 是单线程执行的,这使得开发者在使用 JavaScript 的时候要尽量避免同步执行长耗时的工作,以防止线程阻塞。
但在 Web Worker 技术出现后,允许开发者在浏览器内创建一个新的脚本线程了,此线程可以独立地执行任务而不干扰用户界面。

在 Web Worker 内,虽然我们不能直接操作 DOM 节点,也不能使用 Window 对象的默认方法和属性,但可以使用 Web Socket、IndexedDB 和 XMLHttpRequest(有些属性会被禁用)

需要访问页面 DOM 时,Web Worker 则可以发消息给页面线程,页面线程也可以发消息给 Web Worker 来控制 Web Worker 的执行过程,下面是一个简要的演示代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 页面 JavaScript
// 创建 Web Worker
let worker = new Worker("testWorker.js")
// 向 Web Worker 发送消息
worker.postMessage({msg: 'hello'})
// 接收 Web Worker 的消息
worker.onmessage = (event) => {
    console.log(event.log(event.data))
}
// 杀死 worker 的线程,页面关闭时他也会自动终结
worker.terminate()
1
2
3
4
5
6
// testWorker.js 文件的内容
// 接收消息
this.onmessage = function (event) {
    // 发送消息
    this.postMessage("Hi" + event.data.msg)
}

注意: 它们之间发送消息时传递的不是数据的引用,而是数据的复制

扩展:
虽然绝大多数第三方网站的运维者可能不会在意你用 Electron 请求他们的网站,更不会在自己的网站中加入恶意的脚本来控制你客户的机器,但这并不能成为你开启 webPreferences.nodeIntegration 属性的理由

现在我们逆向思考一下,第三方网站的运维者是如何发现你用 Electron 请求他们的网站的呢?

一般情况下,他们会监控用户请求头里的 User-Agent 信息。当用 Electron 请求某网站时,默认的 User-Agent 值是:

1
Mozilla/5.0(Windows NT 10.0; Win64; x64)AppleWebKit/537.36(KHTML, lick Gecko) chapter5/0.1.0 Chrome/76.0.3809.146 Electron/6.1.2 Safari/537.36

你会发现这个字符串里有 Electron 字样,第三方网站的运维者也是以此来判断请求是否来自 Electron 应用的。
如果你不想用这样的 User-Agent 来请求第三方网站,那么你可以在加载 URL 时更改 User-Agent 的值,代码如下:

1
2
3
win.webContents.loadURL('https://www.baidu.com/', {
    userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:68.0) Gecko/20100101 Firefox/68.0'
})

上面的代码就让你的请求变得像发自 FireFox 浏览器一样了。除了修改 User-Agent 请求头外,你还应注意保护自己的源码,避免被第三方网站的运维者分析特征。

如果你的应用程序中有非常多的页面加载请求需要设置 User-Agent,那么你可以直接设置 app.user Agent Fallback 属性的值,设置此值后,应用中所有的页面加载请求都会使用此 User-Agent(注意,仅最新版本的 Electron 才拥有此属性,早期版本的 Electron 并不具备)

通过 executeJavaScript 注入脚本

通过 preload 参数注入脚本适用于需要注入大量业务逻辑到第三方网站中的需求,而且有时可能不止注入了一个脚本文件,你可以在注入的脚本中通过 require 加载其他脚本,以控制注入脚本内容的可维护性

但是很多时候可能只需要注入一两句 JavaScript 代码即可,这种情况下我们就没必要修改 preload 参数和创建新的脚本文件了,你只需要调用 webContents 的 executeJavaScript 即可

加上我们希望获取网站的第一个 img 标签的 src 属性,实验代码示例如下:

1
2
3
4
win.once('did-finish-load', async () => {
    let result = await win.webContents.executeJavaScript("document.querySelector('img').src")
    console.log(result)
})

页面加载完成前,如果过早地执行注入脚本,可能得不到任何结果,所以我们这里用到了 BrowserWindow 的 did-finish-load 事件。
你可以在注入脚本里监听 window.onload 事件,效果相同

webContents 的 executeJavaScript 方法返回的是一个 Promise 对象,所以我们给 reday-to-show 事件的监听函数加了 async 关键字,在 executeJavaScript 方法前加了 await 关键字(也可以在 executeJavaScript 方法后使用 then 方法)

扩展:
本例中用到了 async 和 await 关键字。async 用于声明一个函数是异步的,await 用户等待一个异步方法执行完成。await 关键字只能出现在 async 声明的方法中

async 关键字声明的方法返回一个 Promise 对象,代码如下:

1
2
3
4
let a = async () => {
    return 1;
}
a()

在浏览器开发者工具中运行上面两行代码,得到的输出为 Promise {:1}
Promise 是 ES6 中的一个新的类型,此处得到的结果是 Promise 类型的一个实例,它代表着一个异步操作的最终完成或者失败

我们可以通过 Promise 对象的 then 方法获取异步操作的结果 promise.then(successCallback, failureCallback)。
异步操作执行成功后会调用 successCallback 回调函数,执行失败后会调用 failureCallback 回调函数。
以上面的例子来说,执行如下代码:

1
2
3
a().then(result => {
    console.log(result)
})

我们将得到输出结果: 1。

await 关键字其实就相当于 Promise 的 then 方法。以下代码也能正确输出 a 函数的执行结果。

1
2
3
4
5
let b = async () => {
    let result = await a()
    console.log(result)
}
b()

综上所述,你会发现使用 async 和 await 关键字可以有效地解决 JavaScript 语言“回调地狱”的问题。

被注入的代码其实是包在 Promise 对象中的。如果被注入的代码执行过程中产生异常,也会调用 Promise 对象的 reject 方法

如果你注入的代码逻辑较多,那么你可以把逻辑写在一个立即执行函数内(如果逻辑非常多,还是建议使用 preload 方式注入脚本),代码如下:

1
2
3
(function() {
    return document.querySelector('img').src
})()

这样既达到了封装的目的,又能防止第三方网站的运维者对你的代码进行特征分析。

除了通过 webContents 的 executeJavaScript 方法注入 JavaScript 代码外,你还可以用 webContents 的 insertCSS 方法给第三方页面注入样式:

1
let key = await win.webContents.insertCSS("html, body { background-color: #f00 !important;}")

此样式注入成功后,页面背景变成了红色,同时也返回了一个 Promise 对象,对象的值是被注入样式的 key。我们可以用这个 key 删除注入的样式,删除代码如下:

1
await contents.removeInsertedCSS(key)

注意 removeInsertedCSS 亦返回 Promise 对象

禁用窗口的 beforeunload 事件

网页可以铜鼓注册 beforeunload 事件来阻止窗口关闭,当用户关闭窗口时,浏览器会给出警告提示。
但如果你用 Electron 加载了一个注册了 beforeunload 事件的第三方网页,你会发现这个窗口无法关闭,而且不会收到任何提示

此时你可能会考虑到,可以通过注入一段简单的脚本把 window.onbeforeunload 设置成 null:

1
await win.webContents.executeJavaScript("window.onbeforeunload = null")

这种方案在大多数情况下是可行的,但并不是完美的解决方案。
由于你无法获悉第三方网页在何时注册 onbeforeunload 事件,因此有可能取消其 onbeforeunload 事件时它尚未被注册,这时你的意图就落空了,无法解决问题

最优雅的解决方案是监听 webContents 的 will-prevent-unload 事件,通过 event.preventDefault(); 来取消该事件,这样就可以自由地关闭窗口了

1
2
3
win.webContents.on('will-prevent-unload', event => {
    event.preventDefault()
})

页面动画效果

使用 CSS 控制动画

开发者可以使用 CSS Animations 技术控制页面元素产生动画效果,这时目前 Web 界面中最常用的动效实现方式,如下代码所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@keyframes dropDown {
    0% {
        transform: translate(0px, -120px);
        opacity: 0;
    }
    100% {
        transform: translate(0px, 0px);
        opacity: 1;
    }
}
.appTip {
    animation-name: dropDown;
    animation-duration: 800ms;
    animation-delay: 0ms;
    animation-timing-function: ease;
    animation-iteraton-count: 1;
    animation-fill-mode: forwards;
}

以上代码通过 @keyframes 定义了动画行为: 位置从高度 -120 下降到 0,透明度从 0 提高到 1.
然后这个动画行为被赋予给 .appTip 所代表的页面元素,要求元素理解开始执行(animation-delay)动画且在 800 毫秒内执行完成(animation-duration)。
在此时间段内动画以慢速开始,然后变快,最终慢速结束的过渡效果执行(animation-timing-function)。
动画执行一次即可(animation-iteration-count),执行完成后元素停留在最后一帧的状态(animation-fill-mode)

使用 JavaScript 控制动画

前面我们使用 CSS Animations 技术控制 .appTip 元素从顶部以 "渐显" 的方式 “坠落” 到指定的位置,但这种以 CSS 样式描述控制动画的方式,与用编程语言控制动画的方式有很大差异,对于 JavaScript 的程序员来说,需要花一点经历才能理解和接受。

JavaScript 其实也有自己的动画 API -- Web Animations API,只不过这个 API 在很多浏览器内没有被很好地支持,所以其接受度没有 CSS Animations 高。
幸好 Chrome 浏览器支持 Web Animations API,因此我们在 Electron 中开发应用时不用担心这一点。

我们用 Web Animations API 实现一遍与上面内容同样的动效,如下代码所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
let keyframes = [{
    transform: "translate(0px, -120px)",
    opacity: 0
}, {
    transform: "translate(0px, 0px)",
    opacity: 1
}]
let options = {
    iterations: 1,
    delay: 0,
    duration: 800,
    easing: "ease"
}
let myAnimation = document.querySelector(".appTip").animate(keyframes, options)

用 JavaScript 编程的方式控制动效看起来和用 CSS 样式标记的形式没什么区别,执行效率上也几乎没什么差别。
但事实上使用了 JavaScript 控制动画时自由度大大提升了,比如我们可以把 keyframes 和 options 独享缓存起来,随时修改其中的属性,也可以随时调用 Dom 元素的 animate 方法。而这些工作用 CSS 来完成就比较麻烦了

另外 animate 方法返回了一个动画执行对象,可以用 myAnimation.pause() 方法暂停动画的执行,用 myAnimation.play() 方法恢复暂停的动画或开始一个新动画,用 myAnimation.reverse() 方法把动画倒着播放一遍。
此外,它还有 onfinish 事件供开发者在动画执行完成后处理一些任务。

因此如果你需要在 Electron 应用中使用动画效果,推荐使用 Web Animations API 技术来完成工作,而不是用 CSS Animations 技术