Electron 桌面应用开发完全指南:从环境搭建到生产发布

前端工程

为什么选择 Electron

跨平台桌面应用开发一直是个让人头疼的问题。写 Windows 用 C# 和 WPF,写 macOS 用 Swift 和 AppKit,写 Linux 用 GTK 或 Qt——三套代码,三份维护成本。Electron 的出现改变了这个局面:一套 HTML/CSS/JavaScript,打包后同时跑在 Windows、macOS 和 Linux 上。

但凡用过 VS Code、Figma、Slack、Discord 的开发者,其实每天都在用 Electron 应用。VSCode 的启动速度、Figma 的渲染性能,证明了 Electron 如果架构得当,体验完全可以媲美原生。

我帮团队迁移过一个内部工具——从 C# WinForms 改到 Electron。后续两个季度,新功能的迭代速度提升了将近三倍。原因很简单:招前端比招 C# 桌面开发容易得多,组件生态也丰富得多。

这篇文章是我踩坑后的完整总结。我不会罗列 API 文档——那些你随时能查到——我关注的是实际项目中真正会遇到的问题和对应的解决方案。


项目初始化:别再抄官方的 quick-start 了

官方 electron-quick-start 示例用裸 JavaScript,没有构建工具,没有模块化,没有热更新。真实项目这样起步,两个月后你会在屎山里改 bug。

推荐用 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 默认不能访问 Node.js API

两者通过 IPC(进程间通信) 桥接。主进程暴露有限的 API 给渲染进程,渲染进程通过 ipcRenderer 调用。

一个典型的通信流程:

用户点击"保存文件"
  → 渲染进程: ipcRenderer.invoke('save-file', fileData)
    → 主进程: 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,               // 沙箱模式
    },
  });

  // 开发环境加载 Vite 开发服务器
  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 是关键——它限制了渲染进程可调用的 API 范围。永远不要直接暴露 ipcRenderer 给渲染进程,那样等于把 Node.js 完全开放给了网页内容。


安全性:七个必须遵守的规则

Electron 的安全模型经历了多次迭代,老教程里的做法很多已经不安全了。截至 2026 年,以下是硬性要求:

# 规则 配置 原因
1 contextIsolation: true webPreferences 隔离渲染进程的 JS 上下文和 preload
2 nodeIntegration: false webPreferences 禁止渲染进程直接 require Node 模块
3 sandbox: true webPreferences 操作系统级沙箱,限制进程权限
4 使用 contextBridge preload.ts 白名单式暴露 API,而非直接挂载
5 验证 IPC 消息来源 ipcMain.handle event.senderFrame 校验调用方 URL
6 禁止远程内容 webPreferences 不加载远程 URL,除非有 CSP 策略
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('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 };
});

窗口管理:不仅仅是 new BrowserWindow

无边框窗口与自定义标题栏

大多数现代桌面应用都抛弃了系统原生标题栏——VS Code、Figma、Notion 都是这样。实现方式:

