[EDIT]: я говорил об этом на Covalence Conf 2020, который вы можете посмотреть здесь, если хотите!

Начиная с самых ранних версий Electron, модуль remote был незаменимым инструментом для связи между основным процессами и процессами рендеринга. Основная предпосылка такова: из процесса рендеринга вы запрашиваете remote дескриптор объекта в основном процессе. Затем вы можете использовать этот дескриптор так же, как если бы он был обычным объектом JavaScript в процессе рендеринга - вызывая методы, ожидая обещаний и регистрируя обработчики событий. Все вызовы IPC между средством визуализации и основным процессом обрабатываются за вас за кулисами. Супер удобно!

… Пока этого не произойдет. Почти каждое нетривиальное приложение Electron, которое использовало модуль remote, включая Slack, в конечном итоге сожалело о своем решении. Вот почему.

№1 - Медленно.

Electron, основанный на Chromium, наследует многопроцессорную модель Chromium. Есть один или несколько процессов рендеринга, которые отвечают за рендеринг HTML / CSS и запуск JS в контексте страницы, и один главный процесс, который отвечает за координирует работу всех рендереров и выполняет определенные операции от их имени.

Когда процесс рендеринга обращается к удаленному объекту, например, для чтения свойства или вызова функции, процесс рендеринга отправляет сообщение основному процессу с просьбой выполнить эту операцию, а затем блокирует ожидание ответа. Это означает, что пока рендерер ожидает результата, он ничего не может сделать, кроме как вертеть пальцами. Без анализа входящих сетевых данных, без рендеринга, без обработки таймеров. Просто жду.

Среднее время доступа к свойству удаленного объекта на моей машине составляет около 0,1 мс. Для сравнения: доступ к свойству объекта, который является локальным для средства визуализации, занимает около 0,00001 мс [репликация]. Удаленные объекты в десять тысяч раз медленнее, чем локальные. Позвольте мне выразить это крупным текстом, потому что это важно.

Удаленные объекты в в десять тысяч раз медленнее, чем локальные.

Выполнение одного или двух таких вызовов по 0,1 мс время от времени не является проблемой: 0,1 мс все еще довольно быстро по сравнению с 16 мс, которые вы получаете, если хотите остаться в пределах одного кадра. Это бюджет 160 вызовов remote объектов на кадр, если вы больше ничего не делаете.

... но очень легко случайно сделать намного больше удаленных вызовов, чем вы могли ожидать. Например, рассмотрим следующий код, который представляет собой объект пользовательского домена, присутствующий в основном процессе, которым управляют из средства визуализации:

// 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-сообщений:

  1. начальный вызов getGlobal(), который возвращает прокси-объект,
  2. получение свойства rectangle из thing, которое возвращает другой прокси-объект,
  3. вызывая getBounds() на прямоугольнике, который возвращает третий прокси-объект,
  4. получение свойства x границ,
  5. получение y свойства границ,
  6. получение свойства width границ,
  7. получение свойства height границ,
  8. снова получить свойство rectangle для thing, которое возвращает тот же прокси-объект, что и в (2),
  9. вызов setBounds с новым значением.

Эти три строки кода - ни один цикл! - занимают почти целую миллисекунду. Миллисекунда - это долгое время.

Безусловно, можно оптимизировать этот код, чтобы уменьшить количество сообщений IPC, необходимых для выполнения этой конкретной задачи (и на самом деле некоторые специальные внутренние структуры данных Electron, такие как объект границ, возвращаемый из BrowserWindow.getBounds, имеют магические свойства, которые делают их немного более эффективными. ). Но подобный код может легко найти свой путь в пыльные уголки вашего приложения и в конечном итоге вызвать эффект смерти от тысячи сокращений - код, который кажется подозрительным при проверке, на самом деле намного медленнее, чем кажется. Эта проблема усугубляется тем фактом, что эти прокси-объекты могут оказаться в самых разных местах, если они были возвращены функцией, которая их создала, что приводит к тому, что эти медленные удаленные IPC вызываются из мест, очень далеких от первоначального вызова remote.getGlobal().

# 2 - Это создает возможность запутать проблемы с синхронизацией.

Обычно мы думаем о JavaScript как о однопоточном (не говоря уже о новом модуле рабочие потоки в Node). То есть, пока ваш код работает, ничего другого происходить не может. Это по-прежнему верно в Electron, но при использовании модуля remote есть некоторая хитрость, которая может привести к состояниям гонки, когда вы не ожидаете, что они будут существовать. Например, рассмотрим этот относительно распространенный шаблон JavaScript:

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

Где doThing запускает какой-то процесс, который в конечном итоге вызовет событие thing-is-done. Модуль http в Node - хороший пример модуля, который обычно используется таким образом. Это безопасно в обычном JavaScript, потому что событие thing-is-done не может быть запущено до тех пор, пока ваш код не будет завершен.

Однако, если obj является прокси для удаленного объекта, этот код содержит условие гонки. Скажем, doThing - это операция, которая может завершиться очень быстро. Когда мы вызываем obj.doThing() для прокси-объекта в процессе рендеринга, модуль remote отправляет IPC основному процессу под капотом. Затем doThing() вызывается в основном процессе, и он запускает все, что делает, возвращая undefined в качестве возвращаемого значения процессу рендеринга. Теперь есть два потока выполнения: основной процесс, который выполняет thing, и процесс рендеринга, который собирается отправить сообщение основному процессу с просьбой добавить обработчик событий в obj. Если thing завершается особенно быстро, может случиться так, что событие thing-is-done запускается в основном процессе до того, как поступит сообщение, информирующее основной процесс о том, что процесс рендеринга заинтересован в этом событии.

