Electron Desktop-App-Entwicklung: Komplettleitfaden von der Einrichtung bis zur Produktion
Warum Electron
Die plattformübergreifende Desktop-Entwicklung war schon immer ein Kopfschmerz. Windows-Apps in C# und WPF schreiben, macOS-Apps in Swift und AppKit, Linux-Apps in GTK oder Qt — drei Codebasen, dreifacher Wartungsaufwand. Electron hat das Spiel verändert: eine HTML/CSS/JavaScript-Codebasis, gleichzeitig für Windows, macOS und Linux verpackt.
Wenn Sie VS Code, Figma, Slack oder Discord nutzen, führen Sie bereits täglich Electron-Apps aus. Die Startgeschwindigkeit von VS Code und die Rendering-Leistung von Figma beweisen, dass Electron mit der richtigen Architektur native Erfahrung erreichen kann.
Ich half bei der Migration eines internen Tools von C# WinForms zu Electron. Die Feature-Liefergeschwindigkeit verdreifachte sich in den folgenden zwei Quartalen. Der Grund ist einfach: Frontend-Entwickler einzustellen ist viel einfacher als C#-Desktop-Entwickler, und das Komponenten-Ökosystem ist erheblich reicher.
Dieser Artikel ist meine komplette Zusammenfassung nach der Migration. Ich werde keine API-Dokumentation rezitieren — das können Sie selbst nachschlagen — ich konzentriere mich auf das, was in der Produktion tatsächlich schiefgeht und wie man es behebt.
Projektinitialisierung: Kopieren Sie nicht den offiziellen Quick-Start
Der offizielle electron-quick-start verwendet reines JavaScript, keine Build-Tools, keine Module, kein Hot Reload. Ein echtes Projekt auf diese Weise zu starten, garantiert Spaghetti-Code innerhalb von zwei Monaten.
Verwenden Sie electron-vite oder richten Sie Vite + Electron manuell ein:
npm create @quick-start/electron@latest my-app -- --template vue-ts
Oder manuelle Einrichtung:
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
Empfohlene Verzeichnisstruktur:
my-electron-app/
├── electron/ # Hauptprozess
│ ├── main.ts # Einstiegspunkt, Fenstereerstellung
│ ├── preload.ts # Sicherheitsbrücke
│ └── ipc/ # IPC-Kanalverwaltung
│ ├── file.ts # Dateioperationen
│ └── system.ts # Systeminformationen
├── src/ # Renderer (Frontend)
│ ├── App.vue
│ ├── main.ts
│ └── components/
├── resources/ # App-Icons, statische Ressourcen
├── electron-builder.yml
├── vite.config.ts
└── package.json
Die kritische Architekturentscheidung: vollständige Trennung von Haupt- und Renderer-Prozessen. Der Hauptprozess (electron/) behandelt nur Systemebenen-Operationen. Der Renderer (src/) ist ein Standard-Frontend-Projekt. Dies ermöglicht es Spezialisten auf beiden Seiten, unabhängig ohne Reibung zu arbeiten.
Prozessmodell: Wissen, wer was macht
Electron hat zwei Kernprozesse. Wenn Sie dieses Konzept nicht verinnerlichen, werden Sicherheits- und Leistungsprobleme wiederholtes Umschreiben erzwingen.
| Prozess | Laufzeitumgebung | Fähigkeiten | Einschränkungen |
|---|---|---|---|
| Main | Node.js | Fenster erstellen, Dateien lesen/schreiben, System-APIs aufrufen, Lebenszyklus verwalten | Kann DOM nicht direkt manipulieren |
| Renderer | Chromium | HTML/CSS rendern, auf Benutzereingaben reagieren, DOM manipulieren | Kann standardmäßig nicht auf Node.js-APIs zugreifen |
Die Kommunikation erfolgt über IPC (Inter-Process Communication). Der Hauptprozess stellt eine begrenzte API-Oberfläche für Renderer über contextBridge bereit.
Ein typischer Kommunikationsablauf:
Benutzer klickt auf "Datei speichern"
→ Renderer: ipcRenderer.invoke('save-file', fileData)
→ Main: ipcMain.handle('save-file', async (event, data) => {
dialog.showSaveDialog() → fs.writeFileSync()
})
→ Gibt Ergebnis an Renderer zurück
Hauptprozess-Einstieg
// 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, // Muss aktiviert sein
nodeIntegration: false, // Muss deaktiviert sein
sandbox: true, // Sandbox-Modus
},
});
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-Skript — Das Sicherheitstor Ihrer App
// 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'),
});
Aufruf aus dem Renderer:
// src/components/SaveButton.vue
const handleSave = async () => {
const result = await window.electronAPI.saveFile(
editorContent.value,
'untitled.txt'
);
if (result.success) {
showToast(`Gespeichert unter ${result.filePath}`);
}
};
contextBridge ist entscheidend — es beschränkt, was der Renderer aufrufen kann. Setzen Sie niemals rohes ipcRenderer dem Renderer aus; das würde dem Web-Inhalt vollen Node.js-Zugang geben.
Sicherheit: Sieben nicht verhandelbare Regeln
Das Sicherheitsmodell von Electron hat mehrere Iterationen durchlaufen. Alte Tutorials zeigen oft mittlerweile unsichere Muster. Stand 2026 sind dies die harten Anforderungen:
| # | Regel | Konfiguration | Warum |
|---|---|---|---|
| 1 | contextIsolation: true |
webPreferences | Isoliert den Renderer-JS-Kontext vom Preload |
| 2 | nodeIntegration: false |
webPreferences | Blockiert direktes Node.js-require im Renderer |
| 3 | sandbox: true |
webPreferences | OS-Level-Sandbox, schränkt Prozessberechtigungen ein |
| 4 | contextBridge verwenden |
preload.ts | Whitelist-basierte API-Freigabe |
| 5 | IPC-Nachrichtenursprung validieren | ipcMain.handle | event.senderFrame-URL prüfen |
| 6 | Kein Remote-Inhalt ohne CSP | webPreferences | CSP-Richtlinie für Remote-URLs erforderlich |
| 7 | Signieren und notarisieren | electron-builder | macOS erfordert Notarisierung, Windows empfiehlt Signierung |
IPC-Ursprungsvalidierung
// electron/ipc/file.ts
ipcMain.handle('save-file', async (event, content: string) => {
if (!event.senderFrame?.url.startsWith('file://')) {
throw new Error('Nicht autorisierter Aufrufer');
}
if (typeof content !== 'string' || content.length > 10 * 1024 * 1024) {
throw new Error('Ungültiger Inhalt');
}
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 };
});
Fensterverwaltung: Mehr als new BrowserWindow
Rahmenlose Fenster und benutzerdefinierte Titelleisten
Die meisten modernen Desktop-Apps verzichten auf native Titelleisten — VS Code, Figma, Notion machen das alle. Implementierung:
const mainWindow = new BrowserWindow({
frame: false,
titleBarStyle: 'hidden',
...(process.platform === 'darwin'
? { titleBarStyle: 'hiddenInset' }
: { frame: false }),
});
Benutzerdefinierte Titelleiste im Renderer:
<!-- 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>
Hinweis zu -webkit-app-region: drag — diese CSS-Eigenschaft ermöglicht Ziehen-zum-Verschieben im Titelleistenbereich. Schaltflächenbereiche müssen sie mit no-drag überschreiben, da sonst ein Klick auf eine Schaltfläche das Fensterziehen auslöst.
Kommunikation zwischen mehreren Fenstern
Komplexe Apps benötigen oft mehrere Fenster (Einstellungen, Vorschau, Dialoge). Leiten Sie die Kommunikation zwischen Fenstern über den Hauptprozess:
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);
}
});
});
Native Modulintegration: Wenn Node.js nicht ausreicht
Einige Aufgaben sind in reinem JavaScript zu langsam — Batch-Bildverarbeitung, Video-Encoding. Hier kommen C++-Native-Module ins Spiel.
Option 1: Node.js-Native-Addon (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 verwenden, um Vorschaubild zu erzeugen
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)
Option 2: Kindprozess ruft externe Tools auf
Bevorzugen Sie diese Option — entkoppelt, Abstürze beeinflussen den Hauptprozess nicht:
// 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 beendet mit ${code}: ${stderr}`));
});
});
});
Auto-Update: Nahtlose Aktualisierungen
Auto-Update ist Standard für Desktop-Apps. electron-updater (basierend auf electron-builder) ist die ausgereifteste Option:
// 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: 'Aktualisierung verfügbar',
message: 'Aktualisierung wird im Hintergrund heruntergeladen...',
});
});
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Aktualisierung bereit',
message: 'Jetzt neu starten, um die neueste Version zu installieren.',
}).then(() => {
autoUpdater.quitAndInstall();
});
});
autoUpdater.on('error', (err) => {
console.error('Aktualisierungsfehler:', err.message);
});
setTimeout(() => autoUpdater.checkForUpdates(), 5000);
}
Verpackung und Vertrieb
Beispiel für 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
Build-Befehle:
npm run dev # lokale Entwicklung
npm run build # aktuelle Plattform
npm run build:win # Windows
npm run build:mac # macOS
npm run build:linux # Linux
Leistungsoptimierung: Lassen Sie Ihre App nicht zu Chrome werden
Die größte Kritik an Electron ist der Speicherverbrauch. Eine nackte Electron-App verbraucht 50-80MB, und mit echtem Inhalt leicht 200MB+. Hier sind in der Produktion validierte Strategien:
1. Nicht-kritische Fenster lazy laden
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. Hintergrund-Drosselung
const win = new BrowserWindow({
webPreferences: {
backgroundThrottling: true,
offscreen: false,
},
});
win.on('blur', () => {
win.webContents.setBackgroundThrottling(true);
});
3. BrowserView für eingebetteten Inhalt verwenden
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. Speicherleck-Checkliste
| Prüfung | Werkzeug | Metrik |
|---|---|---|
| Zombie-Prozesse nach Fensterschließung | Task-Manager / Activity Monitor | Verbleibende Electron-Prozesse |
| Nicht zerstörte webContents | webContents.getAllWebContents() |
Wachsende Anzahl |
| Ungereinigte IPC-Listener | ipcMain.eventNames() |
Fehlender removeHandler |
| Große Objektreferenzen | Chrome DevTools Memory-Panel | Heap-Snapshot-Vergleich |
Debugging-Tipps
Hauptprozess-Debugging
# Mit angehängtem Debugger starten
electron --inspect=5858 .
# chrome://inspect in Chrome öffnen, um sich zu verbinden
VSCode launch.json:
{
"type": "node",
"request": "launch",
"name": "Debug Main Process",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"runtimeArgs": ["--inspect=5858", "."],
"port": 5858
}
Renderer-Prozess-Debugging
DevTools werden im Entwicklungsmodus automatisch geöffnet. In der Produktion mit einem Flag erzwingen:
if (process.argv.includes('--devtools')) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
Häufige Fallstricke
Fallstrick 1: macOS-Notarisierung schlägt fehl
"notarization failed: The binary is not signed"
Lösung: Stellen Sie hardenedRuntime: true, korrekte Entitlements-Datei und ein Apple Developer ID-Zertifikat sicher.
Fallstrick 2: Windows-Pfad zu lang
Windows hat ein 260-Zeichen-Pfadlimit. Tief verschachtelte node_modules überschreiten es leicht. Aktivieren Sie lange Pfadunterstützung in Windows 10 1607+ über Gruppenrichtlinie, oder verwenden Sie electron-builder install-app-deps.
Fallstrick 3: Native Module scheitern nach asar-Verpackung
electron-builder verpackt Code standardmäßig in app.asar, aber .node-Native-Module können nicht aus dem Inneren von asar geladen werden:
# electron-builder.yml
asarUnpack:
- "node_modules/sharp/**"
- "node_modules/better-sqlite3/**"
- "native/**/*.node"
Fallstrick 4: Langer weißer Bildschirm beim Start
- Vite-Ausgabe code-splitten, minimalen Code für ersten Paint laden
- Einen Splash-Screen verwenden, um die weiße Phase zu überbrücken
- Fenster erst beim
ready-to-show-Ereignis anzeigen:
mainWindow.once('ready-to-show', () => { mainWindow.show(); });
Verwandte Werkzeuge
- Base64 Kodieren/Dekodieren — Inline-Ressourcen und Dateiübertragungen verarbeiten
- JSON-Formatierer — IPC-Nachrichten und Konfigurationsdateien debuggen
- Bildkompressor — Bildressourcengröße in der App optimieren
Probiere diese browser-lokalen Tools aus — keine Registrierung erforderlich →