翻译文章 “Electron’s ‘remote’ module considered harmful”
从最早的Electron版本开始,远程(remote
)模块就成为在主进程与渲染进程之间进行通讯(IPC,Information Processing Cente)的首选工具。这个模块的基本前提是:您在渲染进程中请求远程(remote
)模块获取主进程中对象的句柄(handle)。然后,您可以使用该句柄,就好像它是渲染进程中的普通 JavaScript 对象一样——调用函数,使用 promise 及注册事件处理程序。主进程与渲染进程之间的所有 IPC 调用都在后台为您处理。好像是超级方便呢!
然而并没有。几乎所有使用过远程模块的电子应用程序(包括slack)最终都相当地后悔。这是为什么?
基于Chromium的Electron继承了Chromium的多进程模型。有一个或多个渲染器进程,它们负责渲染HTML / CSS并在页面的上下文中运行JS;有一个主进程负责协调所有渲染器并代替它们执行某些操作。
当渲染进程需要访问一个远程对象(例如读取属性或调用函数)时,渲染进程会向主进程发送一条消息,要求其执行该操作,然后进程就阻塞了,等待主进程响应。这意味着在渲染进程等待结果的同时,它只能做些转圈圈的loading 动画。无法解析网络请求回来的数据,无法渲染,无法处理定时器。只能等待。
在我的机器上,访问远程对象上的属性(即主进程与渲染进程之间通信)的平均时间约为0.1毫秒。作为对比,访问渲染进程本地对象上的属性大约需要0.00001毫秒。从这可以得知,远程对象比本地对象慢一万倍。再次强调一下:
远程对象的访问速度比本地对象访问速度慢一万倍。
执行一到两次 0.1 毫秒通讯并不是什么大问题。但实际上可能会进行比您预期更多的 remote 调用。例如,下面的代码。假设主进程中存在的自定义域对象正在由渲染进程进行操作:
// 主进程
global.thing = {
rectangle: {
getBounds() { return { x: 0, y: 0, width: 100, height: 100 } }
setBounds(bounds) { /* ... */ }
}
}
// 渲染进程
const thing = remote.getGlobal('thing')
const { x, y, width, height } = thing.rectangle.getBounds()
thing.rectangle.setBounds({ x, y, width, height: height + 100 })
在渲染进程中执行上述代码涉及九个往返 IPC 消息:
remote.getGlobal()
调用返回一个代理对象;thing
对象获取 rectangle
属性,该属性返回另一个代理对象;rectangle
属性上 getBounds()
方法,返回第三个代理对象;rectangle
属性的 x 属性;rectangle
属性的 y 属性;rectangle
属性的 width 属性;rectangle
属性的 height 属性;thing
的 rectangle
属性,它返回与 (2) 获取的代理对象的一个相同的对象;setBounds
函数。这三行代码(非循环)几乎要花一毫秒的时间才能执行。 一毫秒是很长的时间。
当然,可以优化此代码以减少完成此特定任务所需的 IPC 消息数量。
实际上,某些特殊的内部 Electron 数据结构(例如从BrowserWindow.getBounds返回的bounds对象)具有不可思议的属性,使它们的工作效率更高。
但是像这样的代码很容易地进入应用程序的角落,并最终产生千刀万剐的效果——检查时看起来毫无疑点的代码实际上运行起来比它看起来要慢得多。如果这些代理对象是从创建它们的函数返回的,这些代理对象可能会出现在各种地方,这使问题更加复杂。
我们通常认为 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()
时,remote
模块会在后端向主进程发送 IPC 通讯。然后在主进程中调用doThing()
,它运行它需要做的事情,并且将 undefined
作为返回值返回给渲染进程。现在有两个线程执行:
doThing()
函数;thing-is-done
添加到 obj
。如果doThing()
函数完成得特别快,可能会在渲染进程发消息给主进程将事件thing-is-done
挂载到obj
之前触发thing-is-done
事件。
这里的主进程和渲染进程都是单线程 JavaScript。但是它们之间的交互导致了一个竞态条件,即事件在对调用doThing()
和on('thing-is-done')
之间已经触发。
如果这看起来令人困惑和并且有些微妙,但它确实如此。Electron 自己的测试组件包含了这种竞态条件的许多不同版本,直到最近为了减少测试的不稳定性而发现了这些问题。
当您从远程模块请求一个对象时,您会得到一个代理对象——另一方面,它代表一个实际对象。remote
模块尽可能地使该对象看起来好像在渲染进程中,并且也做得很好,但是有很多情况使remote
对象有一些奇怪的表现,可能前 99 次工作得很好,但在第 100 次以某种极其难以调试的方式失败。以下是例子:
remote.getGlobal('foo').constructor.name === "Proxy"
,这里不是该构造函数在主进程的真实 name,任何涉及到原型链的对象只要碰到remote
对象都会爆炸。NaN
和Infinity
在远程模块中无法进行正确处理。如果一个remote
函数返回NaN
,在渲染进程中的代理对象会返回undefined
。第一次,甚至可能已经使用remote
模块 100 次了,您可能不会遇到这些细微的差别。但是,当您意识到remote
模块在某些极端情况运行导致您需要花费 6 个小时找 BUG ,这个时候要想不用remote
模块就为时已晚了。
许多 Electron 应用从来没有故意运行不受信任的代码。不过,在您的应用中启用沙箱(sandbox)仍然是明智的预防措施——例如,显示任意用户控制的图像是很常见的,例如,PNG解码包含的 BUG。
但是,沙盒渲染的安全性只有主进程能保证。渲染进程可以通过与主进程进行通信,请求主进程代为执行操作——例如打开一个新窗口或保存文件。当主进程接收到这样的请求时,它将确定是否允许渲染进程执行该操作,如果不允许,它将忽略该请求并毫不客气地关闭渲染进程以防止不良行为(或者可能只是拒绝请求,具体取决于违规的严重程度)。这里有一个明确的安全边界:无论渲染进程提出什么要求,都通过主进程来判断是否允许该请求。
remote
模块在此安全边界上撕开了一个大口子。如果渲染进程给主进程发起请求——“请获取全局变量并调用此方法",则渲染进程也可以制定并发送请求要求主进程执行其所需的任何操作。实际上,remote
模块几乎使得沙盒模式没什么卵用了。Electron 提供了禁用remote
模块的选项,如果您在应用中使用沙盒,肯定也应该禁用remote
模块。
而这涉及的主要问题是:remote
模块实施起来的固有复杂性。在进程之间桥接 JS 对象绝非易事。例如,remote
模块必须在进程之间传播引用计数,以防止对象在其他进程中被垃圾回收。这项任务非常具有挑战性,以至于没有大量的簿记和 C ++ 代码块就无法完成(尽管一旦 WeakRefs 可用就可能可以用纯 JS 来完成)。即使使用了所有这些机制,remote
模块也无法(而且很可能永远也无法)正确地使用GC循环引用。世界上很少有人完全了解remote
模块的实现,并且修复其中的 BUG 也非常困难。
总结以上四点,remote
模块运行缓慢,容易出现竞争条件,remote
对象与常规 JS 对象略有不同,并且承担了巨大的安全责任。因此不要在您的应用中使用它。
理想情况下,最好在应用程序中尽量减少进行 IPC 通信——最好在渲染过程中做尽可能多的工作。如果您需要在同一来源的多个窗口之间进行通信,您可以使用window.open()
并同步编写脚本,就像在 Web 上一样地使用。要在不同来源的窗口之间进行通信,请使用postMessage
。
但是当您真的只是需要在主进程中调用函数时,我推荐你使用 electron 7 中的方法ipcRenderer.invoke()。它的作用与过去的ipcRenderer.sendSync()
相似,但它是异步的——意味着它不会阻塞渲染进程中的其他事件。以下有一个加载文件的例子:
过去,基于remote
模块:
// 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 ...
})
现在,使用ipcRenderer.invoke()
:
// 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 ...
或者,使用 ipcRenderer.send()
(在 electron 6 或更早的版本中)
请注意,除非您进行一些簿记以跟踪哪个响应属于哪个请求,否则该方法一次只能处理一个请求。(
invoke()
可以自动匹配处理对应请求的响应。)
// Main
ipcMain.on('read-file', async (event, path) => {
if (!pathIsOK(path))
return event.sender.send('read-file-complete', 'forbidden')
const buf = await fs.promises.readFile(path)
event.sender.send('read-file-complete', null, buf)
})
// Renderer
ipcRenderer.send('read-file', '/path/to/file')
ipcRenderer.on('read-file-complete', (event, err, data) => {
// ... do something with data ...
})
// Note that only one request can be made at a time, or else
// the responses might get confused.
这只是一个很小的示例,您需要对 IPC 通信进行的操作可能会更复杂,并且调用时不会那么巧妙。但是,以这种方式编写 IPC 通信处理程序将为您提供更清晰,更易于调试,更强大且更安全的应用程序。