第六章 Electron常见开发需求

本文所有源码均在:github.com/Sunny-117/e...

本文收录在《Electron桌面客户端应用程序开发入门到原理》掘金专栏

本文介绍

  • 点对点通信
  • 拼写检查
  • 窗口池
  • 原生文件的拖放
  • 最近文件列表
  • 屏幕截图

点对点通信

WebRTC,英语全称是 Web Real Time Communication,是一项实时通信技术,它允许网络应用在不借助中间媒介的情况下建立**浏览器之间点对点**的连接,实现视频流、音频流以及其他任意格式的数据传输。

WebRTC 项目始于 2011 年,Google 购买了一家名为 Global IP Solutions(GIPS)的公司,后者拥有一系列语音和视频通信技术。Google 将这些技术的一部分开源,并推动 WebRTC 成为一个开放的网络标准。自那以后,WebRTC 经过多年的发展和改进,得到了 W3C 和 IETF 的正式标准化。WebRTC 的出现,使用户在无须安装任何插件或者第三方软件的情况下,仅基于浏览器创建点对点的数据分享成为可能。

两个设备之间要建立WebRTC连接,就需要一个**信令服务器**。

信令服务器以一个"中间人"的身份帮助双方在尽可能少地暴露隐私的情况下建立连接。

比如对于音视频通话的应用来说,两端的媒体编码格式、媒体分辨率信息就是通过信令服务器互相告知对方的(这就是媒体协商),所以信令服务器最好是一个公网上的服务器,两个设备都能与这台服务器无障碍地通信。

WebRTC里面有一些核心的概念:

  • STUN(Session Traversal Utilities for NAT):STUN 服务器的主要作用是帮助端点发现其公网上的IP地址和端口号。当一个 WebRTC 应用启动时,它会向 STUN 服务器发送一个请求,STUN 服务器会回应这个端点在互联网上的公网地址和端口。这被称为 NAT 穿透。
  • TURN(Traversal Using Relays around NAT):TURN 服务器是 STUN 的一个扩展,它实际上是一个中继服务器。当 STUN 无法帮助端点建立直接连接时,TURN 服务器可以中继两个端点之间的数据包。

概括起来,sturn/turn服务器,都是在 WebRTC 通信中使用的服务器,它们帮助处理 NAT(网络地址转换)和防火墙带来的问题,使得位于这些网络限制后的设备能够相互通信。

具体的过程是两个设备通过sturn/turn服务器获取到自己映射在公网上的IP地址和端口号,再通过信令服务器告诉对方,这样双方就知道彼此的IP地址和端口号了,两个设备就可以基于此建立连接,这就是P2P打洞,也就是网络协商。注意,由于网络环境复杂,连接很可能没办法成功建立,此时sturn/turn服务器会协助传输数据,保证应用功能的正确性。

