Electron 入门:Web 应用打包成桌面软件

本文面向:想把 Web 应用打包成桌面软件的前端开发者。 预计阅读时间:12 分钟 最终效果:理解 Electron 的核心概念(主进程、渲染进程、安全模型),掌握 BrowserWindow 配置、服务器嵌入和窗口状态持久化。


Electron 是什么

Electron 把 Chromium(Chrome 的内核)和 Node.js 打包到了一起。你的 Web 前端跑在 Chromium 里,你的后端逻辑跑在 Node.js 里,两者通过 IPC(进程间通信)连接。VS Code、Slack、Discord 都是 Electron 应用。

对 Web 开发者来说,最大的好处是:你已有的 HTML/CSS/JS/React 代码可以直接复用,不需要学 Swift 或 C++。

最小项目结构

一个 Electron 项目至少需要三样东西:

perl 复制代码
my-app/
  package.json          # 入口指向 main.js
  main.js               # 主进程:创建窗口、管理生命周期
  preload.js            # 预加载脚本:安全地暴露 API 给前端
  index.html            # 你的 Web 页面

package.json 里的 "main": "main.js" 告诉 Electron 从哪个文件启动。运行 npx electron . 就能打开一个桌面窗口,里面显示你的 HTML。

主进程 vs 渲染进程

这是 Electron 最核心的概念:

  • 主进程(Main Process) :运行 main.js 的 Node.js 环境。它能访问文件系统、操作系统 API、创建窗口。一个应用只有一个主进程。
  • 渲染进程(Renderer Process) :每个 BrowserWindow 里跑的是一个独立的 Chromium 页面,和你在浏览器里打开网页一样。它不能直接访问 Node.js API。

两个进程各司其职。主进程负责"管家"工作(窗口、托盘、菜单、系统交互),渲染进程负责"展示"工作(UI、用户交互)。它们通过 contextBridge 安全地通信。

BrowserWindow 配置

创建窗口的核心是 new BrowserWindow(options)。ChatCrystal 的配置如下:

typescript 复制代码
const win = new BrowserWindow({
  width: state.width,       // 从保存的状态恢复,或默认 1280
  height: state.height,     // 默认 800
  x: state.x,
  y: state.y,
  minWidth: 900,            // 最小宽度,防止窗口被拖得太小
  minHeight: 600,
  show: false,              // 先不显示,等页面加载完再显示
  title: "ChatCrystal",
  icon: iconPath,
  webPreferences: {
    preload: path.join(__dirname, "preload.js"),
    contextIsolation: true,
    nodeIntegration: false,
    sandbox: true,
  },
});

几个关键点:

  • show: false 配合 ready-to-show 事件使用,避免窗口先闪一下白屏再加载内容。
  • minWidth / minHeight 保证 UI 不会被压到变形。
  • webPreferences 是安全相关的配置,下面专门讲。

安全设置

Electron 的安全模型遵循一个原则:渲染进程不应该有特权。三个配置项实现这一点:

contextIsolation: true:渲染进程的 JavaScript 和 preload 脚本运行在不同的上下文里。即使网页被注入恶意代码,它也无法访问 preload 脚本里的 Node.js 对象。

nodeIntegration: false :渲染进程里不能直接 require('fs') 之类的 Node.js 模块。这是关闭的,因为网页内容可能来自用户输入(比如 AI 对话内容),如果能执行任意 Node.js 代码就是远程代码执行漏洞。

sandbox: true:进一步限制,让渲染进程连 Chromium 的扩展 API 都用不了,只保留最基本的 Web 能力。

那渲染进程怎么和主进程通信?通过 preload 脚本:

typescript 复制代码
// preload.ts
import { contextBridge } from "electron";

contextBridge.exposeInMainWorld("electronAPI", {
  isElectron: true,
  versions: {
    electron: process.versions.electron,
    node: process.versions.node,
    chrome: process.versions.chrome,
  },
});

contextBridge.exposeInMainWorld 是唯一安全的方式,它把指定的对象挂到 window.electronAPI 上。前端代码可以读 window.electronAPI.isElectron 来判断自己是不是跑在 Electron 里,但无法访问任何危险的 Node.js API。

CSP(内容安全策略) 是另一层防护。ChatCrystal 在生产环境设置了严格的 CSP 头:

