【Electron Playground 系列】窗口篇

作者:Kurosaki

本文主要讲解Electron 窗口的 API 和一些在开发之中遇到的问题。

官方文档 虽然比较全面,但是要想开发一个商用级别的桌面应用必须对整个 Electron API  有较深的了解,才能应对各种需求。

1. 创建窗口

通过BrowserWindow,来 创建 或者 管理 新的浏览器窗口,每个浏览器窗口都有一个进程来管理。

1.1. 简单创建窗口

const { BrowserWindow } = require('electron');
const win = new BrowserWindow();
win.loadURL('https://github.com');

效果如下:

1.1.2. 优化

问题electronBrowserWindow 模块在创建时,如果没有配置 show:false,在创建之时就会显示出来,且默认的背景是白色;然后窗口请求 HTML,会出现视觉闪烁。

解决

const { BrowserWindow } = require('electron');
const win = new BrowserWindow({ show:false });

win.loadURL('https://github.com');

win.on('ready-to-show',()=>{
    win.show();
})

两者对比有很大的区别

1.2. 管理窗口

所谓的管理窗口,相当于主进程可以干预窗口多少。

  • 窗口的路由跳转

  • 窗口打开新的窗口

  • 窗口大小、位置等

  • 窗口的显示

  • 窗口类型(无边框窗口、父子窗口)

  • 窗口内 JavaScript 的 node 权限,预加载脚本等

  • ....

这些个方法都存在于BrowserWindow模块中。

1.2.1. 管理应用创建的窗口

BrowserWindow模块在创建窗口时,会返回 窗口实例,这些 **窗口实例 **上有许多功能方法,我们利用这些方法,管理控制这个窗口。

在这里使用Map对象来存储这些 窗口实例

const BrowserWindowsMap = new Map<number, BrowserWindow>()
let mainWindowId: number;

const browserWindows = new BrowserWindow({ show:false })
browserWindows.loadURL('https://github.com')
browserWindows.once('ready-to-show', () => {
  browserWindows.show()
})
BrowserWindowsMap.set(browserWindow.id, browserWindow)
mainWindowId = browserWindow.id  // 记录当前窗口为主窗口

窗口被关闭,得把Map中的实例删除。

browserWindow.on('closed', () => {
  BrowserWindowsMap?.delete(browserWindowID)
})

1.2.2. 管理用户创建的窗口

主进程可以控制窗口许多行为,这些行为会在后续文章一一列举;以下以主进程控制窗口建立新窗口的行为为例。

使用new-window监听新窗口创建

