3、Electron核心概念:主进程与渲染进程、进程间通信(IPC)、Electron生命周期

好,咱们直接进入正题。这一章是Electron的骨架,搞懂了它,后面写代码就像搭积木一样简单。

很多新手一上来就被「主进程」「渲染进程」搞晕了。我刚开始学的时候也这样,心想:不就是写个桌面应用吗,怎么还分两个进程?

嗯,这里有个关键原因:安全。浏览器不允许JS直接操作文件系统,但桌面应用需要。Electron就把「能碰系统」的部分和「展示界面」的部分拆开了。

3.1 主进程与渲染进程

主进程(Main Process),说白了就是应用的「大脑」。它负责:

  • 创建窗口(new BrowserWindow)
  • 调用系统API(文件读写、通知、菜单)
  • 管理应用生命周期

每个Electron应用有且只有一个主进程。它运行在Node.js环境里,所以你能用require、fs、path这些模块。

渲染进程(Renderer Process),就是「脸面」。每个窗口都是一个独立的渲染进程。它运行在Chromium环境里,负责:

  • 展示HTML/CSS/JS页面
  • 处理用户交互(点击、输入)
  • 调用浏览器API(DOM操作、localStorage)

核心区别一句话:主进程管系统,渲染进程管界面。两者不能直接互相调用。

我在项目中遇到过一个问题:新手在渲染进程里直接写 require('fs'),结果报错。为什么?因为渲染进程默认没有Node.js权限。你需要手动开启 nodeIntegration: true,但我不建议这么做——有安全风险。

3.2 进程间通信(IPC)

既然两个进程不能直接对话,那怎么让它们协作?答案就是IPC(Inter-Process Communication)。

Electron提供了两种IPC方式:

方式 方向 使用场景
ipcMain + ipcRenderer 双向通信 渲染进程请求数据,主进程返回结果
contextBridge + preload 安全桥接 暴露有限的API给渲染进程

先看最基础的用法:

// 主进程 (main.js)
const { ipcMain } = require('electron')

ipcMain.handle('read-file', async (event, filePath) => {
  const fs = require('fs')
  return fs.readFileSync(filePath, 'utf-8')
})
// 渲染进程 (renderer.js)
const { ipcRenderer } = require('electron')

async function loadFile() {
  const content = await ipcRenderer.invoke('read-file', './data.txt')
  console.log(content)
}

你看,渲染进程发一个「请求」,主进程处理完再「回复」。这就是IPC的核心逻辑。

我的习惯:把所有IPC通道名定义在一个常量文件里,比如 IPC_CHANNELS.js。这样两边引用同一个名字,不会写错。

但注意!上面那种写法在最新版Electron里会报安全警告。为什么?因为渲染进程直接用了 require('electron'),这等于把整个Electron API暴露给了网页代码——万一有XSS攻击,后果很严重。

所以,推荐做法是用 preload 脚本 + contextBridge:

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  readFile: (filePath) => ipcRenderer.invoke('read-file', filePath)
})
// 渲染进程 (renderer.js)
// 现在只能用 window.electronAPI.readFile()
async function loadFile() {
  const content = await window.electronAPI.readFile('./data.txt')
  console.log(content)
}

我曾经在一个项目里偷懒,直接开了nodeIntegration。结果上线后用户反馈页面被注入了恶意脚本,差点酿成大祸。从那以后,我再也不敢跳过preload了。

3.3 Electron生命周期

一个Electron应用从启动到关闭,会经历几个关键事件。理解这些事件,你才能控制好「什么时候该做什么事」。

核心生命周期事件:

事件 触发时机 典型用途
ready Electron初始化完成 创建窗口、注册IPC
window-all-closed 所有窗口都关闭了 macOS上不退出应用,其他平台退出
activate macOS点击Dock图标 重新创建窗口(如果没窗口的话)
before-quit 应用准备退出前 保存用户数据、清理资源

标准启动流程:

const { app, BrowserWindow } = require('electron')

// 1. 等待ready
app.whenReady().then(() => {
  // 2. 创建窗口
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  // 3. 加载页面
  win.loadFile('index.html')
})

// 4. 处理窗口关闭
app.on('window-all-closed', () => {
  // macOS习惯:不退出应用
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

// 5. macOS点击Dock重新激活
app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
})

你想想看,为什么macOS上关闭窗口不退出应用?因为苹果的设计理念是「应用一直运行,窗口只是视图」。Windows用户可能不习惯,但跨平台开发就得尊重各平台的习惯。

避坑指南:我曾经在 ready 事件之前就调用了 BrowserWindow,结果应用直接崩溃。记住:所有窗口操作必须在 ready 之后进行。

还有一个容易被忽略的点:子进程的退出。如果你的应用启动了子进程(比如Python脚本),记得在 before-quit 里杀掉它们,否则会出现「应用关了但进程还在跑」的诡异情况。

好了,这一章的核心就这些。总结一下:

  • 主进程管系统,渲染进程管界面
  • IPC用 preload + contextBridge 最安全
  • 生命周期事件控制着应用的「生老病死」

下一章我们就要动手写第一个Electron应用了。到时候你会发现,这些概念会反复用到。