И основной процесс, и процесс рендеринга здесь являются однопоточными, обычным JavaScript. Но взаимодействие между ними вызывает состояние гонки, когда событие запускается между вызовом doThing() и вызовом on('thing-is-done').

Если это кажется запутанным и тонким, то это потому, что это так. Собственный набор тестов Electron содержал множество различных версий такого рода условий гонки, пока недавняя попытка уменьшить нестабильность тестов не обнаружила их.

# 3 - Удаленные объекты немного отличаются от обычных объектов.

Когда вы запрашиваете объект у удаленного модуля, вы получаете обратно прокси-объект - тот, который заменяет реальный объект на другой стороне. Удаленный модуль делает все возможное, чтобы этот объект выглядел так, как если бы этот объект действительно присутствовал в текущем процессе, и он выполняет довольно хорошую работу, но есть много странных крайних случаев, которые делают удаленные объекты разными способами, которые будут работать. штраф первые 99 раз и сбой каким-то чрезвычайно трудным для отладки способом на сотом. Вот несколько примеров:

  1. Цепочки прототипов не отражаются между процессами. Так, например, remote.getGlobal('foo').constructor.name === "Proxy" вместо настоящего имени конструктора на удаленной стороне. Все, что удаленно умно и связано с прототипами, гарантированно взорвется, если коснется удаленного объекта.
  2. NaN и Infinity неправильно обрабатываются удаленным модулем. Если удаленная функция возвращает NaN, вместо этого прокси в процессе рендеринга вернет undefined.
  3. Возвращаемые значения из обратных вызовов, которые выполняются в процессе рендеринга, не передаются обратно в основной процесс. Когда вы передаете функцию как обратный вызов удаленному методу, тогда вызов этого обратного вызова из основного процесса всегда будет возвращать undefined, независимо от того, что возвращает метод в процессе рендеринга. Это связано с тем, что основной процесс не может заблокировать ожидание, пока процесс рендеринга вернет результат.

Скорее всего, вы не столкнетесь ни с одним из этих тонких различий при первом использовании удаленного модуля. А может даже в сотый раз. Но к тому времени, когда вы поймете, что какой-то краеугольный случай того, как работает удаленный модуль, вызывает ошибку, которую вы пытались выяснить для списка шесть часов, будет слишком поздно, чтобы легко изменить решение. использовать remote.

# 4 - Это уязвимость системы безопасности, которая ждет своего часа.

Многие приложения Electron никогда намеренно не запускают ненадежный код. Тем не менее, включение песочницы в вашем приложении - это разумная мера предосторожности - например, довольно часто можно отображать произвольные изображения, управляемые пользователем, и нередко, например, декодирование PNG может содержать ошибки.

Но песочница рендерера настолько безопасна, насколько это обеспечивает основной процесс. Средство визуализации связывается с основным процессом, чтобы запросить выполнение действий от его имени, например, открытие нового окна или сохранение файла. Когда основной процесс получает такой запрос, он определяет, следует ли позволить рендереру делать это, а если нет, он проигнорирует запрос и бесцеремонно завершит процесс рендеринга из-за плохого поведения. (Или, возможно, просто отклоните запрос, в зависимости от того, насколько серьезным является нарушение.) Здесь есть четкая граница безопасности: независимо от того, что запрашивает процесс рендеринга, главный процесс отвечает за решение, разрешить это или нет.

Модуль remote пробивает огромную дыру размером с грузовик Mack в этой границе безопасности. Если процесс рендеринга может отправлять запросы основному процессу, в которых говорится: Пожалуйста, получите эту глобальную переменную и вызовите этот метод, тогда скомпрометированный процесс рендеринга может сформулировать и отправить запрос, чтобы попросить основной процесс сделать все, что он хочет. Фактически, модуль remote делает песочницу почти бесполезной. Electron предоставляет возможность отключить модуль remote, и если вы используете песочницу в своем приложении, вам обязательно нужно отключить remote.

Я даже не затронул основной класс проблем: сложность реализации remote. Связывание объектов JS между процессами - задача не из легких: например, подумайте о том, что remote должен распространять счетчики ссылок между процессами, чтобы предотвратить сборку мусора объектов в другом процессе. Эта задача достаточно сложна, и ее невозможно решить без тяжелой бухгалтерии и тонких блоков C ++ (хотя ее можно будет решить с помощью чистого JavaScript, как только появятся WeakRef). Даже со всем этим механизмом remote не сможет (и, скорее всего, никогда не сможет) правильно обрабатывать циклические ссылки GC. Мало кто в мире полностью понимает реализацию remote, и исправлять ошибки, которые в ней возникают, очень сложно ™.

Модуль remote является медленным, подверженным гонкам, создает объекты, слегка отличающиеся от обычных объектов JS, и представляет собой серьезную угрозу безопасности. Не используйте его в своем приложении.

Хорошо, тогда что мне делать вместо этого?

В идеале вам следует свести к минимуму использование IPC в своем приложении - лучше сохранить как можно больше работы в процессе рендеринга. Если вам нужно обмениваться данными между несколькими окнами в одном и том же источнике, вы можете использовать window.open() и синхронизировать их сценарии, точно так же, как в Интернете. Для связи между окнами разного происхождения существует postMessage.

Но когда вам действительно нужно вызвать функцию в основном процессе, я бы порекомендовал вам использовать новый метод ipcRenderer.invoke(), доступный в Electron 7. Он работает аналогично почтенному ipcRenderer.sendSync(), но асинхронно, то есть не будет блокировать другие события в рендерере. Вот пример преобразования системы на основе remote для загрузки файла в систему на основе 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 ...
})

После, с 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, может быть более сложным и не так точно переводить на invoke. Но написание обработчиков IPC таким образом предоставит вам более четкое, простое в отладке, более надежное и безопасное приложение.