Electron 入门实战:用一个加法计算器吃透 Electron 核心概念

2 阅读7分钟

这不是一篇只讲概念的八股文,而是一篇能直接跑起来的实战小册
我们从一个极简的 Electron 应用——加法计算器(macOS)出发,一路串起:主进程、渲染进程、预加载脚本(preload)和 IPC(进程间通信)。


一、学习第一步,Electron 是什么?

一句话:用 Web 技术写桌面应用
一个最小可用的 Electron 应用,至少有这三类角色:

  • 主进程(Main Process):Node.js 环境,负责创建窗口、系统 API、生命周期。
  • 渲染进程(Renderer Process):一个或多个浏览器窗口,跑的是前端页面,默认不能直接访问 Node 和部分 Electron API。
  • 预加载脚本(Preload):在「主进程」和「渲染进程」之间的桥梁,在页面加载前执行,用来安全地把有限的 API 暴露给页面。

主进程和渲染进程互不越界,彼此也看不到对方的运行环境,所以需要 预加载 + IPC 来安全地打通两边。


二、项目结构与运行步骤

先弄清楚目录里都有什么,再谈实现。加法计算器项目结构如下:

my-electron-app/
├── main.js        # 主进程入口
├── preload.js     # 预加载脚本(桥接主进程与渲染进程)
├── index.html     # 计算器界面
├── renderer.js    # 渲染进程逻辑(纯前端)
└── package.json
  • 运行步骤(仅两步):
    • 在项目根目录执行:npm install
    • 然后执行:npm start(底层就是 electron .

几秒之后,你会看到一个原生窗口,里面就是我们的加法计算器页面。就是👇🏻这样:

image.png


三、让我们开始实战吧:

3.1 主进程 main.js

const { app, BrowserWindow, ipcMain } = require('electron'); // 引入 Electron 主进程 API
const path = require('path'); // 用于拼接预加载脚本路径

let mainWindow; // 保存窗口引用,方便后续操作

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 600,
    height: 300,
    resizable: false,
    webPreferences: {
      nodeIntegration: false, // 关闭渲染进程里的 Node,提升安全性
      contextIsolation: true, // 启用上下文隔离,配合 preload 使用
      preload: path.join(__dirname, 'preload.js'), // 指定预加载脚本
    },
  });

  mainWindow.loadFile('index.html'); // 加载本地页面

  mainWindow.on('closed', () => {
    mainWindow = null;
  });
}

