Skip to content

窗口

几乎所有包含图形化界面的操作系统都是以窗口为基础构建各自的用户界面的。
系统内小到一个计算器,大到一个复杂的业务系统,都是基于窗口而建立的。
如果开发人员要开发一个有良好用户体验的 GUI 应用,势必会在窗口的控制上下足功夫,比如窗口边框和标题栏的重绘、多窗口的控制、模态窗口和父子窗口的应用等。

窗口的常用属性及应用场景

大部分桌面 GUI 应用都是由一个或多个窗口组成的,在前面我们已经创建了很多 Electron 窗口(也就是 BrowserWndow),但窗口的属性只用到了

1
2
3
webPreferences: {
    nodeIntegration: true
}

其实,Electron 的 BrowserWindow 还有很多种可用属性

控制窗口的属性:

属性名称 x,y,center,movable
属性说明 通过 x, y 可控制窗口在屏幕中的位置,如果没有设置这两个值,窗口默认显示在屏幕正中
应用场景举例 前面我们使用多种方式创建了窗口,然而当多个相同大小的窗口被创建时,后创建的窗口会完全覆盖先创建的窗口。此时,我们就可以通过设置窗口的 x,y 属性,使后创建的窗口与先创建的窗口交错显示,比如: 每创建一个窗口,使 x,y 分别比前一个窗口的 x,y 多 60 个像素

控制窗口大小的属性:

属性名称 width,height,minWidth,minHeight,maxWidth,maxHeight,resizable,minimizable,maximizable
属性说明 通过以上属性可以设置窗口的大小以及是否允许用户控制窗口的大小
应用场景举例 窗口默认是可以通过拖动改变大小的,如果你不想让用户改变窗口的大小,可以指定 width 和 height 属性后再把 resizable 设置为 flase。 如果你希望用户改变窗口的大小,但不希望用户把窗口缩小到很小或放大到很大(窗口太小或太大后界面布局往往会错乱),可以通过 minWidth,minHeight,maxWidth,maxHeight 来控制用户行为

控制窗口边框、标题栏与菜单栏的属性:

属性名称 title,icon,frame,autoHideMenuBar,titleBarStyle
属性说明 通过以上属性可以设置窗口的边框、标题栏与菜单栏
应用场景举例 即使不设置 title 或 icon 也没问题,因为窗口的 title 默认为你网页的 title,icon 默认为可执行文件的图标。 frame 是一个非常重要的属性,目前市面上几乎所有的软件都会用自定义的窗口标题栏和边框,只有把 flame 设置成 false,我们才能屏蔽掉系统标题栏,为自定义的标题栏奠定基础。 你不能轻易把系统菜单设置成 Null,因为在 Mac 系统下,系统菜单贯休到“复制”、“粘贴”、“撤销”、“重做” 这样的常用快捷键命令(类似 Command + Z 的快捷键也与系统菜单有关)

上面列举了一些 Electron 窗口的基础配置(并不是全部配置)。
此外,Electron 窗口还有一个非常特殊的配置 -- webPreferences,它包含多个子属性,可以用于控制窗口内的网页。
下面选几个常用的属性进行介绍:

控制渲染进程访问 Node.js 环境能力的属性

属性名称 nodeIntegration,nodeIntegrationInWorker,nodeIntegrationInSubFrames
属性说明 控制窗口加载的网页是否集成 Node.js 环境
应用场景举例 这三个配置项默认值都是 false,如果你要把其中的任何一个设置为 true,需要确保网页不包含第三方提供的内容,否则第三方网页就有权利访问用户电脑的 Node.js 环境,这对于用户来说是非常危险的。Electron 官方也告诫开发者需要承担软件安全性的责任

可以增强渲染进程能力的属性:

属性名称 preload,webSecurity,contextIsolation
属性说明 允许开发者最大限度地控制渲染进程加载的页面
应用场景举例 preload 配置项使开发者可以给渲染进程加载的页面注入脚本。就算渲染进程加载的是第三方页面,且开发者关闭了 nodeIntegration, 注入的脚本还是有能力访问 Node.js 环境的。 webSecurity 配置项可以控制网页的同源策略,关闭同源策略后,开发者可以轻松地调用第三方网站的服务端接口,而不会出现跨域的问题

以上涉及的很多配置对控制窗口表现都十分重要

窗口标题栏和边框

自定义窗口的标题栏

窗口的边框和标题栏是桌面 GUI 应用非常重要的一个界面元素。
开发者创建的窗口在默认情况下会使用操作系统提供的边框和标题栏,但操作系统默认的样式和外观都比较刻板,大多数优秀的商业应用都会选择自定义。
我们随意选几个桌面应用(均为 Windows 操作系统下的应用)来看看它们的标题栏是如何表现的,如图 5-1、图 5-2、图 5-3 所示:

图5-1控制面板标题栏 图5-1 Windows 操作系统控制面板的标题栏

图5-2百度网盘标题栏 图5-2 百度网盘的标题栏

