Things I Learned (Electron)

Electron version🔗

• Electron

Electron 主要由 Node.js + Chromium 组成,不同版本对应的 Node.js 以及 Chromium 的版本都是不一样的。如果想要知道 Electron 具体使用了哪个版本的 Node.js 以及 Chromium,可以参考如下的方法:

  1. 在官方 Release 文档中查找对应版本号的具体情况;
  2. 在 Electron App 的运行环境中,使用 process.versions 来获取当前 Electron 使用到的各个组件的版本号,包括 Node.js,Chromium,V8 等。举例来说,以下是 Electron 7.1.7 所使用的具体版本信息:
{
  "node": "12.8.1",
  "v8": "7.8.279.23-electron.0",
  "uv": "1.30.1",
  "zlib": "1.2.11",
  "brotli": "1.0.7",
  "ares": "1.15.0",
  "modules": "75",
  "nghttp2": "1.39.2",
  "napi": "4",
  "llhttp": "1.1.4",
  "http_parser": "2.8.0",
  "openssl": "1.1.0",
  "icu": "64.2",
  "unicode": "12.1",
  "electron": "7.1.7",
  "chrome": "78.0.3904.130"
}

Dark Mode in Electron 5.0🔗

• Electron

新版浏览器提供了 prefers-color-scheme 这个 Media Query 用于检测当前的系统环境是否为 Dark Mode(对应的笔记见这里)。这个 API 在 Chrome 76 及 Electron 7.0 版本中提供。

对于 Electron 5 以及 Electron 6 的版本,可以通过 systemPreferences 这个 API 来进行判断。

示意代码如下:

const { systemPreferences } = require('electron');

console.log('Current: ', systemPreferences.isDarkMode());

systemPreferences.subscribeNotification(
  'AppleInterfaceThemeChangedNotification',
  function theThemeHasChanged () {
    console.log(systemPreferences.isDarkMode());
  }
);

几点说明:

  1. 代码需要在主进程执行;
  2. systemPreferences.isDarkMode API 已经废弃了(见文档),在 Electron 7.0+ 版本中,应该使用 nativeTheme.shouldUseDarkColors 进行判断(见文档)。

webkit-app-region🔗

• Electron

-webkit-app-region 是一个 Electron 中的 CSS 属性,可以用于指明用户是否可以通过拖拽当前的 HTML 元素来完成对整个窗体的拖拽。这种情况主要是针对 frameless 窗口的。因为对于 frameless 窗口来说,由于没有了顶部 toolbar,所以默认是无法让用户直接拖拽的。-webkit-app-region 相当于提供了一个编程可指明的自定义拖拽区域,用于实现类似窗口顶部 toolbar 的效果。

具体的使用方法非常简单,只需要针对特定的 HTML 元素,应用如下的 CSS 就可以了:

.draggable {
  -webkit-app-region: drag;
}

当然,这里需要注意到的一点是,如果一个区域因为某个 HTML 元素的存在变成了 -webkit-app-region: drag,那么对于 Windows 系统来说(Mac 经测试不会有这个问题),这个区域上的其他元素(不论是否在 drag 元素的“上方”)都无法收到鼠标的事件(如 click / hover 等)。如果希望可以继续保留某些元素(比如按钮)的鼠标事件,需要在这些元素上通过如下的方式显示声明:

.button {
  -webkit-app-region: no-drag;
}

Electron 相关的文档说明。

关于使用 -webkit-app-region: drag 后其他区域内元素就无法收获鼠标事件的讨论,可以参考 Electron 的这个 issue。


Require Strategy in Electron🔗

• Electron

因为 Electron 项目天然的集成了 Node.js,可以直接使用 require 命令来加载其他的模块。因此,很多项目中就不再使用 Webpack 或 Rollup 来对项目进行打包操作。简单的使用 TypeScript 或 Babel 进行转化,保证 import 转化成了 require 命令,就能顺利的跑起来了。

然而,如果细究下去,直接使用 require 依次加载各个文件和通过打包将所有需要加载的部分一次性载入,两者之间还是存在这性能上的差距。具体的数据差异,可以通过这个测试项目来实际了解。