app.whenReady().then(() => {
  ipcMain.handle('ping', () => 'pong'); // 注册一个简单的 IPC 处理函数
  createWindow(); // 应用就绪后创建主窗口

  app.on('activate', () => {
    // macOS 上点击 Dock 图标时,如果没有窗口就重新创建
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  // macOS 下通常会保持应用和菜单栏;其他平台则直接退出
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

快速理解:

  • BrowserWindow 负责创建桌面窗口。
  • webPreferences 里:
    • 关闭 nodeIntegration:渲染进程看不到 Node,默认更安全。
    • 开启 contextIsolation:把页面和 preload 严格隔离。
    • 指定 preload:告诉 Electron 在加载页面前先跑 preload.js
  • ipcMain.handle('ping') 提供了一个叫 "ping" 的 IPC 通道,供 preload 间接调用。

3.2 预加载脚本 preload.js

const { contextBridge, ipcRenderer } = require('electron'); // 只引入预加载需要的模块

// 通过 contextBridge 安全地向渲染进程暴露有限 API,而非直接暴露 ipcRenderer
contextBridge.exposeInMainWorld('electronAPI', {
  versions: {
    node: () => process.versions.node, // 读取当前 Node 版本
    chrome: () => process.versions.chrome, // 读取当前 Chrome 版本
    electron: () => process.versions.electron, // 读取当前 Electron 版本
  },
  ping: () => ipcRenderer.invoke('ping'), // 封装一次 IPC 调用,内部才使用 ipcRenderer
});

快速理解:

  • contextBridge.exposeInMainWorld 会在页面里挂出 window.electronAPI
  • 页面只能看到我们决定暴露出来的几个方法:
    • versions.*():读取当前运行时的 Node / Chrome / Electron 版本。
    • ping():实质是调用 ipcRenderer.invoke('ping'),由主进程来返回 'pong'

3.3 渲染进程页面 index.html

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>加法计算器</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style>
      * {
        box-sizing: border-box;
        font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text',
          system-ui, sans-serif;
      }

      body {
        margin: 0;
        padding: 16px;
        background: #f5f5f7;
      }

      .container {
        max-width: 460px;
        margin: 0 auto;
        background: #ffffff;
        border-radius: 12px;
        padding: 16px 20px 20px;
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
      }

      h1 {
        font-size: 18px;
        margin: 0 0 12px;
        text-align: center;
      }

      .inputs {
        display: flex;
        align-items: center;
        gap: 8px;
        margin-bottom: 12px;
      }

      input[type='number'] {
        flex: 1;
        padding: 8px 10px;
        border-radius: 8px;
        border: 1px solid #d0d3d8;
        outline: none;
        font-size: 14px;
      }

      .inputs span {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        padding: 0 2px;
        font-size: 18px;
      }

      input[type='number']:focus {
        border-color: #007aff;
        box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.15);
      }

      button {
        width: 100%;
        padding: 8px 10px;
        border-radius: 8px;
        border: none;
        background: #007aff;
        color: #ffffff;
        font-size: 15px;
        cursor: pointer;
      }

      button:hover {
        background: #0060d1;
      }

      button:active {
        background: #004ea8;
      }

      .result {
        margin-top: 12px;
        font-size: 15px;
        text-align: center;
      }

      .result-value {
        font-weight: 600;
        color: #007aff;
      }

      .error {
        margin-top: 8px;
        font-size: 13px;
        color: #d0021b;
        text-align: center;
      }

      .env-info {
        margin-top: 12px;
        font-size: 11px;
        color: #86868b;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <h1>简单加法计算器</h1> <!-- 应用标题 -->
      <div class="inputs">
        <input id="a" type="number" placeholder="第一个数" /> <!-- 第一个加数 -->
        <span>+</span> <!-- 加号视觉元素 -->
        <input id="b" type="number" placeholder="第二个数" /> <!-- 第二个加数 -->
      </div>
      <button id="calcBtn">计算</button> <!-- 触发计算按钮 -->
      <div class="result">
        结果:
        <span class="result-value" id="result">--</span> <!-- 显示结果 -->
      </div>
      <div class="error" id="error"></div> <!-- 显示输入错误提示 -->
      <p class="env-info" id="envInfo"></p> <!-- 显示运行环境版本信息 -->
    </div>

    <script src="renderer.js"></script>
  </body>
</html>

3.4 渲染进程脚本 renderer.js

window.addEventListener('DOMContentLoaded', () => {
  // 获取页面上的关键 DOM 元素
  const aInput = document.getElementById('a');
  const bInput = document.getElementById('b');
  const calcBtn = document.getElementById('calcBtn');
  const resultEl = document.getElementById('result');
  const errorEl = document.getElementById('error');

  // 加法计算核心逻辑
  function calculate() {
    errorEl.textContent = '';

    const a = Number(aInput.value);
    const b = Number(bInput.value);

    if (aInput.value === '' || bInput.value === '') {
      errorEl.textContent = '请输入两个数字。';
      resultEl.textContent = '--';
      return;
    }

    const sum = a + b; // 执行加法运算
    resultEl.textContent = sum; // 更新结果显示
  }

  // 点击按钮触发计算
  calcBtn.addEventListener('click', calculate);

  // 在任一输入框按下 Enter 也触发计算
  aInput.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      calculate();
    }
  });

  bInput.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      calculate();
    }
  });

  // 预加载脚本暴露的 API:显示运行环境版本(仅当 preload 成功暴露时)
  const envEl = document.getElementById('envInfo');
  if (typeof window.electronAPI !== 'undefined' && window.electronAPI.versions) {
    envEl.textContent = `Chrome ${window.electronAPI.versions.chrome()} · Node ${window.electronAPI.versions.node()} · Electron ${window.electronAPI.versions.electron()}`;
  }
});

