Недавно на проекте столкнулся с необычной задачей - сделать из готового React веб-приложения десктопную версию на Electron. Что же тут необычного? А то, что наше веб-приложение построено на микрофронтенд архитектуре и располагается в трёх отдельных репозиториях. А общение между микрофронтендами происходит в runtime через HTTP. И тут начинаются сложности, так как для создания дистрибутива, Electron'у нужен доступ к исходникам всего приложения. Хотя Electron легко подружить с Webpack, как это сделать с плагином Module Federation на первый взгляд не понятно.

Поиск готового решения в интернете ничего не дал, кроме повисших в воздухе вопросов на Stack Overflow. Пришлось придумать своё решение, которое я и опишу здесь.

Стек проекта типовой (React, Webpack Module Federation, Electron, Electron-forge), поэтому не буду подробно расписывать конфиги, лишь опишу ключевые моменты.


Developer build

Начнём с локального запуска десктопного приложения в develop режиме. Здесь всё просто - нужно только изменить скрипт запуска. Хитрость в том, чтобы параллельно запустить корневое приложение в дев режиме и electron-forge.

// package.json
// Скрипт ожидает запуск корневого приложения в режиме разработки и запускает Electron.

"scripts": {
  ...
  "desktop:start": "concurrently \"yarn start\" \"wait-on tcp:3003 && electron-forge start\""
}

А что же с ремоутами? Их придётся стартовать вручную в отдельных терминалах. Это простое решение подходит только для режима разработки, с продуктовой сборкой немного сложнее.


Production build

Здесь добавляется немного ручной работы, так как на момент упаковки electron-forge package в корневом репозитории должны лежать все необходимые бандлы ремоутов. Как же получить бандлы? Пока я не нашёл другого решения, кроме как тащить руками. Для этого идём в ремоут приложение, делаем прод сборку и копируем /dist в корень хост приложения.

Далее нужно включить бандл ремоута в дистрибутив десктопного приложения. Для этого указываем forge где лежат эти ресурсы:

// forge.config.js

module.exports = {
  packagerConfig: {
    extraResource: ['./your-mf-module']
  }
}

Далее нужно внедрить remoteEntry.js скрипт в index.html рута и инициализировать микрофронтовый модуль. Для этого в preload.js, где есть доступ к node api, опишем функцию внедрения скрипта и положим её в глобальный window:

// preload.js

contextBridge.exposeInMainWorld('electron', {
  initMf: async () => {
    const localAppPath = await ipcRenderer.invoke('get-local-app-data');
    const pathToScript = path.join(localAppPath, 'App-name', 'app-1.0.0', 'resources', 'your-mf-module', 'dist', 'remoteEntry.js');
    const script = document.createElement('script');

    script.src = pathToScript;
    document.head.appendChild(script);
  }
});

Тут есть одна сложность - нужно определить путь до директории локальной установки приложений (LOCALAPPDATA). Используем ipcRenderer.invoke() чтобы получить данные из main process. Также опишем соответствующий хендлер в main.js:

// main.js

const createWindow = () => {
  const mainWindow = new BrowserWindow({...});

  ipcMain.handle('get-local-app-data', () => process.env.LOCALAPPDATA);
};

Вызов функции initMf происходит в renderer.js, где доступен объект window:

// renderer.js

window.electron.initMf();

Таким образом мы добавляем скрипты ремоут модулей при старте приложения. Далее их нужно инициализировать как webpack модули. В документации Webpack есть пример (https://webpack.js.org/concepts/module-federation/) такой функции, используем её:

function loadComponent(scope, module) {
  return async () => {
    // Initializes the shared scope. Fills it with known provided modules from this build and all remotes
    await __webpack_init_sharing__('default');

    const container = window[scope]; // or get the container somewhere else

    // Initialize the container, it may provide shared modules
    await container.init(__webpack_share_scopes__.default);

    const factory = await window[scope].get(module);
    const Module = factory();

    return Module;
  };
}

При вызове эта функция находит модуль в window, инициализирует и возвращает его. Полученный модуль импортируем в корневой App.tsx через React.lazy().

Вот собственно и всё решение. Теперь можно без проблем упаковать десктопное приложение и собрать дистрибутив.


Заключение

Возможно, это решение не самое изящное, но кажется единственная альтернатива ему - глобальная перестройка архитектуры всего приложения в монорепозиторий. А в моём решении нужны только доработки в хост приложении и никаких изменений в ремоутах. А в ремоутах как раз и происходит вся разработка, и для обновления десктопной версии приложения нужен только новый бандл изменённого ремоута - и свежий дистрибутив готов.

Комментарии (1)


  1. cmyser
    05.08.2025 19:43

    Tauri ? Получше электрона будет