Полное руководство по разработке десктопных приложений на 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(); });
Связанные инструменты
- Кодирование/Декодирование Base64 — Обработка встроенных ресурсов и передача файлов
- Форматирование JSON — Отладка IPC-сообщений и файлов конфигурации
- Сжатие изображений — Оптимизация размера графических ресурсов в приложении
Попробуйте эти локальные браузерные инструменты — регистрация не требуется →