Featured image of post Electron 跨平台桌面应用:从零搭建到打包分发

Electron 跨平台桌面应用:从零搭建到打包分发

Electron 桌面应用开发完整指南:环境搭建、main + renderer 双进程架构、IPC 通信、菜单与对话框、preload 脚本、安全配置、electron-builder 打包分发。

什么是 Electron

Electron 是 GitHub 2013 年推出的开源框架,让开发者用 HTML + CSS + JavaScript 构建跨平台桌面应用。核心 = Chromium + Node.js

优势

  • Web 技术栈:React / Vue / Angular 任意
  • 跨平台:一套代码,Windows / macOS / Linux
  • 完整 Node.js API:文件系统、子进程、网络
  • 生态丰富:VSCode、Slack、Discord、Notion 都是 Electron

快速开始

环境要求

  • Node.js 18+
  • npm / yarn / pnpm
  • 平台原生工具:Windows 需 Visual Studio Build Tools,macOS 需 Xcode CLT,Linux 需 libnss3

创建项目

1
2
mkdir my-electron-app && cd my-electron-app
npm init -y

修改 package.json

1
2
3
4
5
6
7
8
{
  "name": "my-electron-app",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  }
}

安装:

1
npm install --save-dev electron

package.json 会加:

1
2
3
"devDependencies": {
  "electron": "^33.2.0"
}

main.js(主进程)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const { app, BrowserWindow } = require('electron');
const path = require('path');

const createWindow = () => {
  const win = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,    // 默认 true(安全)
      nodeIntegration: false,    // 默认 false(安全)
    },
  });

  win.loadFile('index.html');
  // win.webContents.openDevTools();
};

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

preload.js(预加载)

1
2
3
4
5
6
7
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('api', {
  sendMessage: (msg) => ipcRenderer.send('message', msg),
  onReply: (callback) => ipcRenderer.on('reply', callback),
  invokeMethod: (method, args) => ipcRenderer.invoke(method, args),
});

index.html(渲染进程)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>My App</title>
</head>
<body>
  <h1>Hello, Electron!</h1>
  <button id="btn">点击</button>
  <p id="output"></p>

  <script>
    document.getElementById('btn').addEventListener('click', () => {
      window.api.invokeMethod('greet', 'World').then((result) => {
        document.getElementById('output').textContent = result;
      });
    });
  </script>
</body>
</html>

启动

1
npm start

打开开发者工具(DevTools):Ctrl + Shift + I

进程架构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
┌──────────────────────┐         ┌──────────────────────┐
  Main Process                   Renderer Process    
  (Node.js)             IPC      (Chromium)          
                       ←─────→                       
  - 应用生命周期                  - 渲染 UI           
  - 创建 / 关闭窗口               - Web 前端          
  - 菜单 / 托盘                  - 不能直接用 Node   
  - 调用系统 API                                     
└──────────────────────┘         └──────────────────────┘
                                           
                                           
                  ┌────────────────┐       
         └────────→│  Preload       │←──────┘
                     (桥接)         
                     - contextBridge
                     - 暴露受限 API 
                   └────────────────┘

IPC 通信

方式 1:invoke / handle(推荐)

渲染进程

1
2
3
// renderer.js
const result = await window.api.invokeMethod('greet', 'World');
console.log(result);

preload.js

1
2
3
contextBridge.exposeInMainWorld('api', {
  invokeMethod: (method, args) => ipcRenderer.invoke(method, args),
});

主进程

1
2
3
4
5
const { ipcMain } = require('electron');

ipcMain.handle('greet', async (event, name) => {
  return `Hello, ${name}!`;
});

方式 2:send / on(单向)

渲染进程

1
2
3
4
window.api.sendMessage('用户点击了');
window.api.onReply((event, data) => {
  console.log('收到主进程回复:', data);
});

preload.js

1
2
3
4
contextBridge.exposeInMainWorld('api', {
  sendMessage: (msg) => ipcRenderer.send('message', msg),
  onReply: (callback) => ipcRenderer.on('reply', callback),
});

主进程

1
2
3
4
ipcMain.on('message', (event, msg) => {
  console.log('收到:', msg);
  event.sender.send('reply', '已收到');
});

主进程 API

菜单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const { Menu, app } = require('electron');

const template = [
  {
    label: '文件',
    submenu: [
      { label: '新建', accelerator: 'CmdOrCtrl+N', click: () => { /* ... */ } },
      { type: 'separator' },
      { role: 'quit' },
    ],
  },
  {
    label: '编辑',
    submenu: [
      { role: 'undo' },
      { role: 'redo' },
      { type: 'separator' },
      { role: 'cut' },
      { role: 'copy' },
      { role: 'paste' },
    ],
  },
];

const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);

对话框

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { dialog } = require('electron');