typescript 复制代码
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
  callback({
    responseHeaders: {
      ...details.responseHeaders,
      "Content-Security-Policy": [
        "default-src 'self';" +
        " script-src 'self';" +
        " style-src 'self' 'unsafe-inline';" +
        " img-src 'self' data: blob:;" +
        " font-src 'self' data:;" +
        " connect-src 'self' http://localhost:* ws://localhost:*;" +
        " object-src 'none';" +
        " base-uri 'self'",
      ],
    },
  });
});

CSP 告诉浏览器:只允许加载同源的脚本,禁止内联脚本(script-src 'self'),禁止插件(object-src 'none')。这对 ChatCrystal 尤其重要,因为它会渲染 AI 对话内容,必须防止 XSS 注入。

注意开发环境跳过了 CSP,因为 Vite 的 HMR(热更新)需要注入内联脚本。

嵌入 Fastify 服务器

很多 Electron 应用只是展示静态页面,但 ChatCrystal 需要一个后端服务器来处理数据库、向量搜索等逻辑。做法是把 Fastify 服务器直接嵌入主进程:

typescript 复制代码
async function startServer(port: number) {
  const serverEntry = pathToFileURL(
    path.join(app.getAppPath(), "server", "dist", "server", "src", "index.js"),
  ).href;

  const serverModule = await Function(
    "specifier",
    "return import(specifier)",
  )(serverEntry);

  return serverModule.createServer({ port, host: "127.0.0.1" });
}

这里有个技巧:Function("specifier", "return import(specifier)") 是一个绕过 Electron 主进程 CJS 限制的 workaround。Electron 的主进程默认是 CommonJS 模块,不能直接用 await import() 加载 ESM 模块。通过 Function 构造器可以绕过这个限制。

启动时先检测端口,如果 3721 被占用就随机分配一个:

typescript 复制代码
function findFreePort(preferred: number): Promise<number> {
  return new Promise((resolve, reject) => {
    const srv = net.createServer();
    srv.listen(preferred, "127.0.0.1", () => {
      srv.close(() => resolve(preferred));
    });
    srv.on("error", () => {
      const srv2 = net.createServer();
      srv2.listen(0, "127.0.0.1", () => {
        const port = (srv2.address() as net.AddressInfo).port;
        srv2.close(() => resolve(port));
      });
    });
  });
}

然后创建窗口,加载服务器的 URL:

typescript 复制代码
mainWindow = createWindow();
const url = devUrl || `http://localhost:${serverPort}`;
await mainWindow.loadURL(url);

开发模式下 devUrl 指向 Vite 开发服务器(http://localhost:13721),生产模式下指向内嵌的 Fastify 服务器。

窗口状态持久化

用户把窗口拖到副屏、调了大小,下次打开应该恢复到原来的位置和尺寸。ChatCrystal 用一个 JSON 文件实现:

typescript 复制代码
function loadWindowState(): WindowState {
  try {
    const data = readFileSync(getWindowStatePath(), "utf-8");
    return JSON.parse(data);
  } catch {
    return { width: 1280, height: 800, isMaximized: false };
  }
}

function saveWindowState(win: BrowserWindow): void {
  const isMaximized = win.isMaximized();
  const bounds = isMaximized
    ? (lastNormalBounds ?? win.getBounds())
    : win.getBounds();
  writeFileSync(getWindowStatePath(), JSON.stringify(state));
}

保存的路径是 app.getPath("userData")/window-state.json,这是 Electron 提供的用户数据目录,跨平台且不会和应用代码混在一起。

还有一个细节:如果用户拔掉了外接显示器,上次保存的窗口位置可能在屏幕外面。所以恢复时要检查:

typescript 复制代码
if (state.x !== undefined && state.y !== undefined) {
  const displays = screen.getAllDisplays();
  const visible = displays.some((d) => {
    const b = d.bounds;
    return (
      state.x! >= b.x - 50 &&
      state.x! < b.x + b.width &&
      state.y! >= b.y - 50 &&
      state.y! < b.y + b.height
    );
  });
  if (!visible) {
    state.x = undefined;  // 重置位置,让系统自动放置
    state.y = undefined;
  }
}

- 50 的容差是为了处理窗口边缘刚好贴着屏幕边界的情况。

单实例锁

桌面应用通常只允许运行一个实例。用户双击图标时,如果已经有实例在跑,应该把已有窗口激活,而不是再开一个。

typescript 复制代码
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
  app.quit();
}

