Полное руководство по разработке десктопных приложений на Electron: От настройки до продакшена

前端工程

Почему Electron

Кроссплатформенная десктопная разработка всегда была головной болью. Пишите приложения для Windows на C# и WPF, приложения для macOS на Swift и AppKit, приложения для Linux на GTK или Qt — три кодовые базы, тройное обслуживание. Electron изменил правила игры: одна кодовая база HTML/CSS/JavaScript, упакованная одновременно для Windows, macOS и Linux.

Если вы используете VS Code, Figma, Slack или Discord, вы уже запускаете приложения на Electron каждый день. Скорость запуска VS Code и производительность рендеринга Figma доказывают, что Electron может соответствовать нативному опыту при правильной архитектуре.

Я помогал переносить внутренний инструмент с C# WinForms на Electron. Скорость доставки функций утроилась за следующие два квартала. Причина проста: нанять фронтенд-разработчиков гораздо проще, чем разработчиков десктопных приложений на C#, а экосистема компонентов значительно богаче.

Эта статья — мой полный обзор после миграции. Я не буду пересказывать документацию API — вы можете посмотреть её сами — я сосредоточусь на том, что реально ломается в продакшене и как это исправить.


Инициализация проекта: Не копируйте официальный Quick-Start

Официальный electron-quick-start использует чистый JavaScript, без инструментов сборки, без модулей, без горячей перезагрузки. Начинать реальный проект таким образом — гарантировать спагетти-код через два месяца.

Используйте electron-vite или настройте Vite + Electron вручную:

npm create @quick-start/electron@latest my-app -- --template vue-ts

Или ручная настройка:

mkdir my-electron-app && cd my-electron-app
npm init -y
npm i -D electron electron-builder vite @vitejs/plugin-vue typescript
npm i vue

Рекомендуемая структура каталогов:

my-electron-app/
├── electron/              # Основной процесс
│   ├── main.ts           # Точка входа, создание окон
│   ├── preload.ts        # Мост безопасности
│   └── ipc/              # Управление IPC-каналами
│       ├── file.ts       # Файловые операции
│       └── system.ts     # Системная информация
├── src/                   # Рендерер (фронтенд)
│   ├── App.vue
│   ├── main.ts
│   └── components/
├── resources/             # Иконки приложения, статические ресурсы
├── electron-builder.yml
├── vite.config.ts
└── package.json

Ключевое архитектурное решение: полное разделение основного и рендерер-процессов. Основной процесс (electron/) обрабатывает только системные операции. Рендерер (src/) — стандартный фронтенд-проект. Это позволяет специалистам с каждой стороны работать независимо без трений.


Модель процессов: Знайте, кто за что отвечает

Electron имеет два основных процесса. Если вы не усвоите эту концепцию, проблемы безопасности и производительности заставят вас переписывать код снова и снова.

Процесс Среда выполнения Возможности Ограничения
Main Node.js Создавать окна, читать/записывать файлы, вызывать системные API, управлять жизненным циклом Не может напрямую манипулировать DOM
Renderer Chromium Рендерить HTML/CSS, реагировать на ввод пользователя, манипулировать DOM Не может обращаться к API Node.js по умолчанию

Обмен данными происходит через IPC (Inter-Process Communication). Основной процесс предоставляет ограниченную поверхность API рендерерам через contextBridge.

Типичный поток коммуникации:

Пользователь нажимает "Сохранить файл"
  → Рендерер: ipcRenderer.invoke('save-file', fileData)
    → Main: ipcMain.handle('save-file', async (event, data) => {
        dialog.showSaveDialog() → fs.writeFileSync()
      })
    → Возвращает результат рендереру

Точка входа основного процесса

// electron/main.ts
import { app, BrowserWindow } from 'electron';
import { registerIpcHandlers } from './ipc';

