Guide complet de développement d'applications desktop avec Electron : De la configuration à la production

前端工程

Pourquoi Electron

Le développement desktop multiplateforme a toujours été un casse-tête. Écrire des applications Windows en C# et WPF, des applications macOS en Swift et AppKit, des applications Linux en GTK ou Qt — trois bases de code, triple maintenance. Electron a changé la donne : une seule base de code HTML/CSS/JavaScript, empaquetée pour Windows, macOS et Linux simultanément.

Si vous utilisez VS Code, Figma, Slack ou Discord, vous exécutez déjà des applications Electron tous les jours. La vitesse de démarrage de VS Code et les performances de rendu de Figma prouvent qu'Electron peut égaler l'expérience native avec la bonne architecture.

J'ai aidé à migrer un outil interne de C# WinForms vers Electron. La vitesse de livraison des fonctionnalités a triplé au cours des deux trimestres suivants. La raison est simple : embaucher des développeurs frontend est bien plus facile que d'embaucher des développeurs desktop en C#, et l'écosystème de composants est nettement plus riche.

Cet article est mon résumé complet post-migration. Je ne vais pas réciter la documentation API — vous pouvez la consulter vous-même — je vais me concentrer sur ce qui plante réellement en production et comment le réparer.


Initialisation du projet : Ne copiez pas le Quick-Start officiel

Le electron-quick-start officiel utilise du JavaScript brut, sans outils de build, sans modules, sans hot reload. Commencer un vrai projet de cette façon garantit du code spaghetti en deux mois.

Utilisez electron-vite ou configurez manuellement Vite + Electron :

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

Ou configuration manuelle :

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

Structure de répertoires recommandée :

my-electron-app/
├── electron/              # Processus principal
│   ├── main.ts           # Point d'entrée, création de fenêtres
│   ├── preload.ts        # Pont de sécurité
│   └── ipc/              # Gestion des canaux IPC
│       ├── file.ts       # Opérations sur les fichiers
│       └── system.ts     # Informations système
├── src/                   # Rendu (frontend)
│   ├── App.vue
│   ├── main.ts
│   └── components/
├── resources/             # Icônes de l'app, ressources statiques
├── electron-builder.yml
├── vite.config.ts
└── package.json

La décision architecturale critique : séparation complète des processus principal et de rendu. Le processus principal (electron/) gère uniquement les opérations système. Le rendu (src/) est un projet frontend standard. Cela permet aux spécialistes de chaque côté de travailler indépendamment sans friction.


Modèle de processus : Savoir qui fait quoi

Electron possède deux processus fondamentaux. Si vous n'assimilez pas ce concept, les problèmes de sécurité et de performance vous obligeront à réécrire le code à plusieurs reprises.

Processus Runtime Capacités Limitations
Principal Node.js Créer des fenêtres, lire/écrire des fichiers, appeler les API système, gérer le cycle de vie Ne peut pas manipuler le DOM directement
Rendu Chromium Rendre le HTML/CSS, répondre aux entrées utilisateur, manipuler le DOM Ne peut pas accéder aux API Node.js par défaut

La communication se fait via IPC (Inter-Process Communication). Le processus principal expose une surface d'API limitée aux rendus via contextBridge.

Un flux de communication typique :

L'utilisateur clique sur "Enregistrer le fichier"
  → Rendu : ipcRenderer.invoke('save-file', fileData)
    → Principal : ipcMain.handle('save-file', async (event, data) => {
        dialog.showSaveDialog() → fs.writeFileSync()
      })
    → Retourne le résultat au rendu

Point d'entrée du processus principal

// 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,      // Doit être activé
      nodeIntegration: false,      // Doit être désactivé
      sandbox: true,               // Mode bac à sable
    },
  });

  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();
  }
});

Script de preload — La porte de sécurité de votre application

// 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'),
});

Appel depuis le rendu :

// src/components/SaveButton.vue
const handleSave = async () => {
  const result = await window.electronAPI.saveFile(
    editorContent.value,
    'untitled.txt'
  );
  if (result.success) {
    showToast(`Enregistré dans ${result.filePath}`);
  }
};

contextBridge est crucial — il restreint ce que le rendu peut appeler. N'exposez jamais le ipcRenderer brut au rendu ; cela donnerait au contenu web un accès complet à Node.js.


Sécurité : Sept règles non négociables

Le modèle de sécurité d'Electron a évolué à travers plusieurs itérations. Les anciens tutoriels montrent souvent des motifs désormais non sécurisés. En 2026, voici les exigences strictes :

# Règle Configuration Pourquoi
1 contextIsolation: true webPreferences Isole le contexte JS du rendu du preload
2 nodeIntegration: false webPreferences Bloque le require Node.js direct dans le rendu
3 sandbox: true webPreferences Bac à sable au niveau de l'OS, restreint les permissions du processus
4 Utiliser contextBridge preload.ts Exposition d'API basée sur une liste blanche
5 Valider l'origine des messages IPC ipcMain.handle Vérifier l'URL de event.senderFrame
6 Pas de contenu distant sans CSP webPreferences Politique CSP requise pour les URL distantes
7 Signer et notariser electron-builder macOS exige la notarisation, Windows recommande la signature