Coturn(github.com/coturn/cotu...

不过对于我们来讲,基于 C++ 的 Coturn 项目不太适合我们,这里我选择使用前端工程师更容易理解的 PeerJS(github.com/peers)项目构建W...

上下文菜单和拼写检查

上下文菜单

所谓上下文菜单,其实就是之前介绍过的右键菜单。

之前我们在实现右键菜单的时候,利用的是原生 JavaScript 来实现的。

js 复制代码
const menu = document.getElementById("menu");
// 点击右键时对应的事件
window.oncontextmenu = function (e) {
  e.preventDefault();
  menu.style.left = e.clientX + "px";
  menu.style.top = e.clientY + "px";
  menu.style.display = "block";
};

// 用户点击右键菜单上面的某一项的时候
// 注意下面的查询 DOM 的方式只会获取到第一个匹配的元素
// 因此右键菜单上面的功能只会绑定到第一个菜单项上面
document.querySelector(".menu").onclick = function () {
  console.log("这是右键菜单上面的某一个功能");
};

// 当用户点击窗口的其他地方的时候,右键菜单应该消失
window.onclick = function () {
  menu.style.display = "none";
};

实际上,在 Electron 内部也内置了上下文菜单(右键菜单)的模块

具体示例如下:

js 复制代码
const { Menu, MenuItem } = require("electron");

// 创建一个上下文菜单的实例
const contextMenu = new Menu();
// 回头我们就可以往这个 Menu 实例里面添加菜单项( MenuItem 的实例 )
contextMenu.append(
  new MenuItem({
    label: "复制",
    role: "copy",
  })
);
contextMenu.append(
  new MenuItem({
    label: "粘贴",
    role: "paste",
  })
);
contextMenu.append(
  new MenuItem({
    label: "剪切",
    role: "cut",
  })
);
module.exports = contextMenu;

创建一个 Menu 的实例,往这个 Menu 的实例里面通过 append 方法添加 MenuItem 的实例。

之后,在窗口实例上面监听 context-menu,然后调用 Menu 实例的 pop 方法来弹出上下文菜单。

js 复制代码
// 设置右键菜单
// context-menu 事件会在用户点击右键时触发
win.webContents.on("context-menu", () => {
  // contextMenu 是刚才导出的 Menu 实例
  // 在 Menu 实例上面有一个 pop 方法,可以弹出菜单
  // 这里接收了一个参数 win,回头上下文菜单就会在这个窗口上弹出
  contextMenu.popup(win);
});

那么,在实际开发中,我们究竟使用哪一种方式来实现右键菜单?两者的对比如下:

  1. Electron 的 MenuMenuItem 方法
    • 集成度高:这种方法与 Electron 应用的其他原生功能(如系统级别的剪贴板操作)更好地集成。
    • 跨平台一致性:Electron 的菜单在不同的操作系统上表现更加一致,更符合用户的操作系统习惯。
    • 性能:作为原生组件,Electron 的菜单可能在性能上有一定优势。
    • 限制:这种方法限制于 Electron 环境,不适用于纯网页应用。
  2. 原生 JavaScript 实现的上下文菜单
    • 灵活性和可定制性:使用 HTML 和 CSS,你可以更自由地设计和样式化你的菜单。
    • 适用性广:这种方法不仅适用于 Electron,还适用于任何网页应用,如果你是从网页改 Electron,那么这种方式可以直接用,不需要代码上的任何修改。
    • 控制性:你可以更精细地控制菜单的行为和交互。

拼写检查

所谓拼写检查,顾名思义,就是检查用户的输入是否正确。

要使使用拼写检查,有这么几个步骤:

1. 开启拼写检查

首先需要在 webPreferences 里面进行配置:

js 复制代码
webPreferences: {
  spellcheck: true
},

对于 Electron9以及以上,默认开启,之前的版本,需要手动开启

2. 设置你要检查的语言

注意,这里在设置检查语言的时候,不同的系统是有区别的。

windows

该系统下需要手动进行设置:

js 复制代码
// 将拼写检查器设置为检查美式英语和法语
// myWindow 就是窗口实例
myWindow.webContents.session.setSpellCheckerLanguages(['en-US', 'fr'])

// 包含所有可用语言的代码数组
const possibleLanguages = myWindow.webContents.session.availableSpellCheckerLanguages

macOS

macOS 系统下没办法手动设置,而是自动检查你当前系统的语言,根据你当前系统的语言进行检查。

3. 进行拼写检查操作

  • myWindow.webContents.replaceMisspelling(suggestion):将错误的单词进行一个替换
  • myWindow.webContents.session.addWordToSpellCheckerDictionary( ):允许用户将拼写错误的单词添加到字典里面

窗口池

1. 窗口池基本介绍

Electron创建窗口速度非常快,但渲染窗口速度很慢,从创建窗口完成到渲染窗口完成(加载本地一个简单的HTML页面)大概需要 2 秒的时间。

窗口池的原理就是提前准备n个隐藏的备用窗口,这里的 n 可以随意设置,一般情况下1~2 就足够了。让这 n 个隐藏的窗口加载一个空白页面。

  • 当用户需要使用窗口时,程序就从窗口池中取出一个备用窗口,迫使内容区域路由到用户指定的页面,然后把窗口显示出来。由于我们已经初始化了窗口所需的资源,所以路由跳转的过程是非常快的,一般不会超过0.5秒。

  • 当用户关闭窗口时,就直接把窗口实例释放掉,但程序会监听窗口的关闭事件,一旦释放了一个窗口,就马上创建一个新的隐藏窗口备用,也就是说确保窗口池中始终有n个窗口等待被使用。

窗口池的原理与线程池、数据库链接池的原理类似,创建线程或数据库链接是消耗资源非常高的操作,所以程序会创建一个"池子",提前准备好n个线程或链接,当应用程序索取时,就从"池子"里"捞出"一个空闲的线程或链接给消费者程序使用。一旦消费者程序使用完毕,要么归还线程或链接,要么直接释放,如果是直接释放的话,"池子"就要有自我创建的能力,确保"池子"里有充足的资源备用。我们这里就是采用直接释放窗口的逻辑。这主要是为了保证每次使用的窗口都具备全新的状态,而不必考虑清除上一次使用时遗留的状态。

2. 窗口池核心代码

窗口类

js 复制代码
/**
 * 窗口类
 */
class WindowItem {
  /**
   *
   * @param {*} settings 创建窗口的时候的相关配置
   */
  constructor(settings) {
    this.width = settings.width;
    this.height = settings.height;
    this.x = settings.x;
    this.y = settings.y;
    this.id = v4(); // 窗口的唯一标识
    this.window = this.createWindow();
    if (settings.url) {
      this.window.loadURL(settings.url);
    } else {
      // 如果没有url的话,就加载默认的页面
      this.window.loadFile(path.join(__dirname, "../defaultWindow/index.html"));
    }
  }
  createWindow() {
    return new BrowserWindow({
      width: this.width,
      height: this.height,
      x: this.x,
      y: this.y,
      show: false, // 一开始不显示窗口
      webPreferences: {
        nodeIntegration: true,
        contextIsolation: false,
      },
    });
  }
}
module.exports = WindowItem;

窗口池类

js 复制代码
/**
 * 窗口池类:负责管理多个窗口
 */
class WindowManager {
  constructor() {
    this.pools = []; // 存储多个窗口实例
    this.defaultSettings = {
      width: 300,
      height: 300,
      x: 100,
      y: 100,
      url: null,
    };
  }
  /**
   * 初始化方法,用于初始化窗口池里面的窗口
   * 按照默认配置初始化 3 个窗口
   */
  init(n = 3) {
    for (let i = 0; i < n; i++) {
      this.createDefaultSettingWindow();
    }
    console.log(this.pools);
  }
  /**
   * 按照默认配置来创建窗口
   */
  createDefaultSettingWindow() {
    this.pools.push(new WindowItem(this.defaultSettings));
  }
  /**
   * 获取窗口池中窗口的数量
   */
  getWindowCount() {
    return this.pools.length;
  }
  /**
   * 从窗口池里面拿一个窗口出来
   */
  getWindow() {
    return this.pools.shift();
  }
}
module.exports = WindowManager;

原生文件拖放

所谓原生文件拖放(Native File Drag and Drop)是指**操作系统级别**支持的一种用户界面交互模式,允许用户通过鼠标或其他指针设备,将文件从一个位置拖动并释放(或"放置")到另一个位置。

ev.sender.startDrag 方法是 Electron 中用于启动原生拖拽操作的API。这个方法属于 WebContents 类,通常在 Electron 的主进程中使用,用于响应渲染进程的请求来开始一个拖拽操作。它允许应用程序指定拖拽过程中的文件和可选的自定义图标。

startDrag基本用法

startDrag 方法接受一个对象参数,该对象包含拖拽操作的相关信息。最基本的属性包括:

  • file:要拖动的文件的路径。这是一个必须提供的属性。
  • icon:拖拽操作中显示的图标的路径。这是一个可选属性,用于自定义拖拽时的图标。

下面是一个简单的示例:

js 复制代码
ipcMain.on('ondragstart', (event, filePath) => {
  event.sender.startDrag({
    file: filePath,
    icon: path.join(__dirname, 'path/to/icon.png')
  });
});

没有拖拽完成的回调

关于拖拽操作的完成,startDrag 方法本身并不提供直接的回调或事件来通知拖拽完成。

这是**因为拖拽操作是与操作系统交互的**,操作的结果(如用户将文件拖��到哪里)并不反馈给 Electron 应用程序。

虽然没有直接的回调,但你可以通过其他机制来监听或处理拖拽完成后的情况:

  • 监听目标位置的事件:如果拖拽的目标是应用程序内部的某个部分,你可以在那个部分监听 drop 事件来响应文件被拖拽到那里。
  • 利用操作系统级别的通知:如果拖拽操作涉及到应用程序外部,比如拖拽文件到桌面或其他应用,通常没有办法直接从 Electron 获取操作完成的通知。在这种情况下,应用程序需要根据具体的使用场景来设计反馈机制。例如使用 Electron 的 Notification API 来发送桌面通知。

屏幕截图

我们来看一下如何实现屏幕截图这个需求。

screen

screen 模块来自于 electron.

对应文档地址:www.electronjs.org/zh/docs/lat...

这里我们主要用到的是一个 getPrimaryDisplay 方法,该方法用于返回主窗口的 Display 对象,这个对象包含了关于主显示器的各种信息,如尺寸、分辨率、工作区大小等。

Display 对象的属性包括但不限于:

  • bounds: 显示器的绝对坐标和大小,包含 xywidthheight
  • workArea: 显示器的工作区域,不包括系统任务栏和Dock,也是应用程序可用来显示内容的区域,同样包含 xywidthheight
  • scaleFactor: 显示器的缩放因子,适用于高DPI显示器。
  • rotation: 显示器的旋转角度(如90、180、270度)。

workAreaSize 是从 getPrimaryDisplay() 返回的 Display 对象中访问的一个属性,它提供了主显示器工作区的尺寸。这个属性是 workArea 的简化版,仅包含 widthheight 两个字段,分别表示工作区的宽度和高度(以像素为单位)。

这个属性非常有用,因为它告诉你应用程序可以使用的实际屏幕空间大小,排除了任务栏、Dock、窗口装饰等占用的空间。这使得开发者可以根据工作区大小来设计应用的布局和尺寸,确保应用窗口不会被这些系统元素覆盖或挤出屏幕边界。

使用场景举例

假设你想要创建一个新的窗口,恰好填充整个主显示器的工作区,不被系统任务栏或Dock遮挡,可以这样实现:

js 复制代码
const { screen } = require('electron');
const mainWindow = new BrowserWindow({
  width: screen.getPrimaryDisplay().workAreaSize.width,
  height: screen.getPrimaryDisplay().workAreaSize.height
});

desktopCapturer

desktopCapturer 模块也是来自于 electorn.

对应的文档地址:www.electronjs.org/zh/docs/lat...

desktopCapturer.getSources(options) 是 Electron 中的一个方法,用于枚举和获取可用的媒体源,以便进行屏幕捕获。

该方法接收一个 options 参数,该参数是一个对象,用于指定获取源的类型和其他参数。主要的属性包括:

  • types:一个字符串数组,定义要获取的源的类型。可用的类型包括 screenwindow,分别代表整个屏幕和单个窗口。
  • thumbnailSize:一个对象,指定返回的缩略图的大小。它应包含 widthheight 属性。

该方法的返回值是一个 promise,这个 promise 解析为一个包含源信息的数组。

每个源(即每个屏幕或窗口)是一个对象,包含如下属性:

  • id:源的唯一标识符,用于后续捕获此源的屏幕或窗口。
  • name:源的名称,如窗口标题或屏幕编号。
  • thumbnail:源的缩略图,是一个 nativeImage 对象,可以用于预览。

使用场景举例

js 复制代码
// 引入 Electron 的 desktopCapturer 模块。
// desktopCapturer 用于访问屏幕捕获功能,允许应用捕捉屏幕和单独窗口的视频流。
const { desktopCapturer } = require('electron');

// 定义一个异步函数 getSources,用于获取屏幕和窗口的源信息。
async function getSources() {
  try {
    // 使用 desktopCapturer.getSources 方法异步获取所有屏幕和窗口的源。
    // 这个方法接受一个 options 对象作为参数,用于指定要获取的源的类型和缩略图大小。
    const sources = await desktopCapturer.getSources({
      // types 属性是一个数组,指定了需要获取哪些类型的源。
      // 'window' 表示窗口源,'screen' 表示屏幕源。
      types: ['window', 'screen'],
      // thumbnailSize 属性指定返回的缩略图的大小。
      // 在这个例子中,缩略图的宽度被设置为 128 像素,高度被设置为 72 像素。
      thumbnailSize: { width: 128, height: 72 }
    });

    // 遍历获取到的源信息数组。
    for (const source of sources) {
      // 打印每个源的名称到控制台。
      // source.name 通常是窗口的标题或屏幕的标识。
      console.log('Source:', source.name);
      // source.id 是捕获源的唯一标识符,可以用于实际的屏幕捕获操作。
      // source.thumbnail 是源的缩略图,是一个 nativeImage 对象,可以用于界面上显示预览。
      // 这里的示例代码仅打印了源的名称,实际应用中可以使用 source.id 和 source.thumbnail 来实现屏幕捕获和预览功能。
    }
  } catch (err) {
    // 如果在获取源信息的过程中发生错误,则捕获这个错误并打印到控制台。
    console.error('Error getting sources:', err);
  }
}

// 调用 getSources 函数,开始获取屏幕和窗口的源信息。
getSources();

这段代码展示了如何使用 Electron 的 desktopCapturer 模块来获取和处理屏幕及窗口捕获源的基本流程。通过调整 typesthumbnailSize 参数,开发者可以根据具体需求获取不同类型的源和合适大小的缩略图。

nativeImage

同样是 electron 所提供的模块。

文档地址:www.electronjs.org/zh/docs/lat...

该模块提供了一组 API 来创建和管理图像。这个模块的主要作用是允许开发者在 Electron 应用中以编程方式处理图像数据,包括从文件、数据URL、剪贴板或其他 nativeImage 实例中创建图像,以及获取和修改图像的属性。

使用场景

nativeImage 模块在 Electron 应用中有广泛的应用场景:

  • 应用图标 :使用 nativeImage 来设置应用窗口的图标(BrowserWindowapp 图标)。
  • 托盘图标 :在系统托盘区域显示的图标也可以通过 nativeImage 来创建和管理。
  • 剪贴板操作 :可以使用 nativeImage 来读取或写入剪贴板中的图像内容。
  • 动态图像处理 :对于需要动态生成或处理图像的应用,如图像编辑器或工具,nativeImage 提供了基本的图像处理能力。

示例代码

创建一个 nativeImage 实例从本地文件:

js 复制代码
const { nativeImage } = require('electron');
let image = nativeImage.createFromPath('/path/to/image.png');

从数据URL创建一个 nativeImage 实例:

js 复制代码
let dataUrl = 'data:image/png;base64,...';
let image = nativeImage.createFromDataURL(dataUrl);

设置应用窗口图标:

js 复制代码
const { BrowserWindow, nativeImage } = require('electron');
let win = new BrowserWindow({
  icon: nativeImage.createFromPath('/path/to/icon.png')
});

nativeImage 模块通过提供这些功能,极大地增强了 Electron 应用在图像处理方面的能力,使得开发者能够更灵活地在其应用中使用和操作图像。

在我们的代码中,会用到 createFromDataURL 方法,该方法用于从数据 URL(Data URL)创建一个 nativeImage 对象。

假设你有一个图像的数据 URL,想要将其作为应用的窗口图标。这时,你可以使用 nativeImage.createFromDataURL(dataUrl) 方法来创建一个 nativeImage 对象,然后将其设置为窗口的图标。

js 复制代码
const { BrowserWindow, nativeImage } = require('electron');

// 假设这是一个图像的数据URL
let dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...';

// 使用 nativeImage.createFromDataURL 方法从数据URL创建一个 nativeImage 对象
let image = nativeImage.createFromDataURL(dataUrl);

// 创建一个新的 BrowserWindow 实例
let win = new BrowserWindow({
  // 使用创建的 nativeImage 对象作为窗口图标
  icon: image
});

// 加载应用的 HTML 文件或任何其他内容
win.loadURL('file://' + __dirname + '/index.html');

requestSingleInstanceLock 方法

requestSingleInstanceLock 是属于 app 模块上面的一个方法。

对应的方法地址:www.electronjs.org/zh/docs/lat...

用于确保应用程序只有一个实例在运行。当你的应用尝试启动一个新实例时,这个方法可以帮助你检测到这种情况,并允许你将焦点转移到原有的实例上,而不是打开一个新的窗口或实例。这对于开发那些不应该或不需要同时运行多个实例的应用尤其有用,例如,编辑器、工具应用或某些类型的媒体播放器。

app.requestSingleInstanceLock() 被调用时,Electron 会检查当前应用的实例是否已经获得了"单实例锁"。如果没有,当前实例会尝试获取这个锁:

  • 如果成功获取到锁,意味着当前实例是第一个启动的实例,应用会正常启动,返回值为 true。
  • 如果没有获取到锁,意味着已经有一个实例在运行了,返回值为 false,当前尝试启动的实例会触发 second-instance 事件并退出,开发者可以监听这个事件来处理额外的逻辑,比如将焦点转移到已经运行的实例的窗口上。

代码示例

js 复制代码
const { app } = require('electron');

const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
  // 如果获取锁失败,说明已经有一个实例在运行,当前实例应该退出。
  app.quit();
} else {
  // 如果成功获取到锁,当前实例是第一个实例,应该继续运行应用。
  // 创建窗口的逻辑...
  
  // 然后我们为应用绑定 second-instance 事件
  app.on('second-instance', (event, commandLine, workingDirectory) => {
    // 当尝试启动第二个实例时,该事件会被触发。
    // 你可以在这里处理将焦点转移到你的主窗口上等逻辑。
    // commandLine: 启动第二个实例的命令行参数
    // workingDirectory: 第二个实例的工作目录
  });
}

本文所有源码均在:github.com/Sunny-117/e...

「❤️ 感谢大家」

如果你觉得这篇内容对你挺有有帮助的话: 点赞支持下吧,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。觉得不错的话,也可以阅读 Sunny 近期梳理的文章(感谢掘友的鼓励与支持 🌹🌹🌹):

我的博客:

Github: https://github.com/sunny-117/

前端八股文题库: sunny-117.github.io/blog/

前端面试手写题库: github.com/Sunny-117/j...

手写前端库源码教程: sunny-117.github.io/mini-anythi...

热门文章

专栏

相关推荐
轻口味1 小时前
命名空间与模块化概述
开发语言·前端·javascript
前端小小王2 小时前
React Hooks
前端·javascript·react.js
迷途小码农零零发2 小时前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀2 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪3 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef4 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6415 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻5 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云5 小时前
npm淘宝镜像
前端·npm·node.js