let mainWindow: BrowserWindow | null = null;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    minWidth: 800,
    minHeight: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,      // Должно быть включено
      nodeIntegration: false,      // Должно быть отключено
      sandbox: true,               // Режим песочницы
    },
  });

  if (process.env.NODE_ENV === 'development') {
    mainWindow.loadURL('http://localhost:5173');
    mainWindow.webContents.openDevTools();
  } else {
    mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
  }
}

app.whenReady().then(() => {
  registerIpcHandlers();
  createWindow();
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

Скрипт preload — Ворот безопасности вашего приложения

// electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
  saveFile: (content: string, defaultName?: string) =>
    ipcRenderer.invoke('save-file', content, defaultName),
  openFile: () =>
    ipcRenderer.invoke('open-file'),

  getAppVersion: () => ipcRenderer.invoke('get-app-version'),
  getPlatform: () => process.platform,

  minimizeWindow: () => ipcRenderer.send('window-minimize'),
  maximizeWindow: () => ipcRenderer.send('window-maximize'),
  closeWindow: () => ipcRenderer.send('window-close'),
});

Вызов из рендерера:

// src/components/SaveButton.vue
const handleSave = async () => {
  const result = await window.electronAPI.saveFile(
    editorContent.value,
    'untitled.txt'
  );
  if (result.success) {
    showToast(`Сохранено в ${result.filePath}`);
  }
};

contextBridge имеет решающее значение — он ограничивает то, что рендерер может вызывать. Никогда не передавайте сырой ipcRenderer рендереру; это даст веб-содержимому полный доступ к Node.js.


Безопасность: Семь обязательных правил

Модель безопасности Electron прошла через несколько итераций. Старые руководства часто показывают уже небезопасные паттерны. На 2026 год это жёсткие требования:

Правило Конфигурация Почему
1 contextIsolation: true webPreferences Изолирует JS-контекст рендерера от preload
2 nodeIntegration: false webPreferences Блокирует прямой require Node.js в рендерере
3 sandbox: true webPreferences Песочница на уровне ОС, ограничивает разрешения процесса
4 Использовать contextBridge preload.ts Предоставление API на основе белого списка
5 Валидировать источник IPC-сообщения ipcMain.handle Проверять URL event.senderFrame
6 Удалённый контент только с CSP webPreferences Политика CSP обязательна для удалённых URL
7 Подписывать и нотаризовать electron-builder macOS требует нотаризацию, Windows рекомендует подпись

Валидация источника IPC

// electron/ipc/file.ts
ipcMain.handle('save-file', async (event, content: string) => {
  if (!event.senderFrame?.url.startsWith('file://')) {
    throw new Error('Неавторизованный вызывающий');
  }

  if (typeof content !== 'string' || content.length > 10 * 1024 * 1024) {
    throw new Error('Недопустимое содержимое');
  }

  const { filePath } = await dialog.showSaveDialog({
    defaultPath: 'untitled.txt',
    filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
  });

  if (!filePath) return { success: false, canceled: true };

  await fs.promises.writeFile(filePath, content, 'utf-8');
  return { success: true, filePath };
});

Управление окнами: Больше, чем new BrowserWindow

Окна без рамки и пользовательские заголовки

Большинство современных десктопных приложений отказываются от нативных заголовков — VS Code, Figma, Notion делают это. Реализация:

const mainWindow = new BrowserWindow({
  frame: false,
  titleBarStyle: 'hidden',
  ...(process.platform === 'darwin'
    ? { titleBarStyle: 'hiddenInset' }
    : { frame: false }),
});

Пользовательская панель заголовка в рендерере:

<!-- src/components/TitleBar.vue -->
<template>
  <div class="title-bar" :class="{ 'is-mac': isMac }">
    <div class="title-bar-drag" @dblclick="toggleMaximize">
      <span class="app-title">{{ title }}</span>
    </div>
    <div class="window-controls" v-if="!isMac">
      <button @click="minimize" class="control-btn minimize">─</button>
      <button @click="toggleMaximize" class="control-btn maximize">□</button>
      <button @click="close" class="control-btn close">✕</button>
    </div>
  </div>
</template>