拿到锁的实例继续运行。没拿到锁的直接退出。同时监听 second-instance 事件,当用户再次尝试启动时,把已有窗口显示出来:

typescript 复制代码
app.on("second-instance", () => {
  if (mainWindow) {
    if (mainWindow.isMinimized()) mainWindow.restore();
    mainWindow.show();
    mainWindow.focus();
  }
});

应用生命周期

Electron 的生命周期事件串起了整个应用的运行逻辑。ChatCrystal 的启动流程在 app.whenReady() 里:

typescript 复制代码
app.whenReady().then(async () => {
  // 1. 确定数据目录
  const dataDir = getDataDir();
  mkdirSync(dataDir, { recursive: true });

  // 3. 设置环境变量
  process.env.ELECTRON = "true";
  process.env.DATA_DIR = dataDir;
  if (app.isPackaged) {
    process.env.ELECTRON_PACKAGED = "true";
  }

  // 4. 设置 CSP(生产环境)

  // 5. 检测端口
  serverPort = await findFreePort(3721);

  // 6. 启动 Fastify 服务器(开发模式跳过,服务器单独运行)
  if (!process.env.VITE_DEV_URL) {
    const server = await startServer(serverPort);
    serverShutdown = server.shutdown;
  }

  // 7. 创建窗口、加载页面、创建托盘
  mainWindow = createWindow();
  await mainWindow.loadURL(url);
  createTray(mainWindow, serverPort);
});

退出时,before-quit 事件触发优雅关闭:

typescript 复制代码
app.on("before-quit", (e) => {
  if (!isQuitting) {
    e.preventDefault();
    isQuitting = true;
    const timeout = setTimeout(() => {
      app.exit(1);  // 10 秒超时强制退出
    }, 10000);
    gracefulShutdown()
      .finally(() => {
        clearTimeout(timeout);
        app.quit();
      });
  }
});

gracefulShutdown 依次关闭 Fastify 服务器和系统托盘。10 秒超时是为了防止关闭流程卡死------如果数据库保存之类的事情出了问题,应用不会永远挂在那里。

窗口关闭时不是真正退出,而是隐藏到托盘:

typescript 复制代码
win.on("close", (e) => {
  saveWindowState(win);
  if (!isQuitting) {
    e.preventDefault();
    win.hide();
  }
});

用户通过托盘菜单的"Quit"才真正退出,这时 isQuitting 已经被设为 trueclose 事件不会被拦截。

下一步

你现在了解了 Electron 的核心概念:主进程与渲染进程的分工、BrowserWindow 配置、安全模型、服务器嵌入、窗口状态持久化、单实例锁和生命周期管理。

如果你想深入:

  • IPC 通信ipcMain.handle / ipcRenderer.invoke 用于渲染进程调用主进程的功能(比如打开文件对话框)
  • 自动更新electron-updater 可以实现应用内更新
  • 打包发布electron-builder 可以打包成 Windows 安装包(NSIS)、macOS DMG、Linux AppImage
  • 性能优化:懒加载窗口、减少主进程阻塞操作

ChatCrystal 的完整 Electron 代码在 electron/ 目录下,可以直接作为参考项目。从一个能跑的最小结构开始,逐步加上你需要的功能,这是最快的学习路径。


项目地址:github.com/ZengLiangYi...

相关推荐
lqj_本人4 小时前
鸿蒙electron跨端框架PC想法卡片实战:把零散灵感做成能继续展开的卡片流
华为·electron·harmonyos
前端环境观察室4 小时前
别再靠人工记浏览器环境了:用 TypeScript 设计一套可审计模型
前端
鱼樱前端4 小时前
我做了一个不止有基础组件的 Vue 3 UI 库,还把 AI 组件也做进去了
前端·vue.js·ai编程
泥秋哥4 小时前
微前端-Module Federation运行时工具
前端·架构
小黑蛋9125 小时前
Nacos 集群部署方案
前端
PILIPALAPENG5 小时前
第4周 Day 4:Agent 工作流模式——编排复杂流程
前端·人工智能·python
KaMeidebaby5 小时前
卡梅德生物技术快报|蛋白的过表达质粒构建与生信分析实验全流程复盘
前端·数据库·其他·百度·新浪微博
ricardo19735 小时前
代码分割 + 路由懒加载 + 字体子集化:前端瘦身三板斧
前端·面试
dsyyyyy11015 小时前
CSS 2D 效果、3D 效果 与 Animation 总结
前端·css·3d