图5-3微信标题栏 图5-3 微信桌面端的标题栏

商业应用自定义了窗口标题栏后,用户体验有了显著提升,且窗口的拖拽移动、最大化、最小化等功能没有缺失。
可见自定义窗口标题栏对提升产品品质很有帮助。接下来,我们就尝试创建一个拥有自定义标题栏的窗口

1
2
3
4
5
6
win = new BrowerWindow({
    frame: false,
    webPreferences: {
        nodeIntegration: true
    }
})

设置 frame: false 之后,启动应用,窗口的边框和标题栏都不见了,只显示窗口的内容区,这时我们无法拖拽移动窗口,也无法最大化、最小化、关闭窗口了

我们接下来要做的,就是在窗口的内容区开辟一块空间作为窗口的标题栏,打开 src/App.vue,修改其 template 中的代码为如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<div id="app">
    <div class="titleBar">
        <div class="title">
            <div class="logo">
                <img src="@/assets/logo.png" />
            </div>
            <div class="txt">窗口标题</div>
        </div>
        <div class="windowTool">
            <div @click="minisize">
                <i class="iconfont iconminisize"></i>
            </div>
            <div v-if="isMaxSize" @click="restore">
                <i class="iconfont iconrestore"></i>
            </div>
            <div v-else @click="maxsize">
                <i class="iconfont iconmaxsize"></i>
            </div>
            <div @click="close" class="close">
                <i class="iconfont iconclose"></i>
            </div>
        </div>
    </div>
    <div class="content">
        <router-view />
    </div>
</div>

这段代码的作用是把一个页面分隔成两个部分: 上部分样式名为 titleBar 的 div 是窗口的标题栏;
下部分样式名为 content 的 div 是窗口的内容区域。内容区域不是重点,主要关注窗口标题栏

标题栏又分为两个区域: 一个区域是样式为 title 的 div,此处放置窗口的 Logo 和标题;
另一个区域是样式为 wiindowTool 的 div,此处放置窗口的最大化、最小化、还原、关闭等控制按钮。
这些按钮都是以字体图标的形式显示在界面上的。

为了实现上述页面布局,我们重写了本页面的样式,样式分为两部分。第一部分为全局样式,代码为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<style>
body,html {
    margin: 0px; padding: 0px;
    overflow: hidden; height: 100%;
}
#app {
    text-align: center; margin: 0px; padding: 0px;
    height: 100%; overflow: hidden;
    box-sizing: border-box;
    border: 1px solid #f5222d;
    display: flex;
    flex-direction: column;
}
</style>

上述代码控制了 html、body 和 #app 元素的样式。在#app元素内我们为窗口设置了 1 个像素宽的红色边框,并且定义该元素的盒模型样式为 border-box

扩展: 一般情况下,浏览器会在元素的宽度和高度之外绘制元素的边框(border)和内边距(padding),这往往会导致子元素实际大小大于指定大小。
一旦开发者为元素指定了 box-sizing: border-box,元素的边框和内边距将在已设定的宽度和高度内进行绘制,从已设定的宽度和高度分别减去边框和内边距,得到的值即为内容的宽度和高度。