Обратите внимание на -webkit-app-region: drag — это CSS-свойство позволяет перетаскивать окно за область заголовка. Области кнопок должны переопределять его с помощью no-drag, иначе клик по кнопке вызовет перетаскивание окна.

Коммуникация между несколькими окнами

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

ipcMain.on('broadcast-to-windows', (event, channel: string, data: unknown) => {
  BrowserWindow.getAllWindows().forEach(win => {
    if (win.webContents.id !== event.sender.id) {
      win.webContents.send(channel, data);
    }
  });
});

Интеграция нативных модулей: Когда Node.js недостаточно

Некоторые задачи слишком медленны на чистом JavaScript — пакетная обработка изображений, кодирование видео. Здесь на помощь приходят нативные модули на C++.

Вариант 1: Нативный аддон Node.js (node-addon-api)

// native/thumbnail.cc
#include <napi.h>

Napi::Value GenerateThumbnail(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  std::string inputPath = info[0].As<Napi::String>();
  std::string outputPath = info[1].As<Napi::String>();
  // Использовать FFmpeg/libvips для генерации миниатюры
  return Napi::String::New(env, outputPath);
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("generateThumbnail",
    Napi::Function::New(env, GenerateThumbnail));
  return exports;
}

NODE_API_MODULE(thumbnail, Init)

Вариант 2: Дочерний процесс вызывает внешние инструменты

Предпочитайте этот вариант — слабая связность, сбои не влияют на основной процесс:

// electron/ipc/image.ts
import { spawn } from 'child_process';

ipcMain.handle('compress-image', async (event, inputPath: string) => {
  return new Promise((resolve, reject) => {
    const child = spawn('oxipng', ['--opt', 'max', '--strip', 'safe', inputPath]);
    let stderr = '';
    child.stderr.on('data', (data) => { stderr += data; });
    child.on('close', (code) => {
      if (code === 0) resolve({ success: true });
      else reject(new Error(`oxipng завершился с кодом ${code}: ${stderr}`));
    });
  });
});

Автообновление: Бесшовные обновления

Автообновление — стандарт для десктопных приложений. electron-updater (на базе electron-builder) — самый зрелый вариант:

// electron/updater.ts
import { autoUpdater } from 'electron-updater';
import { BrowserWindow, dialog } from 'electron';

export function setupAutoUpdater(mainWindow: BrowserWindow) {
  autoUpdater.setFeedURL({
    provider: 'generic',
    url: 'https://releases.your-app.com/updates/',
  });

  setInterval(() => {
    autoUpdater.checkForUpdates();
  }, 4 * 60 * 60 * 1000);

  autoUpdater.on('update-available', () => {
    dialog.showMessageBox(mainWindow, {
      type: 'info',
      title: 'Доступно обновление',
      message: 'Обновление загружается в фоновом режиме...',
    });
  });

  autoUpdater.on('update-downloaded', () => {
    dialog.showMessageBox(mainWindow, {
      type: 'info',
      title: 'Обновление готово',
      message: 'Перезапустите сейчас, чтобы установить последнюю версию.',
    }).then(() => {
      autoUpdater.quitAndInstall();
    });
  });

  autoUpdater.on('error', (err) => {
    console.error('Ошибка обновления:', err.message);
  });

  setTimeout(() => autoUpdater.checkForUpdates(), 5000);
}

Упаковка и распространение

Пример electron-builder.yml:

appId: com.yourcompany.yourapp
productName: YourApp
directories:
  output: release
files:
  - dist/**/*
  - electron/**/*.js
  - package.json
win:
  target:
    - target: nsis
      arch: [x64, arm64]
  icon: resources/icon.ico
mac:
  target:
    - target: dmg
      arch: [x64, arm64]
  icon: resources/icon.icns
  category: public.app-category.developer-tools
  hardenedRuntime: true
  notarize:
    teamId: YOUR_TEAM_ID
linux:
  target:
    - target: AppImage
      arch: [x64]
    - target: deb
      arch: [x64]
  category: Development
