为何 remote 模块是有害的

为何 remote 模块是有害的

从最早的 Electron 版本开始,remote 模块就一直是主进程和渲染器进程之间进行通信的首选工具。它的基本前提是:在渲染器进程中,你向 remote 索取主进程中一个对象的句柄。然后你就可以使用这个句柄,就好像它是呈现器进程中的一个普通 JavaScript 对象一样–调用方法、等待承诺、注册事件处理程序。渲染器和主进程之间的所有 IPC 调用都在幕后为你处理。超级方便

比较慢

Electron 基于 Chromium,继承了 Chromium 的多进程模型。有一个或几个渲染器进程,负责渲染 HTML/CSS 并在页面的上下文中运行 JS;还有一个主进程,负责协调所有的渲染器并代表它们执行某些操作。

当一个呈现器进程访问一个远程对象时,例如读取一个属性或调用一个函数,呈现器进程会向主进程发送一个消息,要求它执行该操作,然后阻塞等待响应。这意味着,当呈现器在等待结果的时候,它不能做任何事情,只能摆弄它的拇指。没有解析传入的网络数据,没有渲染,没有处理定时器。它只是在等待。

在我的机器上,访问一个远程对象的属性的平均时间大约是 0.1 毫秒。作为比较,访问渲染器本地对象上的一个属性需要大约 0.00001 毫秒。远程对象的速度比本地对象慢一万倍。让我把这句话写成大字,因为这很重要。偶尔做一两个这样的 0.1 毫秒的调用并不是什么问题–如果你想停留在一个单一的帧内,与你得到的 16 毫秒相比,0.1 毫秒仍然是相当快的。假设你不做其他事情,每一帧有 160 次对远程对象的调用预算。但真的很容易不小心比你预期的多出许多远程调用。例如,请看下面的代码,它想象了一个存在于主进程中的自定义域对象被渲染器操纵。

// Main process
global.thing = {
  rectangle: {
    getBounds() { return { x: 0, y: 0, width: 100, height: 100 } }
    setBounds(bounds) { /* ... */ }
  }
}
// Renderer process
const thing = remote.getGlobal('thing')
const { x, y, width, height } = thing.rectangle.getBounds()
thing.rectangle.setBounds({ x, y, width, height: height + 100 })

在渲染器进程中执行这段代码涉及到九个往返的 IPC 消息:

  • 最初的 getGlobal()调用,返回一个代理对象。
  • 从 thing 获取矩形属性,返回另一个代理对象。
  • 在矩形上调用 getBounds(),返回第三个代理对象。
  • 获得边界的 x 属性。
  • 获得边界的 y 属性。
  • 获得边界的宽度属性。
  • 获得边界的高度属性。
  • 再次获得事物的矩形属性,返回与我们在(2)中得到的相同的代理对象。
  • 用新的值调用 setBounds。

这三行代码,不是一个单一的循环!,几乎需要整整一毫秒的时间来执行。一毫秒是一个很长的时间。当然,我们可以优化这段代码,以减少完成这一特定任务所需的 IPC 消息的数量(事实上,一些特殊的内部 Electron 数据结构,如从 BrowserWindow.getBounds 返回的 bounds 对象,具有神奇的属性,使它们更有效率)。但是像这样的代码很容易被发现在你的应用程序的灰尘角落里,并最终产生 “千刀万剐 “的效果–那些在检查时看起来并不可疑的代码,实际上比它看起来要慢得多。如果这些代理对象从创建它们的函数中返回,它们可能会出现在各种地方,从而使这些缓慢的远程 IPC 从远离最初调用 remote.getGlobal()的地方被调用,这一事实使问题更加复杂。

可能创建令人迷惑的执行顺序

我们通常认为 JavaScript 是单线程的(除了 Node 的新工作线程模块)。也就是说,当你的代码在运行的时候,没有其他事情可以发生。这在 Electron 中仍然是正确的,但是当使用远程模块时,有一些微妙的技巧可能会导致在你不期望存在的地方出现竞赛条件。例如,考虑这个比较常见的 JavaScript 模式。

obj.doThing();
obj.on("thing-is-done", () => {
  doNextThing();
});

其中 doThing 启动了一些进程,最终会触发 thing-is-done 事件。Node 中的 http 模块是一个很好的例子,该模块通常以这种方式使用。这在普通的 JavaScript 中是安全的,因为在你的代码运行完毕之前,是不可能触发 thing-is-done 事件的。然而,如果 obj 是一个远程对象的代理,那么这段代码就包含一个竞赛条件。说 doThing 是一个可以很快完成的操作。当我们在渲染器进程中调用 obj.doThing()的代理对象时,远程模块会向主进程发出 IPC。然后 doThing()会在主进程中被调用,它启动了它所做的任何事情,向渲染器进程返回 undefined 作为返回值。现在有两个执行线程:一个是正在做事情的主进程,另一个是即将向主进程发送消息,要求向 obj 添加一个事件处理程序的呈现器进程。如果事情完成得特别快,可能会发生这样的情况:在通知主进程渲染器进程对该事件感兴趣的消息到达之前,主进程就已经触发了 “事情已完成 “事件。

Race condition between Main and Renderer process leading to unexpected behavior.