第二部分样式代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<style lang="scss" scoped>
@import url(https://at.alicdn.com/t/font_1378132_s4e44adve5.css);
.titleBar {
    height: 38px; line-height: 36px;
    background: #fff1f0; display: flex;
    border-bottom: 1px solid #f5222d;
    .title {
    flex: 1; display: flex;
        -webkit-app-region: drag;
        .logo {
            padding-left: 8px; padding-right: 6px;
            img { width: 20px; height: 20px; margin-top: 7px; }
        }
        .txt { text-align: left; flex: 1; }
    }
    .windowTool {
        div {
            color: #888; height: 100%; width: 38px;
        display: inline-block; cursor: pointer;
            i {  font-size: 12px; }
            &:hover { background: #ffccc7; }
        }
        .close:hover { color: #fff; background: #ff4d4f; }
    }
}
.content{ flex: 1; overflow-y: auto; overflow-x: auto; }
</style>

标题栏示例结果1

style 标签内包含 lang 和 scoped 两个属性: scoped 属性标志着此标签内的样式只对本组件有效;lang="scss"标志着该样式标签内使用 Scss 语法书写样式规则

在此标签内,我们通过@import指令引入了一个字体图标样式文件。这是 iconfont 平台(https://www.iconfont.cn/)提供的服务,它为我们标题栏的功能按钮提供了字体图标资源

扩展: iconfont 是由阿里妈妈 MUX 团队打造的矢量图标管理、交流平台。该平台包含很多设计师提供的字体图标库,设计风格多样,选择空间巨大。
如果你希望得到设计风格一致的图标库,你可以选择 Font Awesome(https://fontawesome.com/icons)或 ionicons(https://ionicons.com/)这两个平台

值得注意的是,我们为标题栏的 .title 指定了一个专有名词样式 -webkit-app-region,这个样式标志着该元素所在的区域是一个窗口拖拽区域,用户可以拖拽此元素来移动窗口的位置。

如果我们在一个父元素上设置了此样式,而又不希望父元素内某个子元素拥有此拖拽移动窗口的功能,此时我们可以给该子元素设置-webkit-app-region: no-drag 样式,以此来屏蔽掉这个从父元素继承来的功能。

至此,我们成功地绘制了窗口的标题栏和边框。
.title元素所在区域内按下鼠标所在区域内按下鼠标拖拽窗口,发现窗口也会跟着移动。在窗口边框上拖拽,窗口大小也会跟着变化

这里我们使用 HTML 和 CSS 技术就完成了窗口标题的绘制工作,演示的 Demo 虽然传统,但掌握了上述技术后我们就可以自由地绘制标题栏了,比如增加一个用户头像、增加一个设置按钮等。
另外,我们还可以改变标题栏的位置和大小,比如把标题栏放在窗口的左侧(例如微信桌面端),让整个窗口都是标题栏(例如迅雷的悬浮窗)等,类似这些需求都可以从容实现

窗口的控制按钮

在上一节的 HTML 代码中,我们为标题栏的控制按钮绑定了点击事件,如<div @click="minisize">。接下来为这些控制按钮增加控制逻辑,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const { remote } = require('electron')
export default {
  name: 'App',
  methods: {
    close () {
      remote.getCurrentWindow().close()
    },
    minisize () {
      remote.getCurrentWindow().minimize()
    },
    restore () {
      remote.getCurrentWindow().restore()
    },
    maxsize () {
      remote.getCurrentWindow().maximize()
    }
  }
}

出现错误:

1
Uncaught ReferenceError: __dirname is not defined at eval (index.js?bdb9:4) at Object../node_modules/electron/index.js (chunk-vendors.js:1104) at __webpack_require__ (app.js:8

修复:

在前端项目根目录添加vue.config.js配置:

1
2
3
4
5
6
7
module.exports = {
  pluginOptions: {
    electronBuilder: {
      nodeIntegration: true
    }
  }
}

为了能使用 remote,还需要配置 webPreferences 中的 enableRemoteModule 选项为 true:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const win = new BrowserWindow({
    width: 800,
    height: 600,
    frame: false,
    webPreferences: {
      enableRemoteModule: true,
      nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
      contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION
    }
  })

这是一段典型的 Vue 组建内的 JavaScript 代码,首先引入 electron 模块的 remote 对象。
用户点击最大化、最小化、还原、关闭按钮的操作,都是先通过 remote 对象获得当前窗口的实例(remote.getCurrentWindow),再操作窗口实例完成的。

窗口最大化状态控制

在我们平时的使用中,窗口的最大化与还原按钮应该在窗口最大化状态时显示还原图标,在非最大化状态时显示最大化图标。

为了实现这个功能,我们需要监听窗口最大化状态变化的事件,首先为 App.vue 组件增加一个状态属性:

1
2
3
4
5
data () {
    return {
      isMaxSize: false
    }
},

isMaxSize 负责记录当前窗口是否最大化。在窗口最大化之后,我们把 isMaxSize 属性设置为 true,在窗口还原之后,isMaxSize 属性被设置为 false

控制该按钮显示行为的代码如下(此处用到了 Vue 的条件渲染功能):

1
2
3
4
5
6
<div v-if="isMaxSize" @click="restore">
    <i class="iconfont iconrestore"></i>
</div>
<div v-else @click="maxsize">
    <i class="iconfont iconmaxsize"></i>
</div>

当 isMaxSize 属性被设置为 true 时,界面上就不会再显示最大化按钮,而是显示还原按钮;
当 isMaxSize 属性被设置为 false 时,此时最大化按钮重新显现,还原按钮隐藏。

接下来我们对 Vue 组件增加 mounted 钩子函数,此函数负责监听窗口的最大化、还原状态的变化,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
mounted () {
    const win = remote.getCurrentWindow()
    win.on('maximize', _ => {
      this.isMaxSize = true
      this.setState()
    })
    win.on('unmaximize', _ => {
      this.isMaxSize = false
      this.setState()
    })
},

mounted 是 Vue 组件生命周期钩子函数。当组件挂载到实例上的时候,Vue 组件会调用该钩子函数

因为不确定程序会不会因标题栏以外的其他地方的操作而最大化或还原窗口,所以不能简单地控制按钮的 restore 和 maxsize 方法中改变 isMaxSize 属性的值。
而用这种方式来监听窗口最大化状态属性的变化,即使因标题栏以外的某个业务组件的操作导致了窗口最大化,isMaxSize 属性的状态值也不会错乱。

另外,当用户双击 -webkit-app-region:drag 区域时,窗口也会最大化,这也是监听窗口 maximize 状态变化的理由之一

重点:
此处在渲染进程中监听 win 的 'maximize''unmaximize' 时间,以及后面的 'move''resize' 事件时,存在一个潜在的问题。
当用户按下 Ctrl+R(Mac 系统中为 Command+R)快捷键刷新页面后,再操作窗口触发相应的事件,主进程会报异常。

造成这个现象的原因是我们用 remote.getCurrentWindow 获取到的窗口对象,其实是一个远程对象,它实际上是存在于主进程中的。
我们在渲染进程中为这个远程对象注册了两个事件处理程序('maximize''unmaximize'),事情发生时,处理程序被调用,这个过程没有任何问题。
但是一旦页面被刷新,注册事件的过程会被再次执行,每次刷新页面都会泄漏一个回调。更糟的是,由于以前安装的回调的上下文已经被释放,因此在此事件发生时,泄漏的回调函数找不到执行体,将在主进程中引发异常。

有两种办法可以避免重点中提到的这种异常,最常见的方法就是把事件注册逻辑转移到主进程内。
当窗口最大化状态变化后,需要设置渲染进程的 isMaxSize 属性,常用的方法是让主进程给渲染进程发送消息,再由渲染进程完成 isMaxSize 属性的设置工作

另一种方法就是禁止页面刷新。一个简单的禁止页面刷新的代码如下:

1
2
3
window.onkeydown = function(e) {
    if (e.keyCode == 82 && (e.ctrlKey || e.metaKey)) return false;
}

如果上面的代码能成功屏蔽刷新快捷键,说明渲染进程内的页面先接收到了按键事件,并在事件中返回了 false,Electron 即不再处理该事件。

虽然在浏览器中按 F5 快捷键也会触发页面刷新事件,但在 Electron 中并没有监听 F5 按键,所以开发者不用担心。

另外,此程序基于 Vue 和 webpack 开发,webpack 自带 hot-module-replacement(HMR)技术,并不需要用粗暴的 liv-reload 技术来刷新页面,所以在开发者更新页面内容后,并没有造成页面刷新,也不影响前端代码调试工作。

防抖与限流

为了让用户有更好的体验,我们希望系统能记住窗口的大小、位置和是否最大化的状态。所以,我们还需要监听窗口移动和窗口大小改变的事件。

这两个事件也放在 mounted 方法内注册,代码如下:

1
2
3
4
5
6
win.on('move', this.debounce(() => {
    this.setState()
}))
win.on('resize', this.debounce(() => {
    this.setState()
}))

这两个事件有一个共同点,就是用户无论是拖动窗口边框改变窗口大小,还是拖动标题栏可移动区域改变窗口位置,都会在短时间内触发大量的 "resize" 或 "move" 事件。
这些频繁的调用大部分是无效的,我们往往只要处理最后一次调用即可,所以与监听 "maximize""unmaximize" 事件不同,在监听 "move""resize" 事件的时候,调用了 this.debounce 函数,这是一个防抖函数,代码如下:

1
2
3
4
5
6
7
8
9
debounce (fn) {
    let timeout = null
    return function () {
    clearTimeout(timeout)
    timeout = setTimeout(_ => {
        fn.apply(this, arguments)
    }, 300)
    }
}

防抖函数的作用是当短期内有大量的事件触发时,只会执行最后一次事件关联的任务

此函数原理为在每次 "resize" 或 "move" 事件触发时,先清空 debounc 函数内的 timeout 变量,再设置一个新的 timeout 变量。
如果事件被频繁地触发,旧的 timeoute 尚未执行就被清理掉了,而且 300 毫秒内只允许有一个 timeout 等待执行

举个例子,A 到主管处领任务,主管把任务分配给 A,并告诉给 A,在 300 毫秒后再执行任务。
如果 300 毫秒内 B 夜来领取,那么主管会马上取消 A 的任务,然后把这个任务分配给 B,并告诉 B,在 300 毫秒后再执行任务。
如果在这 300 毫秒内又有 C 来领取任务,那么任务又会分配给 C,取消 B 的任务。
如果接下来这 300 毫秒内没有人再来领取任务,那么 C 在 300 毫秒执行任务,也就是说一定是最后一个人执行任务

扩展: debounce 函数返回了一个匿名函数,这个匿名函数被用来作为窗口 "move" 和 "resize" 事件的监听器。
此处,这个匿名函数内部代码有访问 timeout 和 fn 的能力,即使 debounce 函数已经退出了,这个能力依然存在,这就是 JavaScript 语言的闭包特性

其背后的原理是 js 的执行引擎不但记住了这个匿名函数,还记住了这个匿名函数所在的环境。

类似于防抖函数,JavaScript 还有一个限流函数,也非常常见,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
throttle(fn) {
    let timeout = null
    return function() {
        if (timeout) return
        timeout = setTimeout(_ => {
            fn.apply(this, arguments)
            timeount = null
        }, 300)
    }
}

限流函数的作用是当短期内有大量的事件被触发时,只会执行第一次触发的事件。

此函数的原理为当每次事件触发时,先判断与该事件关联的任务是否正等待执行。如果不是,那么就创建一个 timeout,让任务等待执行。
再有新事件触发时,如果发现有任务在等待执行,即直接退出。

再举个例子,A 到主管处领任务,主管把人物分给 A,并且告诉 A,任务需要在 300 毫秒后执行。
在接下来的 300 毫秒内,B 又来领同样的任务,主管会直接拒绝 B 的申请。直到 A 执行完任务后,再有新人来申请任务,主管才会给他分配任务。

无论是防抖函数还是限流函数,其主要作用都是防止在短期内频繁地调用某函数,导致进行大量无效的操作,损耗系统性能(甚至可能会产生有害的操作,影响软件的正确性)

记录与恢复窗口状态

我们在前面几个事件中都调用了 setState 方法。此方法的主要作用就是记录窗口的状态信息,把窗口的大小、位置和是否最大化等信息保存在 LocalStroage 内。代码如下:

1
2
3
4
5
6
7
setState () {
    const win = remote.getCurrentWindow()
    const rect = win.getBounds()
    const isMaxSize = win.isMaximized()
    const obj = { rect, isMaxSize }
    localStorage.setItem('winState', JSON.stringify(obj))
},

代码中 win.getBounds 返回一个 Rectangle 对象,包含窗口在屏幕上的坐标和大小信息。win.isMaximized 返回当前窗口是否处于最大化状态

扩展: LocalStorage 是一种常用于网页数据本地化存储的技术,各大浏览器均已支持。
因为 Electron 本身也是一种特殊的浏览器,所以我们也可以用 LocalStorage 来存储应用数据。
除此之外,你还可以自由地使用 Cookie 或 indexedDB 等浏览器技术来存储本地数据。

记录了窗口状态,就需要适时地恢复窗口状态。每次打开应用的时候,应用从 LocalStorage 中读取之前记录的窗口状态,然后通过 win.setBoundswin.maximize 方法恢复窗口状态,代码如下:

1
2
3
4
5
6
7
8
9
getState () {
  const win = remote.getCurrentWindow()
  let winState = localStorage.getItem('winState')
  if (winState) {
    winState = JSON.parse(winState)
    if (winState.isMaxSize) win.maximize()
    else win.setBounds(winState.rect)
  }
},

在 mounted 钩子函数结尾,增加如下代码,以达到每次重启应用时,都能恢复窗口状态的目的

1
2
this.isMaxSize = win.isMaximized()
this.getState()

适时地显示窗口

你应该已经发现,窗口一开始会显示在屏幕的正中间,然后才移动到正确的位置,并调整大小。应用的这种表现可能会给使用者造成困扰。

为了解决这个问题,我们在创建窗口时,可以先不让窗口显示出来,代码如下:

1
2
3
4
win = new BrowserWindow({
    //...
    show: false
})

然后在 App.vaue 组件的 getState 方法的末尾加上 win.show() 语句。这样当窗口的位置和大小都准备好之后,才会显示窗口。

注意,调用 win.maximize() 语句时,如果窗口是隐藏状态,也会变成显示状态,因为它其实有 show 的作用

Electron 官方文档推荐开发者监听 BrowserWindow 的 ready-to-show 事件,这不见得是一个好主意,因为此事件是在 “当页面已经渲染完成(但是还没有显示)并且窗口可以被显示时”触发,但此时页面中的 JavaScript 代码可能还没完成工作。
因此,你应该根据业务需求来适时地显示窗口,而不是把这个权力交给 ready-to-show 事件

当然,我不建议你在窗口显示前处理大量的阻塞业务,这可能会导致窗口迟迟显示不出来,用户体验下降

不规则窗口

创建不规则窗口

我们虽然完成了窗口的标题栏和边框的自定义工作,但窗口还是一个传统的举行。
现在市场上有一些应用因其特殊的窗口造型从而提供了更好的用户体验。比如 360 浏览器的安装画面是一个圆形窗口,很多游戏的启动登录画面都是一个不规则窗口等。
现在就一起来创建一个基于 Electron 的不规则窗口

首先,把窗口的高度(height)和宽度(width)修改为相同的值,使窗口成为一个正方形

其次,把窗口的透明属性(transparent)设置为 true,这样设置之后窗口虽然还是正方形的,但只要我们控制好内容区域的 Dom 元素的形状,就可以让窗口形式看起来是不规则的。
不规则窗口往往需要自定义边框和标题栏,所以 frame 属性也被设置为 false。

另外,透明的窗口不可调整大小。所以将 resizable 属性也设置为 false。

窗口显示后,为了防止双击窗口可拖拽区触发最大化事件,我们把 maximizable 属性也设置为 false。

最终,创建窗口的代码如下:

1
2
3
4
5
6
7
8
9
win = new BrowserWindow({
    width: 380,
    height: 380,
    transparent: true,
    frame: false,
    resizable: false,
    maximizable: false,
    // ...
})

接下来再修改 App.vue 的样式,使内容区域的 Dom 元素呈现一个圆形,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
html, body {
    margin: 0px;
    padding: 0px;
    pointer-events: none;
}
#app {
    box-sizing: border-box;
    width: 380px;
    height: 380px;
    border-radius: 190px;
    border: 1px solid green;
    background: #fff;
    overflow: hidden;
    pointer-events: auto;
}

上面样式代码中通过 border-radius 样式把 #app 元素设置成了圆形。border-radius 负责定义一个元素的圆角样式,如果圆角足够大,整个 div 就变成了一个圆形。

为了方便,在 Vue 图标上样式 -webkit-app-region。这样,拖拽 Vue 图标也就是在拖拽窗口。

最终实现的窗口界面如图 5-7 所示。

图5-7不规则窗口示例

图5-7 不规则窗口示例

如果你掌握了 CSS 语言,还可以通过 CSS 样式来控制窗口成为任意其他形状

点击穿透透明区域

我们创建的这个应用虽然窗口看起来是圆形的,但它其实还是一个正方形窗口,只不过正方形四个角是透明的。
因为当我们点击如图 5-8 中所示的 1 区域内的文本文件时,鼠标的点击事件还是发生在本窗口内,而不会点击到文本文件上。

图5-8窗口透明区域鼠标事件穿透示例

图5-8 窗口透明区域鼠标事件穿透示例

作为开发者,我们知晓其中的道理,但从用户的角度来说,这就显得很诡异了。为了达到更好的用户体验,我们需要让鼠标在图 5-8 的1 2 3 4这 4 个区域发生点击动作时可以穿透本窗口,落在窗口后面的内容上。

虽然 Electron 官方文档明确表示 "不能点击穿透透明区域",但这并没有难倒我们,有一个小 trick 可以帮助我们解决这个问题

首先,我们需要用到窗口对象的 setIgnoreMouseEvents 方法,该方法可以使窗口忽略窗口内的所有鼠标事件,并且在此窗口中发生的所有鼠标事件都将被传递到此窗口背后的内容上。
如果调用该方法时传递了 forward 参数,如 setIgnoreMouseEvents(true, {forward: true}),则只有点击事件会穿透窗口,鼠标移动事件仍会正常触发

基于这一点,我们为 App.vue 组件增加 mounted 钩子函数,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
mounted () {
    ...
    window.addEventListener('mousemove', event => {
        const flag = event.target === document.documentElement
        if (flag) {
            win.setIgnoreMouseEvents(true, { forward: true })
        } else {
            win.setIgnoreMouseEvents(false)
        }
    })
    win.setIgnoreMouseEvents(true, { forward: true })
}

上面的代码设置窗口对象监听 mousemove 的事件。当鼠标移入窗口圆形内容区的时候,不允许鼠标事件穿透;当鼠标移入透明区时,允许鼠标事件穿透。

接着,我们为 html、body 元素增加样式 pointer-events: none,为 #app 元素增加样式 pointer-events: auto

设定 pointer-events: none 后,其所标记的元素就永远不会成为鼠标事件的 target 了。
为子元素 #app 设置了 pointer-events: auto,说明子元素 #app 还是可以成为鼠标事件的 target。
也就是说,除了圆形区域内可以接收鼠标事件外,其他区域将不再接收鼠标事件。
当鼠标在圆形区域外移动时,窗口对象的 mousemove 事件触发,event.target 为 documnet.documentElement 对象(这个事件并不是在 html 或 body 元素上触发,而是在窗口对象上触发的,document.documentElement 就是 DOM 树中的根元素,也就是 html 节点所代表的元素)

至此,上文代码中的判断成立,当鼠标在前文所述四个区域移动时,鼠标事件允许穿透;当鼠标在圆形区域移动时,鼠标事件不允许穿透。
运行程序,鼠标在正方形四角区域内点击,鼠标事件具有了穿透效果。

窗口控制

阻止窗口关闭

想象一下这个场景,用户在使用应用时完成了大量的操作,但还没来得及保存,此时,他误触了窗口的关闭按钮。如果开发者没有做防范机制,那么用户的大量工作将毁于一旦。

这时候应用一般需要阻止窗口关闭,并提醒用户工作尚未保存,确认是否需要退出应用

在开发网页时,我们可以用如下代码来阻止窗口关闭:

1
2
3
window.onbeforeunload = function() {
    return false
}

设置好上面的代码后,当用户关闭网页时,网页会弹出一个警告提示,如图 5-9 所示

图5-9网页阻止用户关闭时弹出的警告

图5-9 网页阻止用户关闭时弹出的警告

在 Electron 应用内,我们也可以使用 onbeforeunload 来阻止窗口关闭,但并不会弹出图 5-9 所示的提示窗口,而且开发者也不能在 onbeforeunload 事件内使用 alert 或 config 等技术来完成用户提示。

但开发者可以在 onbeforeunload 事件中操作 DOM,比如创建一个浮动的 div 来提示用户,当用户做出关闭窗口的选择后,再关闭窗口,代码如下:

1
2
3
// 当用户做出关闭窗口的选择后,执行以下代码关闭窗口
const win = remote.getCurrentWindow()
win.destroy()

需要注意的是,在此处不能调用 win.close 关闭窗口,因为如果调用 win.close 又会触发 onbeforeunload 事件,而此事件又会阻止窗口的关闭行为,导致窗口始终无法关闭

在创建提示性浮动的 div 之前,我们应该已经完成“收尾”工作,因此直接销毁窗口并无大碍。所以,此处直接调用 win.destroy() 来销毁窗口。
虽有种种限制,但这也不失为一种可行的解决方案,大部分时候开发者都会选择这种解决方案。

另外还有一种可行的解决方案,即使用 Electron 窗口内置的 'close' 事件实现阻止窗口关闭的功能。
我们在应用主进程中增加如下代码,即可屏蔽窗口关闭事件:

1
2
3
4
win.on('close', event => {
    // 此处发消息给渲染进程,由渲染进程显示提示性信息
    event.preventDefault()
})

'close' 事件发生时由主进程发送消息,通知渲染进程显示提示性信息,待用户做出选择后,再由渲染进程发送消息给主进程,主进程接收到消息后,销毁窗口

重点:
就算我们屏蔽了刷新快捷键,也不能在渲染进程中监听窗口的 'close' 事件,因为渲染进程持有的 win 对象是一个在主进程中的远程对象。
事件发生时,主进程中的 win 对象调用渲染进程的事件处理程序,这个过程是异步执行的,此时在渲染进程中执行 event.preventDefault() 已经毫无效用了。
同理,我们也不应该期望主进程能及时地获得事件处理程序的返回值,所以用 return false 也没有作用

多窗口竞争资源

假设我们开发了一个可以同时打开多个窗口的文档编辑 GUI 应用程序,用户在编辑文档后,文档内容会自动保存到磁盘。
现在假设有两个窗口同时打开了同一个文档,那么此时应用就面临着多窗口竞争读写资源的问题

我们知道,在 Electron 应用中一个窗口就代表着一个渲染进程,此场景下,两个渲染进程可能会同时读写一个本地文件。这可能会出现异常或表现不符预期的问题。

扩展:
JavaScrip 是单线程执行的事件驱动型语言,如果我们在同一个窗口(渲染进程)同时发起多个请求,操作同一个文件,就不会出现任何问题(须使用 Node.js 的 fs.writeFileSync 同步方法或者控制好异步回调的执行顺序)

有三种解决方案可以规避这种异常。

第一种方案是两个窗口通过渲染进程间的消息通信来保证读写操作有序执行
用户操作窗口 A 修改内容后,窗口 A 发消息给窗口 B,通知窗口 B 更新内容。当窗口 A 保存数据时,先发消息给窗口 B,通知窗口 B 此时不要保存数据。
当窗口 A 保存完数据后,再发消息给窗口 B,通知窗口 B 文件已被释放,窗口 B 有权利保存读写该文件了。
当窗口 B 需要保存数据时,也发出同样的通知。

也就是说,当某一个渲染进程准备写某文件时,先广播消息给其他渲染进程,禁止其他渲染进程访问该文件;
当此渲染进程完成文件写操作后,再广播消息给其他渲染进程,说明自己已经释放了该文件,其他窗口就有写此文件的权力了。

第二种方案是使用 Node.js 提供的 fs.watch 来监视文件的变化,一旦文件发生改变,则加载最新的文件,这样无论哪个窗口都能保证当前的内容是最新的,而文件的写操作则交由主进程执行。
当窗口需要保存文件时,渲染进程发送消息给主进程(消息体内包含文件的内容),再由主进程完成写文件操作。
无论多少个窗口给主进程发送写文件的消息,都由主进程来保证文件写操作排队依次执行。此方案优于第一种方案,之所以如此有以下三个原因:

  • 它利用了 JavaScript 单线程执行的特性,主进程收到的消息一定是有顺序的,所以写文件的操作也可以由主进程安排成顺序执行
  • 即使外部程序修改了文件,本程序也能获得文件变化的通知。
  • 程序结构上更简单,维护更方便

扩展:
Node.js 提供了两个监控文件变化的 API -- fs.watch 和 fs.watchFile。
使用 fs.watch 比使用 fs.watchFile 更高效,因此我们应尽可能使用 fs.watch 代替 fs.watchFile

第三种方案是在主进程中设置一个令牌:

1
global.fileLock = false;

然后在渲染进程中读取这个令牌:

1
2
const remote = require('electron').remote
let fileLock = remote.getGlobal('fileLock')

我们通过令牌的方式来控制文件读写的权力,当某一个渲染进程需要写文件时,会先判断令牌是否已经被其他渲染进程“拿走”了(此例中判断令牌变量是否为 true)。
如果没有,那么此渲染进程“占有”令牌(把令牌变量设置为 true),然后完成写文件操作,再 "释放" 令牌(把令牌变量设置为 false)

此操作的复杂点在于我们无法在渲染进程中直接修改主进程的全局变量,只能发送消息给主进程让主进程来修改 global.fileLock 的值。
所以,发消息给主进程的工作还是难以避免。因此,更推荐使用第二种方案。

模态窗口与父子窗口

在一个业务较多的 GUI 应用中,我们经常会用到模态窗口来控制用户的行为,比如用户在窗口 A 操作至某一业务环节时,需要打开窗口 B,在窗口 B 内完成一项重要的操作,在关闭窗口 B 后,才能回到窗口 A 继续操作。此时,窗口 B 就是窗口 A 的模态窗口。

一旦模态窗口打开,用户就只能操作该窗口,而不能再操作其父窗口。此时,父窗口处于禁用状态,只有等待子窗口关闭后,才能再操作其父窗口。

在渲染进程中,为当前窗口打开一个模态窗口的代码如下:

1
2
3
4
5
6
7
8
const remote = require('electron').remote
this.win = new remote.BrowserWindow({
    parent: remote.getCurrentWindow(),
    modal: true,
    webPreferences: {
        nodeIntegration: true
    }
})

此代码中,新建窗口的 parent 属性指向当前窗口,modal 属性设置为 true,新窗口即为当前窗口的模态窗口。

如果创建窗口时,只设置了窗口的 parent 属性,并没有设置 modal 属性,或者将 modal 属性设置为 false,则创建的窗口为 parent 属性指向窗口的子窗口

子窗口将总是显示在父窗口顶部。与模态窗口不同,子窗口不会禁用父窗口。
子窗口创建成功后,虽然始终在父窗口上面,但父窗口仍然可以接收点击事件、完成用户输入等。

Mac 系统下的关注点

Mac 系统下有一个特殊的用户体验准则,就是应用程序关闭所有窗口后不会退出,而是继续保留在 Dock 栏,以便用户再想使用应用时,可以直接通过 Dock 栏快速打开应用窗口

为了实现这个用户体验准则,我们需要做一些额外的工作,代码如下:

1
2
3
4
5
app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') {
        app.quit()
    }
})

在上面代码中,我们监听了应用程序的window-all-closed事件,一旦应用程序所有窗口关闭就会触发此事件。
在此事件中,我们通过 process.platform 判断当前系统是不是 Mac 系统,如果不是 Mac 系统则退出应用;如果是,则什么也不做。
这样就保证了在 Mac 系统下,即使应用程序的所有窗口都关闭了,进程也不会终结,应用图标依旧驻留在 Dock 栏上

扩展:
process.platfrom 值为 darwin 时,表示当前操作系统为 Mac 系统; 值为 win32 时,表示当前操作系统为 Windows 系统(不管是不是 64 位的);
值为 linux 时,表示当前操作系统为 Linux 系统。此外,其还可能是其他值,但在 Electron 应用中并不常用。

除了通过 process.platform 获取操作系统信息外,你还可以通过 require('os').platform() 方法获取,在同一个环境下使用这两种方法返回的值是一样的。

process 对象是一个 Node.js 的对象,它持有当前进程的环境信息,常用的信息包括: process.argv 属性,表示当前进程启动时的命令行参数;
process.env 属性,包含用户环境信息,开发者经常为此属性附加内容,以判断开发的应用程序运行在什么环境下;
process.kill 方法,可以尝试结束某个进程;process.nextTick 方法,可以添加一个回调到当前 JavaScript 事件队列的末尾

你也可以通过 process.versions.electron 获取当前使用的 Electron 的版本号,这是 Electron 框架为 process 对象增加的一个属性。

接着增加如下代码:

1
2
3
4
5
app.on('activate', () => {
    if (win === null) {
        createWindow()
    }
})

app 的'activate' 事件是 Electron 专为 Mac 系统提供的一个事件,当应用程序被激活时会触发此事件。
因为主进程中每关闭一个窗口,我们都会把窗口对应的 win 对象设置为 Null,所以当用户激活应用程序时,再创建一个全新的窗口即可

'activate' 事件回调函数的第二个参数 hasVisibleWindows,表示当前是否存在可见的窗口,开发者也可以利用此参数开发更人性化的功能。

在 macOS 10.14 Mojave 中,Mac 系统引入了全新的深色模式。如果你开发的应用程序为此设置了独立的样式外观,可以通过如下方法获取当前系统是否正处在深色模式下:

1
2
const { systemPreferences } = require('electron').remote
console.log(systemPreferences.isDarkMode())

此处,systemPreferences.isDarkMode() 方法被官方标记为“弃用”,推荐开发者使用nativeTheme.shouldUseDarkColors 来获取此属性,但 systemPreferences 模块只有在 Electron 7.x.y 及以后版本才可用,6.x.y 及以前版本不可用