到这里,你已经拥有一整套可以直接跑起来的 Electron 小应用:UI 在渲染进程,权限在主进程,桥梁在 preload


四、进一步理解主进程:窗口与安全开关

回到 main.js,现在只看几个关键配置:

  • nodeIntegration: false
    渲染进程不能直接 require('fs') 等 Node 模块,等于把「危险按钮」先关上。

  • contextIsolation: true
    页面和 preload 不在同一个 JS 环境里运行,恶意脚本更难污染我们在 preload 中暴露的 API。

  • preload: path.join(__dirname, 'preload.js')
    path.join + __dirname,保证不同系统下都能正确找到 preload.js

  • ipcMain.handle('ping', () => 'pong')
    相当于说:「任何走 'ping' 通道来的调用,我都统一回 'pong'」。
    这是我们对 IPC 模式的一次最小化演示。


五、渲染进程:界面与纯前端逻辑

  • index.html:计算器的 UI
  • renderer.js:只做前端逻辑:读输入、校验、做加法、更新 DOM;
    若存在 window.electronAPI.versions,则显示 Chrome/Node/Electron 版本。

渲染进程里不要require('electron')require('fs'),只做两件事:

  • 使用常规的 Web API(DOM、fetch 等)搭界面、写交互。
  • 通过 preload 暴露的 window.electronAPI 向主进程借力。

六、预加载脚本:安全地暴露 API

预加载脚本在「窗口加载网页之前」执行,既能访问到部分 Node/Electron 能力,又与真正的页面隔离。
官方把它类比为 Chrome 扩展的 Content Script
——提前潜伏进去,往页面塞几个「安全的入口」,而不是把钥匙全给它。

本项目中的 preload.js 使用 contextBridge.exposeInMainWorld,只暴露我们需要的接口,而不是把整个 ipcRenderer 或 Node 暴露出去:

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

contextBridge.exposeInMainWorld('electronAPI', {
  versions: {
    node: () => process.versions.node,
    chrome: () => process.versions.chrome,
    electron: () => process.versions.electron,
  },
  ping: () => ipcRenderer.invoke('ping'),
});
  • versions:在 preload 里可以访问 process.versions,通过三个函数暴露给页面,页面即可显示「Chrome / Node / Electron 版本」。
  • ping:封装 ipcRenderer.invoke('ping'),页面调用 window.electronAPI.ping() 会发 IPC 到主进程,主进程返回 'pong'

永远不要ipcRenderer 整个对象暴露给渲染进程,否则页面可以随意向主进程乱发 IPC。
正确做法就是现在这样——一刀一刀地挑选出允许调用的函数,封装好再暴露出去


七、进程间通信(IPC)简述

主进程和渲染进程不能直接访问对方内存或 API,只能通过 IPC 来「递纸条」。在本项目里,纸条的流向是这样的:

  • 主进程ipcMain.handle('ping', () => 'pong') —— 负责收纸条并回信。
  • 预加载ping: () => ipcRenderer.invoke('ping') —— 负责帮页面把纸条塞进 "ping" 这个通道。
  • 渲染进程:执行 const pong = await window.electronAPI.ping();,在控制台会看到 'pong'

以后只要你想在渲染进程里做「需要系统权限的事」(读文件、打开系统对话框等),基本都是这个套路的放大版:
主进程用 ipcMain.handle 注册处理函数,preload 用 ipcRenderer.invoke 封装方法,再用 contextBridge 暴露给页面。详见 Electron 进程间通信


八、小结

角色文件/位置作用
主进程main.js创建窗口、配置 webPreferences、注册 IPC 处理(如 ping
预加载脚本preload.js在页面加载前执行,用 contextBridge 安全暴露 versionsping
渲染进程index.html + renderer.js纯前端 UI 与逻辑,仅通过 window.electronAPI 使用预加载暴露的 API

参考