实测中,大约 1000 个文件,打包和不打包的版本,载入的时间差距在 200ms 以上。对于用户来说,这个已经是可感知的延迟了(参考数据)。

一次 Electron require 涉及的步骤包括:

  1. 根据请求的地址,寻找文件(Electron 的 _resolveFilename 方法 > Node.js 中的 _resolveFilename 方法 > _findPath 方法 > stat 方法)
  2. 根据实际地址读取文件(Node.js 的 Module.prototype.load 方法 > Module._extensions[‘.js’] 方法 > fs.readFileSync 方法)
  3. 编译加载文件内容(Node.js 的 Module.prototype._compile 方法)

其中,第一步和第二步的 IO 都是比较耗时的操作。特别是对于第一步来说,寻找文件是一个过程。对于非相对路径的文件来说,如果不能在当前的 node_modules 下找到,Node.js 就会逐级往上寻找,直到成功或最终失败。第三步编译和运行的过程,耗时将和内容具体的长短以及具体执行的内容相关。

由于不论是直接 require 的方法还是打包的操作,最终需要执行的程序都是基本相同的(对于 Webpack 来说,有一些 runtime 代码的消耗),也就是第三步的时间两个方案都是大体相同的。因而总体上来说,两种方案的差异主要体现在第一步和第二步的耗时上。由于单个打包文件加载的方案可以节省多次 IO 的查找和读取操作,因而最终会节省不少的时间。


context menu of electron🔗

• Electron

Electron 默认是没有右键支持的,右键点击也不会有效果。为了能够提供一些右键的行为,需要在合适的时间点,手动构造菜单并显示出来。

这里对右键点击的判断,如果放在 render 层用 JavaScript 去监听 contextmenu 事件,虽然可以从 event.target 上拿到元素,但是要判断当前选择的位置、能否选择/黏贴、是否有拼写错误的单词等,都比较困难,很容易写出问题来。

Electron 暴露了 Chromium 的数据,在 WebContents 中增加了 context-menu 的事件。在这个事件的回调函数中,提供了很多的数据,能够帮助更好的了解当前的右键点击状态,从而更好地显示右键菜单项。

context-menu 的文档见这里。

这个功能提交的 Pull Request 见 #5379。

在 context-menu 的回调函数中,第二个参数提供了非常多有用的数据,比如:

  • selectionText - 选中的文字
  • misspelledWord - 当前的拼写错误单词(如果没有拼写错误,这里的返回是空字符串)
  • editFlags - 包含了 canCut, canCopy, canPaste, canSelectAll 等各式布尔值,用于表示当前右键的位置是否允许剪切/复制/黏贴/全选等操作。完整的列表可以参考文档。

一个例子:

const { remote, Menu } = require('electron');

const webContents = remote.getCurrentWebContents();

function buildMenuFromSuggestions(suggestions) {
  if (suggestions.length === 0) return [];
  return suggestions.map(function (suggestion) {
    return {
      label: suggestion,
      click: function () {
        webContents.replaceMisspelling(suggestion);
      },
    };
  }).concat([
    { type: 'separator' },
  ]);
}

webContents.on('context-menu', (event, info) => {
  const { canCut, canCopy, canPaste, canSelectAll } = info.editFlags;
  const { misspelledWord } = info;

  // use your own function of `getCorrections`
  const suggestions = getCorrections(misspelledWord);

  const menuConfig = buildMenuFromSuggestions(suggestions)
    .concat([
      { label: 'Cut', role: 'cut', enabled: canCut },
      { label: 'Copy', role: 'copy', enabled: canCopy },
      { label: 'Paste', role: 'paste', enabled: canPaste },
      {
        label: 'Select All',
        enabled: canSelectAll,
        // role: 'selectAll'
        // following shows an example of how to manually call the API
        click: webContents.selectAll,
      },
    ]);
  
  const menu = remote.Menu.buildFromTemplate(menuConfig);
  menu.popup(remote.getCurrentWindow());
});