const mainWindow = new BrowserWindow({
  frame: false,              // 无边框
  titleBarStyle: 'hidden',   // macOS 隐藏标题栏但保留红绿灯
  // Windows 下用 hidden,macOS 下用 hiddenInset
  ...(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>

<style scoped>
.title-bar {
  display: flex;
  height: 32px;
  background: #1e1e1e;
  -webkit-app-region: no-drag;
}
.title-bar-drag {
  flex: 1;
  -webkit-app-region: drag;
  display: flex;
  align-items: center;
  padding-left: 12px;
}
.app-title {
  color: #ccc;
  font-size: 12px;
}
.window-controls { display: flex; }
.control-btn {
  width: 46px;
  height: 32px;
  border: none;
  background: transparent;
  color: #ccc;
  font-size: 12px;
  cursor: pointer;
}
.control-btn:hover { background: rgba(255,255,255,0.1); }
.control-btn.close:hover { background: #e81123; color: #fff; }
</style>

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

或者直接在 preload 中暴露 MessagePort

// 通过 MessageChannel 直接通信,不经过主进程中转
const { port1, port2 } = new MessageChannelMain();
ipcMain.on('request-port', (event) => {
  event.ports[0].postMessage('port-opened');
});

原生模块集成:当 Node.js 不够用时

有些场景 JavaScript 性能不够——比如图片批量处理、视频编解码。这时需要 C++ 原生模块。

方案一: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 生成缩略图
  // ...

  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)

编译后在主进程中加载:

const { generateThumbnail } = require('./native/build/Release/thumbnail.node');

方案二:子进程调用外部工具

更推荐的方式——解耦,崩溃不影响主进程:

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

自动更新:用户无感升级

自动更新是桌面应用的标配。electron-updater(基于 electron-builder)是最成熟的方案:

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

export function setupAutoUpdater(mainWindow: BrowserWindow) {
  // 配置更新源(可以是 GitHub Releases、自建服务器、OSS)
  autoUpdater.setFeedURL({
    provider: 'generic',
    url: 'https://releases.your-app.com/updates/',
  });

  // 每 4 小时检查一次
  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('Update error:', err.message);
  });

  // 应用启动 5 秒后检查一次
  setTimeout(() => autoUpdater.checkForUpdates(), 5000);
}

自建更新服务器的目录结构

updates/
├── latest.yml              # 最新版本元信息
├── YourApp-1.0.0.exe
├── YourApp-1.0.0.exe.blockmap
├── YourApp-1.0.1.exe
└── YourApp-1.0.1.exe.blockmap

latest.yml 内容由 electron-builder 自动生成。部署到 Nginx 或 OSS 即可。


打包与分发

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
  sign: scripts/sign-windows.js
mac:
  target:
    - target: dmg
      arch: [x64, arm64]
  icon: resources/icon.icns
  category: public.app-category.developer-tools
  hardenedRuntime: true
  entitlements: resources/entitlements.mac.plist
  notarize:
    teamId: YOUR_TEAM_ID
linux:
  target:
    - target: AppImage
      arch: [x64]
    - target: deb
      arch: [x64]
  category: Development
nsis:
  oneClick: false
  allowToChangeInstallationDirectory: true
  createDesktopShortcut: true

打包命令:

# 本地开发
npm run dev

# 构建当前平台
npm run build

# 构建全平台(需 CI 环境)
npm run build:win
npm run build:mac
npm run build:linux

性能优化:别让你的应用变成 Chrome

Electron 被诟病最多的是内存占用。一个空 Electron 自带 50-80MB,加上实际页面很容易上 200MB。以下是在项目中验证过的优化策略:

1. 延迟加载非首屏窗口

// 不要在 app.whenReady 时创建所有窗口
// 按需创建,用完立即销毁
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 替代子窗口

对于需要嵌入第三方内容(如预览 HTML、PDF)的场景,用 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 遗漏
大对象引用未释放 Chrome DevTools Memory 面板 堆快照对比

调试技巧

主进程调试

# 启动时附加调试器
electron --inspect=5858 .

# Chrome 中打开 chrome://inspect 连接

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

真机联调

# 获取应用路径
process.execPath
# → /Applications/YourApp.app/Contents/MacOS/YourApp (macOS)
# → C:\Users\...\AppData\Local\Programs\your-app\YourApp.exe (Windows)

# 直接运行可执行文件查看控制台输出

常见踩坑记录

坑 1:macOS 公证失败

"notarization failed: The binary is not signed"

解决:确认 hardenedRuntime: true,entitlements 文件正确,且使用 Apple Developer ID 证书(非 Mac App Store 证书需单独公证)。

坑 2:Windows 下路径过长

Windows 路径限制 260 字符。node_modules 深层嵌套极易超标。解决方案:

// package.json
{
  "scripts": {
    "postinstall": "electron-builder install-app-deps"
  }
}

或者启用 Windows 长路径支持(Windows 10 1607+):

组策略 → 计算机配置 → 管理模板 → 系统 → 文件系统 → 启用 Win32 长路径

坑 3:asar 打包后 native 模块加载失败

electron-builder 默认将代码打包为 app.asar,但 .node 原生模块无法从 asar 内部加载。

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

坑 4:启动白屏时间过长

优化策略:

  • Vite 构建产物做 code split,首屏只加载必要代码
  • 使用骨架屏或启动图覆盖白屏期
  • 主进程在 ready-to-show 事件后才显示窗口:
mainWindow.once('ready-to-show', () => {
  mainWindow.show();
});

相关工具

本站提供浏览器本地工具,免注册即可试用 →

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