Validation de l'origine IPC

// electron/ipc/file.ts
ipcMain.handle('save-file', async (event, content: string) => {
  if (!event.senderFrame?.url.startsWith('file://')) {
    throw new Error('Appelant non autorisé');
  }

  if (typeof content !== 'string' || content.length > 10 * 1024 * 1024) {
    throw new Error('Contenu invalide');
  }

  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 };
});

Gestion des fenêtres : Au-delà de new BrowserWindow

Fenêtres sans cadre et barres de titre personnalisées

La plupart des applications desktop modernes abandonnent les barres de titre natives — VS Code, Figma, Notion le font toutes. Implémentation :

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

Barre de titre personnalisée dans le rendu :

<!-- 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>

Note sur -webkit-app-region: drag — cette propriété CSS permet le glisser-déplacer sur la zone de la barre de titre. Les zones de boutons doivent la surcharger avec no-drag, sinon cliquer sur un bouton déclenche le déplacement de la fenêtre.

Communication entre plusieurs fenêtres

Les applications complexes nécessitent souvent plusieurs fenêtres (paramètres, aperçu, dialogues). Acheminez la communication inter-fenêtres via le processus principal :

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);
    }
  });
});

Intégration de modules natifs : Quand Node.js ne suffit pas

Certaines tâches sont trop lentes en JavaScript pur — traitement d'images par lot, encodage vidéo. C'est là que les modules natifs C++ interviennent.

Option 1 : Addon natif 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>();
  // Utiliser FFmpeg/libvips pour générer la vignette
  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 : Processus fils appelant des outils externes

Préférez cette option — découplée, les plantages n'affectent pas le processus principal :

// 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 a quitté avec le code ${code} : ${stderr}`));
    });
  });
});

Mise à jour automatique : Mises à niveau transparentes

La mise à jour automatique est la norme pour les applications desktop. electron-updater (basé sur electron-builder) est l'option la plus mature :

// 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: 'Mise à jour disponible',
      message: 'Téléchargement de la mise à jour en arrière-plan...',
    });
  });

  autoUpdater.on('update-downloaded', () => {
    dialog.showMessageBox(mainWindow, {
      type: 'info',
      title: 'Mise à jour prête',
      message: 'Redémarrez maintenant pour installer la dernière version.',
    }).then(() => {
      autoUpdater.quitAndInstall();
    });
  });

  autoUpdater.on('error', (err) => {
    console.error('Erreur de mise à jour :', err.message);
  });

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

Empaquetage et distribution

Exemple de 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

Commandes de build :

npm run dev           # développement local
npm run build         # plateforme actuelle
npm run build:win     # Windows
npm run build:mac     # macOS
npm run build:linux   # Linux

Optimisation des performances : Ne laissez pas votre application devenir Chrome

La principale critique d'Electron est sa consommation mémoire. Une application Electron basique utilise 50-80 Mo, et avec du contenu réel, dépasse facilement les 200 Mo+. Voici des stratégies validées en production :

1. Chargement différé des fenêtres non critiques

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. Limitation en arrière-plan

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

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

3. Utiliser BrowserView pour le contenu intégré

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. Liste de contrôle des fuites mémoire

Vérification Outil Métrique
Processus zombies après fermeture de fenêtre Gestionnaire des tâches / Activity Monitor Processus electron persistants
webContents non détruits webContents.getAllWebContents() Compte croissant
Listeners IPC non nettoyés ipcMain.eventNames() removeHandler manquant
Références à de grands objets Panneau Memory de Chrome DevTools Comparaison d'instantanés du tas

Conseils de débogage

Débogage du processus principal

# Lancer avec le débogueur attaché
electron --inspect=5858 .
# Ouvrir chrome://inspect dans Chrome pour se connecter

VSCode launch.json :

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

Débogage du processus de rendu

Les DevTools s'ouvrent automatiquement en mode développement. En production, forcez-les avec un flag :

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

Pièges courants

Piège 1 : La notarisation macOS échoue

"notarization failed: The binary is not signed"

Solution : Assurez-vous d'avoir hardenedRuntime: true, un fichier d'entitlements correct et un certificat Apple Developer ID.

Piège 2 : Chemin Windows trop long

Windows a une limite de chemin de 260 caractères. Les node_modules profondément imbriqués la dépassent facilement. Activez la prise en charge des chemins longs dans Windows 10 1607+ via la stratégie de groupe, ou utilisez electron-builder install-app-deps.

Piège 3 : Les modules natifs échouent après l'empaquetage asar

electron-builder empaquette le code dans app.asar par défaut, mais les modules natifs .node ne peuvent pas être chargés depuis l'intérieur de l'asar :

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

Piège 4 : Long écran blanc au démarrage

  • Divisez la sortie Vite par code-splitting, chargez un code minimal pour le premier rendu
  • Utilisez un écran de démarrage pour couvrir la période blanche
  • Affichez la fenêtre uniquement sur l'événement ready-to-show :
mainWindow.once('ready-to-show', () => { mainWindow.show(); });

Outils associés

Essayez ces outils exécutés localement dans le navigateur — aucune inscription requise →

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