nsis:
  oneClick: false
  allowToChangeInstallationDirectory: true

Команды сборки:

npm run dev           # локальная разработка
npm run build         # текущая платформа
npm run build:win     # Windows
npm run build:mac     # macOS
npm run build:linux   # Linux

Оптимизация производительности: Не позволяйте вашему приложению стать Chrome

Главная критика Electron — потребление памяти. Базовое приложение Electron использует 50-80 МБ, а с реальным содержимым легко превышает 200 МБ+. Вот стратегии, проверенные в продакшене:

1. Ленивая загрузка некритичных окон

let settingsWindow: BrowserWindow | null = null;

ipcMain.handle('open-settings', async () => {
  if (settingsWindow && !settingsWindow.isDestroyed()) {
    settingsWindow.focus();
    return;
  }
  settingsWindow = new BrowserWindow({ /* ... */ });
  settingsWindow.loadURL('...');
  settingsWindow.on('closed', () => { settingsWindow = null; });
});

2. Фоновое регулирование

const win = new BrowserWindow({
  webPreferences: {
    backgroundThrottling: true,
    offscreen: false,
  },
});

win.on('blur', () => {
  win.webContents.setBackgroundThrottling(true);
});

3. Использование BrowserView для встроенного контента

const view = new BrowserView({
  webPreferences: { sandbox: true, contextIsolation: true },
});
mainWindow.setBrowserView(view);
view.setBounds({ x: 0, y: 0, width: 800, height: 600 });
view.webContents.loadURL('https://preview.your-app.com/doc/123');

4. Чеклист утечек памяти

Проверка Инструмент Метрика
Процессы-зомби после закрытия окна Диспетчер задач / Activity Monitor Зависшие процессы electron
Неразрушенные webContents webContents.getAllWebContents() Растущее количество
Неочищенные IPC-слушатели ipcMain.eventNames() Отсутствует removeHandler
Ссылки на большие объекты Панель Memory в Chrome DevTools Сравнение снимков кучи

Советы по отладке

Отладка основного процесса

# Запуск с подключённым отладчиком
electron --inspect=5858 .
# Открыть chrome://inspect в Chrome для подключения

VSCode launch.json:

{
  "type": "node",
  "request": "launch",
  "name": "Debug Main Process",
  "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
  "runtimeArgs": ["--inspect=5858", "."],
  "port": 5858
}

Отладка процесса рендерера

DevTools автоматически открываются в режиме разработки. В продакшене принудительно откройте флагом:

if (process.argv.includes('--devtools')) {
  mainWindow.webContents.openDevTools({ mode: 'detach' });
}

Частые ловушки

Ловушка 1: Нотаризация macOS не удаётся

"notarization failed: The binary is not signed"

Решение: Убедитесь в hardenedRuntime: true, корректном файле entitlements и сертификате Apple Developer ID.

Ловушка 2: Слишком длинный путь в Windows

Windows имеет ограничение пути в 260 символов. Глубоко вложенные node_modules легко превышают его. Включите поддержку длинных путей в Windows 10 1607+ через групповую политику или используйте electron-builder install-app-deps.

Ловушка 3: Нативные модули не работают после упаковки asar

electron-builder по умолчанию упаковывает код в app.asar, но нативные модули .node не могут загружаться изнутри asar:

# electron-builder.yml
asarUnpack:
  - "node_modules/sharp/**"
  - "node_modules/better-sqlite3/**"
  - "native/**/*.node"

Ловушка 4: Долгий белый экран при запуске

  • Разделите вывод Vite код-сплиттингом, загружайте минимальный код для первой отрисовки
  • Используйте заставку, чтобы закрыть белый период
  • Показывайте окно только по событию ready-to-show:
mainWindow.once('ready-to-show', () => { mainWindow.show(); });

Связанные инструменты

Попробуйте эти локальные браузерные инструменты — регистрация не требуется →

#Electron#桌面应用#跨平台#Node.js#教程