Electron Desktop App Development Complete Guide: From Setup to Production

前端工程

Why Electron

Cross-platform desktop development has always been a headache. Write Windows apps in C# and WPF, macOS apps in Swift and AppKit, Linux apps in GTK or Qt — three codebases, triple the maintenance. Electron changed the game: one HTML/CSS/JavaScript codebase, packaged for Windows, macOS, and Linux simultaneously.

If you use VS Code, Figma, Slack, or Discord, you're already running Electron apps every day. VS Code's startup speed and Figma's rendering performance prove that Electron can match native experience with the right architecture.

I helped migrate an internal tool from C# WinForms to Electron. Feature delivery speed tripled over the next two quarters. The reason is simple: hiring frontend developers is far easier than hiring C# desktop developers, and the component ecosystem is vastly richer.

This article is my complete post-migration summary. I won't recite API documentation — you can look that up — I'll focus on what actually goes wrong in production and how to fix it.


Project Initialization: Don't Copy the Official Quick-Start

The official electron-quick-start uses bare JavaScript, no build tooling, no modules, no hot reload. Starting a real project this way guarantees spaghetti within two months.

Use electron-vite or manually set up Vite + Electron:

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

Or manual setup:

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

Recommended directory structure:

my-electron-app/
├── electron/              # Main process
│   ├── main.ts           # Entry point, window creation
│   ├── preload.ts        # Security bridge
│   └── ipc/              # IPC channel management
│       ├── file.ts       # File operations
│       └── system.ts     # System information
├── src/                   # Renderer (frontend)
│   ├── App.vue
│   ├── main.ts
│   └── components/
├── resources/             # App icons, static assets
├── electron-builder.yml
├── vite.config.ts
└── package.json

The critical architectural decision: complete separation of main and renderer processes. The main process (electron/) handles only system-level operations. The renderer (src/) is a standard frontend project. This lets specialists on each side work independently without friction.


Process Model: Know Who Does What

Electron has two core processes. If you don't internalize this concept, security and performance issues will force repeated rewrites.

Process Runtime Capabilities Limitations
Main Node.js Create windows, read/write files, call system APIs, manage lifecycle Cannot directly manipulate DOM
Renderer Chromium Render HTML/CSS, respond to user input, manipulate DOM Cannot access Node.js APIs by default

Communication happens through IPC (Inter-Process Communication). The main process exposes a limited API surface to renderers via contextBridge.

A typical communication flow:

User clicks "Save File"
  → Renderer: ipcRenderer.invoke('save-file', fileData)
    → Main: ipcMain.handle('save-file', async (event, data) => {
        dialog.showSaveDialog() → fs.writeFileSync()
      })
    → Returns result to renderer

Main Process Entry

// 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,      // Must enable
      nodeIntegration: false,      // Must disable
      sandbox: true,               // Sandbox mode
    },
  });

  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 Script — Your App's Security Gate

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

Calling from the renderer:

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

contextBridge is crucial — it restricts what the renderer can call. Never expose raw ipcRenderer to the renderer; that would give web content full Node.js access.


Security: Seven Non-Negotiable Rules

Electron's security model has evolved through multiple iterations. Old tutorials often show now-unsafe patterns. As of 2026, here are the hard requirements:

# Rule Config Why
1 contextIsolation: true webPreferences Isolates renderer JS context from preload
2 nodeIntegration: false webPreferences Blocks direct Node.js require in renderer
3 sandbox: true webPreferences OS-level sandbox, restricts process permissions
4 Use contextBridge preload.ts Whitelist-based API exposure
5 Validate IPC message origin ipcMain.handle Check event.senderFrame URL
6 No remote content without CSP webPreferences CSP policy required for remote URLs
7 Sign and notarize electron-builder macOS requires notarization, Windows recommends signing

IPC Origin Validation

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

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

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

Window Management: Beyond new BrowserWindow

Frameless Windows and Custom Title Bars

Most modern desktop apps abandon native title bars — VS Code, Figma, Notion all do this. Implementation:

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

Custom title bar in the 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>

Note -webkit-app-region: drag — this CSS property enables drag-to-move on the title bar area. Button areas must override it with no-drag, otherwise clicking a button triggers window dragging.

Multi-Window Communication

Complex apps often need multiple windows (settings, preview, dialogs). Route inter-window communication through the main process:

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 Module Integration: When Node.js Isn't Enough

Some tasks are too slow in pure JavaScript — batch image processing, video encoding. That's where C++ native modules come in.

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>();
  // Use FFmpeg/libvips to generate thumbnail
  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: Child Process Calling External Tools

Prefer this — decoupled, crashes don't affect the main process:

// 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 exited with ${code}: ${stderr}`));
    });
  });
});

Auto-Update: Seamless Upgrades

Auto-update is standard for desktop apps. electron-updater (based on electron-builder) is the most mature 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: 'Update Available',
      message: 'Downloading update in background...',
    });
  });

  autoUpdater.on('update-downloaded', () => {
    dialog.showMessageBox(mainWindow, {
      type: 'info',
      title: 'Update Ready',
      message: 'Restart now to install the latest version.',
    }).then(() => {
      autoUpdater.quitAndInstall();
    });
  });

  autoUpdater.on('error', (err) => {
    console.error('Update error:', err.message);
  });

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

Packaging and Distribution

electron-builder.yml example:

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 commands:

npm run dev           # local development
npm run build         # current platform
npm run build:win     # Windows
npm run build:mac     # macOS
npm run build:linux   # Linux

Performance Optimization: Don't Let Your App Become Chrome

Electron's biggest criticism is memory usage. A bare Electron app uses 50-80MB, and with actual content, easily 200MB+. Here are strategies validated in production:

1. Lazy-Load Non-Critical Windows

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. Background Throttling

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

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

3. Use BrowserView for Embedded Content

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. Memory Leak Checklist

Check Tool Metric
Zombie processes after window close Task Manager / Activity Monitor Lingering electron processes
Undestroyed webContents webContents.getAllWebContents() Growing count
Uncleaned IPC listeners ipcMain.eventNames() Missing removeHandler
Large object references Chrome DevTools Memory panel Heap snapshot comparison

Debugging Tips

Main Process Debugging

# Launch with debugger attached
electron --inspect=5858 .
# Open chrome://inspect in Chrome to connect

VSCode launch.json:

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

Renderer Process Debugging

DevTools are automatically open in development mode. In production, force with a flag:

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

Common Pitfalls

Pitfall 1: macOS Notarization Fails

"notarization failed: The binary is not signed"

Fix: Ensure hardenedRuntime: true, correct entitlements file, and an Apple Developer ID certificate.

Pitfall 2: Windows Path Too Long

Windows has a 260-character path limit. Deeply nested node_modules easily exceed it. Enable long path support in Windows 10 1607+ via Group Policy, or use electron-builder install-app-deps.

Pitfall 3: Native Modules Fail After asar Packaging

electron-builder packages code into app.asar by default, but .node native modules can't load from inside asar:

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

Pitfall 4: Long White Screen on Launch

  • Code-split Vite output, load minimal code for first paint
  • Use a splash screen to cover the white period
  • Show window only on ready-to-show event:
mainWindow.once('ready-to-show', () => { mainWindow.show(); });

Try these browser-local tools — no sign-up required →

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