// 打开文件
const result = await dialog.showOpenDialog({
  title: '选择文件',
  filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
console.log(result.filePaths);

// 保存文件
const result = await dialog.showSaveDialog({
  title: '保存',
  defaultPath: 'untitled.txt',
});

// 消息框
await dialog.showMessageBox({
  type: 'info',
  title: '提示',
  message: '操作成功',
  detail: '已保存到磁盘',
});

系统托盘

1
2
3
4
5
6
7
8
const { Tray, Menu, nativeImage } = require('electron');

const tray = new Tray(nativeImage.createEmpty());
tray.setToolTip('My App');
tray.setContextMenu(Menu.buildFromTemplate([
  { label: '显示', click: () => { /* show window */ } },
  { label: '退出', click: () => { app.quit(); } },
]));

通知

1
2
3
4
new Notification({
  title: '新消息',
  body: 'Hello from main process',
}).show();

文件系统

主进程用 fs,渲染进程通过 IPC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 主进程
ipcMain.handle('read-file', async (event, path) => {
  const fs = require('fs/promises');
  return await fs.readFile(path, 'utf8');
});

ipcMain.handle('write-file', async (event, path, content) => {
  const fs = require('fs/promises');
  await fs.writeFile(path, content);
});

渲染进程访问

完整框架(React / Vue)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# React + Vite
npm create vite@latest my-electron-app -- --template react-ts
cd my-electron-app
npm install
npm install --save-dev electron electron-builder

# 配置 vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  base: './',
  plugins: [react()],
  build: {
    outDir: 'dist',
  },
});
1
2
3
4
5
6
7
// package.json
"main": "main.js",
"scripts": {
  "dev": "concurrently \"vite\" \"electron .\"",
  "build": "vite build",
  "package": "electron-builder"
}

文件结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
my-electron-app/
├── electron/
   ├── main.js
   └── preload.js
├── src/
   ├── App.tsx
   └── main.tsx
├── index.html
├── vite.config.ts
└── package.json

打包分发

electron-builder

1
npm install --save-dev electron-builder

package.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
  "build": {
    "appId": "com.example.myapp",
    "productName": "MyApp",
    "directories": {
      "output": "dist"
    },
    "files": [
      "electron/**/*",
      "dist/**/*",
      "package.json"
    ],
    "win": {
      "target": ["nsis", "portable"],
      "icon": "build/icon.ico"
    },
    "mac": {
      "target": ["dmg", "zip"],
      "icon": "build/icon.icns",
      "category": "public.app-category.developer-tools"
    },
    "linux": {
      "target": ["AppImage", "deb"],
      "icon": "build/icon.png",
      "category": "Development"
    }
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 用 forge 一键导入
npx electron-forge import

# 打包
npm run make

# 结果
# Windows: out/MyApp Setup 1.0.0.exe
# macOS:   out/MyApp-1.0.0.dmg
# Linux:   out/MyApp-1.0.0.AppImage

electron-forge(替代方案)

1
2
3
npm install --save-dev @electron-forge/cli
npx electron-forge import
npm run make

安全最佳实践

必须开启

1
2
3
4
5
6
7
8
new BrowserWindow({
  webPreferences: {
    contextIsolation: true,      // 沙箱
    nodeIntegration: false,      // 渲染进程禁用 Node
    sandbox: true,                // Chromium 沙箱
    preload: path.join(__dirname, 'preload.js'),
  },
});

关闭新窗口

1
2
3
4
5
6
app.on('web-contents-created', (event, contents) => {
  contents.setWindowOpenHandler(({ url }) => {
    require('electron').shell.openExternal(url);
    return { action: 'deny' };
  });
});

CSP

1
2
<meta http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'">

自动更新

1
npm install --save-dev electron-updater
1
2
3
4
// main.js
const { autoUpdater } = require('electron-updater');

autoUpdater.checkForUpdatesAndNotify();

package.json

1
2
3
4
5
6
7
"build": {
  "publish": {
    "provider": "github",
    "owner": "yourname",
    "repo": "myapp"
  }
}

性能优化

  • 开启 V8 缓存--js-flags="--max-old-space-size=4096"
  • 启用 lazy load:路由懒加载
  • Web Worker 处理密集计算
  • offscreen 渲染:截图 / 录屏

常见问题

启动白屏

通常是预加载脚本报错。检查 preload.js 路径和 contextBridge 用法。

require is not defined

渲染进程不能直接用 require,必须通过 preload.js 暴露。

跨域请求

主进程可以任意跨域;渲染进程需要用 IPC 转发到主进程发起请求。

macOS 签名

1
2
3
# 开发者证书
codesign --deep --force --verify --verbose --sign "Developer ID Application: Name" MyApp.app
xcrun notarytool submit MyApp.zip --keychain-profile "AC_PASSWORD"

下一步

  • Tauri 2.x 实战,看 2020-05-15《Tauri 2.x 跨平台桌面应用》
  • 桌面开发技术栈对比,看 2014-08-15《桌面开发技术栈对比》

参考资料

  • Electron 官方:https://www.electronjs.org/
  • Electron 文档:https://www.electronjs.org/docs/latest/
  • electron-builder:https://www.electron.build/
  • electron-forge:https://www.electronforge.io/
  • Awesome Electron:https://github.com/sindresorhus/awesome-electron
使用 Hugo 构建
主题 StackJimmy 设计