这里的主进程和渲染器进程都是单线程的,正常的 JavaScript。但是它们之间的交互导致了一个竞赛条件,事件在调用 doThing()和调用 on(’thing-is-done’)之间被触发。如果这看起来令人困惑和微妙,那是因为它是。Electron 自己的测试套件包含了许多不同版本的这种竞赛条件,直到最近为了减少测试的松散性而把它们找出来。

远程对象不同于正常对象

当你从远程模块请求一个对象时,你会得到一个代理对象–它代表了另一边的真实对象。远程模块尽力使该对象看起来好像真的存在于当前进程中,而且它做得很好,但有很多奇怪的边缘情况,使远程对象的方式不同,前 99 次工作正常,第 100 次却以某种极难调试的方式失败。这里有几个例子。

  • 原型链在进程之间不被镜像。因此,例如,remote.getGlobal(‘foo’).constructor.name === “Proxy”,而不是远端构造函数的真实名称。任何涉及原型的远程智能都会在触及远程对象时被保证爆炸。
  • NaN 和 Infinity 没有被远程模块正确处理。如果一个远程函数返回 NaN,渲染器进程中的代理将返回未定义值。
  • 在渲染器进程中运行的回调的返回值不会被传回主进程。当你把一个函数作为回调传递给远程方法时,那么从主进程中调用该回调将总是返回未定义,而不管呈现器进程中的方法返回什么。这是因为主进程不能阻塞等待呈现器进程返回一个结果。

机会是,当你第一次使用远程模块时,你不会遇到这些微妙的差异。甚至可能是第 100 次。但是,当你意识到远程模块工作方式的某个角落导致了你在六个小时内一直试图解决的错误时,再轻易改变使用远程的决定就太晚了。

存在安全隐患

很多 Electron 应用程序都不会故意运行不受信任的代码。然而,在你的应用程序中启用沙盒仍然是一个明智的预防措施–例如,显示任意的用户控制的图像是很常见的,而且,比如说,PNG 解码包含错误的情况也不是没有过。

但是,沙盒中的渲染器只有在主进程中才是安全的。渲染器与主进程进行通信,请求以其名义进行操作–例如,打开一个新窗口或保存一个文件。当主进程收到这样的请求时,它会判断是否应该允许呈现器做这件事,如果不允许,它就会忽略这个请求,并因不良行为而毫不客气地关闭呈现器进程。(也可能只是拒绝请求,这取决于违规行为的严重程度)。这里有一个明确的安全边界:无论渲染器进程提出什么要求,主进程都负责决定是否允许。

远程模块在这个安全边界上撕开了一个巨大的麦克卡车大小的洞。如果一个渲染器进程可以向主进程发送 “请获取这个全局变量并调用这个方法 “的请求,那么一个被破坏的渲染器进程就有可能制定并发送一个请求,要求主进程做它想做的任何事情。有效地,远程模块使沙箱几乎毫无用处。Electron 提供了一个禁用远程模块的选项,如果你在你的应用程序中使用沙盒,你肯定也应该禁用远程。

我甚至还没有触及一类主要的问题:远程实现的固有复杂性。在进程之间连接 JS 对象不是一项小任务:例如,考虑到远程必须在进程之间传播引用计数,以防止对象在另一个进程中被 GC。这项任务非常具有挑战性,如果没有庞大的簿记和精致的 C++板块,它是无法完成的(尽管一旦 WeakRefs 可用,它可能会成为纯 JavaScript 的一部分)。即使有了所有这些机器,远程也不能(而且很可能永远不能)正确地 GC 循环引用。世界上很少有人能完全理解 remote 的实现,而且修复其中出现的 bug 是非常困难的。

remote 模块很慢,容易发生竞赛,产生的对象与普通 JS 对象有细微差别,而且是一个巨大的安全责任。不要在你的应用程序中使用它。

替换

理想情况下,你应该尽量减少应用中 IPC 的使用–最好将尽可能多的工作留在渲染器进程中。如果你需要在同一原点的多个窗口之间进行通信,你可以使用 window.open(),并对它们进行同步编写,就像你在网络上一样。对于在不同起源的窗口之间的通信,还有 postMessage。

但是当你真的只需要在主进程中调用一个函数时,我建议你使用新的 ipcRenderer.invoke()方法,它从 Electron 7 开始可用。它的工作原理类似于古老的 ipcRenderer.sendSync(),但它是异步的,也就是说,它不会阻碍渲染器中其他事情的发生。下面是一个从基于远程加载文件的系统转换到基于 ipcRenderer.invoke() 的系统的例子。

// 之前
// Main
global.api = {
  loadFile(path, cb) {
    if (!pathIsOK(path)) return cb("forbidden", null);
    fs.readFile(path, cb);
  },
};
// Renderer
const api = remote.getGlobal("api");
api.loadFile("/path/to/file", (err, data) => {
  // ... do something with data ...
});

// 现在
// Main
ipcMain.handle("read-file", async (event, path) => {
  if (!pathIsOK(path)) throw new Error("forbidden");
  const buf = await fs.promises.readFile(path);
  return buf;
});
// Renderer
const data = await ipcRenderer.invoke("read-file", "/path/to/file");
// ... do something with data ...
上一页