// 创建窗口监听
browserWindow.webContents.on('new-window', (event, url, frameName, disposition) => {
  /** @params {string} disposition
  *  new-window : window.open调用
  *  background-tab: command+click
  *  foreground-tab: 右键点击新标签打开或点击a标签target _blank打开
  * /
})

注:关于disposition字段的解释,移步electron文档electron源码chrome 源码

扩展new-window

经过实验,并不是所有新窗口的建立, new-window 都能捕捉到的。

以下方式打开的窗口可以被new-window事件捕捉到

window.open('https://github.com')
<a href='https://github.com' target='__blank'>链接</a>

**
渲染进程中使用BrowserWindow创建新窗口,不会被 new-window事件捕捉到
**

const { BrowserWindow } = require('electron').remote
const win = new BrowserWindow()
win.loadURL('https://github.com')

_渲染进程访问 __remote_ _,主进程需配置enableRemoteModule:true _
使用这种方式同样可以打开一个新的窗口,但是主进程的new-window捕捉不到。

应用new-window
new-window 控制着窗口新窗口的创建,我们利用这点,可以做到很多事情;比如链接校验、浏览器打开链接等等。默认浏览器打开链接代码如下:

import { shell } from 'electron'
function openExternal(url: string) {
  const HTTP_REGEXP = /^https?:\/\//
  // 非http协议不打开,防止出现自定义协议等导致的安全问题
  if (!HTTP_REGEXP) {
    return false
  }
  try {
    await shell.openExternal(url, options)
    return true
  } catch (error) {
    console.error('open external error: ', error)
    return false
  }
}
// 创建窗口监听
browserWindow.webContents.on('new-window', (event, url, frameName, disposition) => {
  if (disposition === 'foreground-tab') {
      // 阻止鼠标点击链接
      event.preventDefault()
      openExternal(url)
  }
})

_关于 __shell_ 模块,可以查看官网 https://www.electronjs.org/docs/api/shell
_

1.3. 关闭窗口

**close** **事件和 ****closed** 事件
close 事件在窗口将要关闭时之前触发,但是在 DOM 的 beforeunload 和 unload 事件之前触发。

// 窗口注册close事件
win.on('close',(event)=>{
event.preventDefault()  // 阻止窗口关闭
})

closed 事件在窗口关闭后出触发,但是此时的窗口已经被关闭了,无法通过 event.preventDefault() 来阻止窗口关闭。

win.on('closed', handler)

主进程能够关闭窗口的 API 有很多,但都有各自的利弊。

1.3.1. win.close()

关于这个 API 的利弊

  1. 如果当前窗口实例注册并阻止close事件,将不会关闭页面,而且也会 阻止计算机关闭(必须手动强制退出);

  2. 关闭页面的服务,如websocket,下次打开窗口,窗口中的页面会 重新渲染

  3. 通过这个API触发的close事件在 unloadbeforeunload之前触发,通过这点可以实现 关闭时触发弹窗

完整代码在github:electron-playground

  1. 会被closed事件捕捉到。

1.3.2. win.destroy()

  1. 强制退出,无视close事件(即:无法通过event.preventDefault()来阻止);

  2. 关闭页面,以及页面内的服务,下次打开窗口,窗口中的页面会重新渲染;

  3. 会被closed事件捕捉到。

1.3.3. win.hide()

这个隐藏窗口。

  1. 隐藏窗口,会触发hideblur事件,同样也是可以通过event.preventDefault()来阻止

  2. 只是隐藏窗口,通过win.show(),可以将窗口显现,并且会保持原来的窗口,里面的服务也不会挂断

2. 主窗口隐藏和恢复

2.1. 主窗口

2.1.1. 为什么需要 主窗口?

一个应用存在着许多的窗口,需要一个窗口作为 主窗口,如果该窗口关闭,则意味着整个应用被关闭。
场景:在应用只有一个页面的时,用户点击关闭按钮,不想让整个应用关闭,而是隐藏;
例如:其他的APP,像微信,QQ等桌面端。

利用上文中提到的关闭窗口的 API ,我们实现一个主窗口的隐藏和恢复。

改造一下 close 事件

let mainWindowId: number // 用于标记主窗口id

const browserWindow = new BrowserWindow()

// 记录下主窗口id
if (!mainWindowId) {
  mainWindowId = browserWindow.id
}

browserWindow.on('close', event => {
  // 如果关闭的是主窗口,阻止
  if (browserWindow.id === mainWindowId) {
    event.preventDefault()
    browserWindow.hide()
  }
})

2.1.2. 恢复主窗口显示

能隐藏,就能恢复。

const mainWindow = BrowserWindowsMap.get(mainWindowId)
if (mainWindow) {
  mainWindow.restore()
  mainWindow.show()
}

**mainWindow.show()** 方法:功能如其名,就是“show出窗口”。
_为什么要是有 __mainWindow.restore()_ 
_windows_ _下如果 __hide_ _之后不调用 __show_ _方法而是只调用 __restore_ 方法就会导致页面挂住不能用

2.1.3. 强制关闭主窗口

有些场景下,可能需要的强制退出,附上代码:

const mainWindow = BrowserWindowsMap.get(mainWindowId)
if (mainWindow) {
  mainWindowId = -1
  mainWindow.close()
}

存在的问题

我们改变了 Electron 窗口的既定行为,就会有许多场景下会有问题

问题一:因为阻止了 close 事件,导致 关机 时无法关闭 主窗口,可以使用如下代码

app.on('before-quit', () => {
    closeMainWindow()
})

在 macOS Linux Windows 下都可以。

问题二:为避免启动 多个应用

app.on('second-instance', () => {
  const mainWindow = BrowserWindowsMap.get(mainWindowId)
  if (mainWindow) {
    mainWindow.restore()
    mainWindow.show()
  }
})

在 macOS Linux Windows 下都可以

问题三:首次启动应用程序、尝试在应用程序已运行时或单击 应用程序坞站任务栏图标 时重新激活它

app.on('activate', () => {
  if (mainWindow) {
    mainWindow.restore()
    mainWindow.show()
  }
})

只应用于macOS

问题四: 双击托盘图标 打开APP

tray.on('double-click', () => {
  if (mainWindow) {
    mainWindow.restore()
    mainWindow.show()
  }
})

这样每个环节的代码都有,即可实现,具体代码可参见链接

3. 窗口的聚焦和失焦

3.1. 聚焦

3.1.1. 创建窗口时配置

const { BrowserWindow } = require('electron');
const win = new BrowserWindow();
win.loadURL('https://github.com')

focusable:true  窗口便可聚焦,便可以使用聚焦的 API 
focusable:falseWindows 中设置 focusable: false 也意味着设置了skipTaskbar: true. 在 Linux 中设置 focusable: false 时窗口停止与 wm 交互, 并且窗口将始终置顶;

以下讨论的情况仅为focusable:true情况下

const { BrowserWindow } = require('electron');
const win = new BrowserWindow() // focusable:true 为默认配置

罗列了一下 API

3.1.2. 关于聚焦的API

API 功能
BrowserWindow.getFocusedWindow() 来获取聚焦的窗口
win.isFocused() 判断窗口是否聚焦
win.on('focus',handler) 来监听窗口是否聚焦
win.focus() 手动聚焦窗口

3.1.3. 其他API副作用和聚焦有关的:

API 功能
win.show() 显示窗口,并且聚焦于窗口
win.showInactive() 显示窗口,但是不会聚焦于窗口

3.2. 失焦

3.2.1. 关于失焦的api

API 功能
win.blur() 取消窗口聚焦
win.on('blur',cb) 监听失焦

3.2.2. 其他api副作用和失焦有关的:

api 功能
win.hide() 隐藏窗口,并且会触发失焦事件

4. 窗口类型

4.1. 无边框窗口

4.1.1. 描述

无边框窗口是不带外壳(包括窗口边框、工具栏等),只含有网页内容的窗口

4.1.2. 实现

Windows macOS Linux

const { BrowserWindow } = require('electron')
let win = new BrowserWindow({ width: 800, height: 600, frame: false })
win.show()

macOS下,还有不同的实现方式,官方文档

4.1.3. macOS 下独有的无边框

  • 配置titleBarStyle: 'hidden'

返回一个隐藏标题栏的全尺寸内容窗口,在左上角仍然有标准的窗口控制按钮(俗称“红绿灯”)

// 创建一个无边框的窗口
const { BrowserWindow } = require('electron')
let win = new BrowserWindow({ titleBarStyle: 'hidden' })
win.show()

效果如下:

  • 配置titleBarStyle: 'hiddenInset'

返回一个另一种隐藏了标题栏的窗口,其中控制按钮到窗口边框的距离更大。

// 创建一个无边框的窗口
const { BrowserWindow } = require('electron')
let win = new BrowserWindow({ titleBarStyle: 'hiddenInset' })
win.show()

效果如下:

配置titleBarStyle: 'customButtonsOnHover'

效果如下:

4.1.4. 窗口顶部无法拖拽的问题

虽然无边框窗口,很美观,可以自定义title;但是改变了Electron窗口顶部的默认行为,就需要使用代码来兼容它,实现其原来承担的功能。

出现上述情况,是因为在默认情况下, 无边框窗口是不可拖拽的。 应用程序需要在 CSS 中指定 -webkit-app-region: drag 来告诉 Electron 哪些区域是可拖拽的(如操作系统的标准标题栏),在可拖拽区域内部使用 -webkit-app-region: no-drag 则可以将其中部分区域排除。 请注意, 当前只支持矩形形状。完整文档

使用-webkit-app-region: drag 来实现拖拽,但是会导致内部的click事件失效。这个时候可以将需要click元素设置为-webkit-app-region: no-drag。具体的细节 Electron 的issues

为了不影响窗口内的业务代码,这里拖拽的代码,应该在preload触发。

preload 代码运行,在窗口代码运行之前

核心代码:

// 在顶部插入一个可以移动的dom
function initTopDrag() {
  const topDiv = document.createElement('div') // 创建节点
  topDiv.style.position = 'fixed' // 一直在顶部
  topDiv.style.top = '0'
  topDiv.style.left = '0'
  topDiv.style.height = '20px' // 顶部20px才可拖动
  topDiv.style.width = '100%' // 宽度100%
  topDiv.style.zIndex = '9999' // 悬浮于最外层
  topDiv.style.pointerEvents = 'none' // 用于点击穿透
  // @ts-ignore
  topDiv.style['-webkit-user-select'] = 'none' // 禁止选择文字
  // @ts-ignore
  topDiv.style['-webkit-app-region'] = 'drag' // 拖动
  document.body.appendChild(topDiv) // 添加节点
}

window.addEventListener('DOMContentLoaded', function onDOMContentLoaded() {
    initTopDrag()
})

在创建窗口时引用 preload 即可

const path = require('path')
const { BrowserWindow } = require('electron')

const BaseWebPreferences = {
  nodeIntegration: true,
  preload: path.resolve(__dirname, './windowType.js'), // 这里引用preload.js 路径
}

// 主窗口代码
const win = new BrowserWindow({ webPreferences: BaseWebPreferences, frame: false, titleBarStyle: 'hiddenInset' })
win.loadURL('https://github.com')

便可实现窗口顶部拖拽

_tips: 如果窗口打开了 __devtools_ ,窗口也是可以拖拽的,只不过这个拖拽体验不好

4.2. 父子窗口

所谓的父子窗口,就是子窗口永远在父窗口之上,只要子窗口存在,哪怕位置不在父窗口上方,都是无法操作父窗口

const { BrowserWindow } = require('electron')

let top = new BrowserWindow()
let child = new BrowserWindow({ parent: top })
child.show()
top.show()

窗口之间通信 章节中介绍到父子窗口之间的通信;通过 getParentWindow 拿到父窗口的 类BrowserWindowProxy,通过 win.postMessage(message,targetOrigin) 实现通信

4.3. 模态窗口

模态窗口也是一种父子窗口,只不过展示会有不同

const { BrowserWindow } = require('electron')

let top = new BrowserWindow()
let child = new BrowserWindow({ parent: top, modal: true, show: false })
child.loadURL('https://github.com')
child.once('ready-to-show', () => {
  child.show()
})

5. 窗口之间的通信

实现窗口通信必须不影响窗口内的业务代码, jdk 等的注入

5.1. 主进程干预方式

主进程是可以干预渲染进程生成新的窗口的,只需要在创建窗口时,webContents 监听 new-window

import path from 'path'
import { PRELOAD_FILE } from 'app/config'
import { browserWindow } from 'electron';

const BaseWebPreferences: Electron.BrowserWindowConstructorOptions['webPreferences'] = {
  nodeIntegration: true,
  webSecurity: false,
  preload: path.resolve(__dirname, PRELOAD_FILE),
}

// 创建窗口监听
browserWindow.webContents.on('new-window', (event, url, frameName, disposition) => {
    event.preventDefault()
    // 在通过BrowserWindow创建窗口
    const win = new BrowserWindow({
      show:false,
      webPreferences: {
        ...BaseWebPreferences,
        additionalArguments:[`--parentWindow=${browserWindow.id}`] // 把父窗口的id传过去
      }
    });
    win.loadURl(url);
    win.once('ready-to-show',()=>{
        win.show()
    })
})

preload.js  文件window.process.argv,便能拿到父窗口的id,window.process.argv是一个字符串数组,可以使用yargs来解析

preload.js  代码

import { argv } from 'yargs'
console.log(argv);

拿到了父窗口的 id ,封装一下通信代码,挂载到 window 上

/**
 * 这个是用于窗口通信例子的preload,
 * preload执行顺序在窗口js执行顺序之前
 */
import { ipcRenderer, remote } from 'electron'
const { argv } = require('yargs')

const { BrowserWindow } = remote

// 父窗口监听子窗口事件
ipcRenderer.on('communication-to-parent', (event, msg) => {
  alert(msg)
})

const { parentWindowId } = argv
if (parentWindowId !== 'undefined') {
  const parentWindow = BrowserWindow.fromId(parentWindowId as number)
  // 挂载到window
  // @ts-ignore
  window.send = (params: any) => {
    parentWindow.webContents.send('communication-to-parent', params)
  }
}

应用一下试试看:

这种方法可以实现通信,但是太麻烦了。

5.2. 父子窗口通信

和主进程干预,通过ipc通信方式差不多,只是利用父子窗口这点,不用通过additionalArguments传递父窗口id,在子窗口通过window.parent,就可以拿到父窗口

browserWindow.webContents.on('new-window', (event, url, frameName, disposition) => {
    event.preventDefault()

    // 在通过BrowserWindow创建窗口
    const win = new BrowserWindow({
        show:false,
        webPreferences:BaseWebPreferences,
        parent:browserWindow // 添加父窗口
      });
    win.loadURl(url);
    win.once('ready-to-show',()=>{
        win.show()
    })

})

弊端:子窗口永远在父窗口之上。

const path = require('path')
const { BrowserWindow } = require('electron')

const BaseWebPreferences = {
  // // 集成node
  nodeIntegration: true,
  // // 禁用同源策略
  // webSecurity: false,
  // 预加载脚本 通过绝对地址注入
  preload: path.resolve(__dirname, './communication2.js'),
}

// 主窗口代码
const parent = new BrowserWindow({ webPreferences: BaseWebPreferences, left: 100, top: 0 })
parent.loadURL(
  'file:///' + path.resolve(__dirname, '../playground/index.html#/demo/communication-part2/main'),
)
parent.webContents.on('new-window', (event, url, frameName, disposition) => {
  // 阻止默认事件
  event.preventDefault()
  // 在通过BrowserWindow创建窗口
  // 子窗口代码
  const son = new BrowserWindow({
    webPreferences: BaseWebPreferences,
    parent,
    width: 400,
    height: 400,
    alwaysOnTop: false,
  })
  // son.webContents.openDevTools();
  son.loadURL(
    'file:///' +
      path.resolve(__dirname, '../playground/index.html#/demo/communication-part2/client'),
  )
})

preload.js

import { remote, ipcRenderer } from 'electron'

// 父窗口监听子窗口事件
ipcRenderer.on('communication-to-parent', (event, msg) => {
  alert(msg)
})

const parentWindow = remote.getCurrentWindow().getParentWindow()
// @ts-ignore
window.sendToParent = (params: any) =>
  parentWindow.webContents.send('communication-to-parent', params)

但是必须得是父子窗口,有弊端。

5.3. 使用window.open

终极方法

web 端,使用 window.open  会返回一个 windowObjectReference ,通过这个方法可以实现 postMessage ;但是在 Electron 端,把 window.open 方法重新定义了;使用 window.open 创建一个新窗口时会返回一个 BrowserWindowProxy对象,并提供一个有限功能的子窗口.
MDN文档 Electron文档

const  BrowserWindowProxy = window.open('https://github.com', '_blank', 'nodeIntegration=no')
BrowserWindowProxy.postMessage(message, targetOrigin)

代码精简,且需要的功能,即符合 BrowserWindow(options) 中 options 配置的,都可以使用 window.open 配置。

6. 全屏、最大化、最小化、恢复

6.1. 全屏

6.1.1. 创建时进入全屏

配置new BrowserWindow({ fullscreen:true })

const { BrowserWindow } = require('electron')
const win = new BrowserWindow({ fullscreen:true,fullscreenable:true })
win.loadURL('https://github.com')

6.1.2. 使用API进入全屏

确保当前窗口的fullscreenable:true,以下API才能使用

  1. win.setFullScreen(flag),设置全屏状态;

  2. win.setSimpleFullScreen(flag)macOS下独有,设置简单全屏。

6.1.3. 全屏状态的获取

  1. win.fullScreen,来判断当前窗口是否全屏;

  2. win.isFullScreen()macOS独有;

  3. win.isSimpleFullScreen()macOS独有。

6.1.4. 全屏事件的监听

  1. rezise 调整窗口大小后触发;

  2. enter-full-screen 窗口进入全屏状态时触发;

  3. leave-full-screen 窗口离开全屏状态时触发;

  4. enter-html-full-screen 窗口进入由HTML API 触发的全屏状态时触发;

  5. leave-html-full-screen 窗口离开由HTML API触发的全屏状态时触发。

6.1.5. HTML API无法和窗口联动问题

const path = require('path')
const { BrowserWindow } = require('electron')
const BaseWebPreferences = {
    nodeIntegration: true,
    preload: path.resolve(__dirname, './fullScreen.js'),
};
const win = new BrowserWindow({ webPreferences: BaseWebPreferences })
win.loadURL('file:///' + path.resolve(__dirname, '../playground/index.html#/demo/full-screen'))

使用按钮全屏和退出全屏是可以的,但是先点击左上角

(0)

相关推荐

  • 我们要放弃Windows 7吗?微软准备弹全屏“广告”催更Win10了

    在今年3月份的时候,微软就已经向家庭版用户发出提醒,表示Windows 7系统的支持将会在2020年1月14日终止,并在其网站上发出通告.直到10月份,Windows 7专业版系统用户也开始收到了相同 ...

  • Nativefier

    前言 一切为了原神,哈哈哈 仓库: https://github.com/nativefier/nativefier 内容 安装 npm install -g nativefier Node.js & ...

  • Electron Python界面开发(通过zerorpc)

    Python 开发GUI要么太繁琐要么太丑,而前端技术恰巧是最适合做漂亮UI的.所以考虑将Python和前端技术结合,通过进程通信和前端框架交流,打包成一个完整的桌面APP.教程分成两种实现方式,一个 ...

  • 【Electron Playground 系列】文件下载篇

    作者:long.woo 文件下载是我们开发中比较常见的业务需求,比如:导出 excel. web 应用文件下载存在一些局限性,通常是让后端将响应的头信息改成 Content-Disposition: ...

  • 战国历史科普系列补充篇——《皓镧传》的原型赵姬究竟是一个什么样的女人?

    刘焕曰:三国历史科普系列完结了,每个系列十篇,从这一期开始,每一期的乱世文章最后都补充一篇,乱世的女人,从神秘的赵姬开始. 魏璎珞 <皓镧传>曾经在荧幕掀起收视热潮,原型赵姬也是一位传奇女 ...

  • 【自传体】退休殇感★心思絮语(散文随笔系列前言篇)

     [自传体]退休殇感★心思絮语(散文随笔系列前言篇)---    作者///桂西老庞说 [内容简介//前言]: 是啊,在我的这一生中,飘零创痕曲折,真的是"我昔自蜀归,百年已过半.观棋未终局 ...

  • 【03】退休殇感★心思絮语(散文随笔系列之三篇)

    [03]退休殇感★心思絮语(散文随笔系列之三篇)--- 作者///桂西老庞说 - 题记:春花秋月何时了,往事知多少:退休的光景里,才知道那些曾经创痕过往,在心思絮语里,形成一行行象形文字所组成的斑痕韵 ...

  • 【04】退休殇感★心思絮语(散文随笔系列之四篇)

    [04]退休殇感★心思絮语(散文随笔系列之四篇)--- 作者///桂西老庞说 - 题记:春花秋月何时了,往事知多少:退休的光景里,才知道那些曾经创痕过往,在心思絮语里,形成一行行象形文字所组成的斑痕韵 ...

  • 技术系列—第二篇

    上一课我们学习了K线的包含关系,现在交易软件上的K线应该都是没有包含关系的K线了.那么我们学习下一步-- 为了形象直观,我们用实物苹果演示一下: 正品字如同山顶的形状 –顶分 倒品字如同山底的形状-底 ...

  • 基本面投资系列第一篇——为什么要做价值投资?

    之前每周的周五.周六都没更新,现在在思考这两天要不要利用起来,写一些除了财报和基金播报之外的文章,现在初步想到的是写一些基本面投资的常识吧,虽然说接触这一块也不是很多年,但是对于很多小白来说,应该会有 ...

  • 基本面投资系列第二篇——财务指标分析是基础

    上周六写了第一篇价值投资的文章,今晚抽空写的第二篇,主要给大家讲一讲价值投资里面最基础的东西--财务指标分析. 我没做过统计,不太清楚多少股民朋友不了解财务指标分析这一投资领域比较基础的知识.可能有些 ...

  • 剑桥艺术史:绘画欣赏 ·13创作水平(本系列末篇)

    13 创作水平 艺术家都要和自己的技能作斗争,他们使用的材料有时很棘手,有时表现出事前不知道的特点.但对艺术家来说,最困难的是如何用颜料.木炭.马赛克或彩色玻璃等把他们想表现的东西按他们观察到的形象丝 ...