把 Electron 当本地服务器用:FeedMind 桌面端的工程实践
把 Electron 当本地服务器用:FeedMind 桌面端的工程实践
🖥️ Zero-IPC 架构
🕸️ CDP 浏览器沙箱
📦 单文件主进程 Bundle
🧠 内存治理三件套
FeedMind 是一个知识管理 + AI 对话 + 内容爬取平台,其桌面端把 Electron 从「壳」重新定义为一台自带 Web 服务器的本地主机。本文汇总了它的全部工程实践:
- 零 IPC 架构 — 主进程内嵌 Hono HTTP API + 同源托管 SPA,渲染层与主进程只走 localhost,没有 preload、没有 contextBridge
- CDP 浏览器沙箱 — 爬虫与 AI Agent 不再下载第二个 Chromium,Playwright 直连 Electron 内置内核,靠 UA 校验拦截误连用户浏览器
- 环境自举模式 — 所有「打包前后差异」收口到主进程的第一行
import,一个文件解决 .env、数据目录、日志落盘、Chromium 开关 - 单文件主进程 — esbuild bundle + external 白名单,与 electron-builder 三张清单严格对齐的体积治理
- 内存治理三件套 — 监控熔断、空闲窗口回收、渲染进程显式 GC,长跑型桌面应用不内存泄漏
- 踩坑实录 — ESM 垫片、asar 原生模块、GPU 加速取舍、
import.meta.dirname失效……每一条都有真实代码佐证
🎯 引言:跳出「壳 + IPC」的思维定式
几乎所有 Electron 教程的第一课都是同一套叙事:主进程开一个 BrowserWindow,preload 里用 contextBridge 暴露几个 API,渲染层通过 ipcRenderer invoke 主进程。于是 Electron 项目很快长出这样的形状——renderer 一堆 ipcMain.handle 通道名常量、preload 一层手写的类型桥、类型定义在三个进程间来回复制。
FeedMind 桌面端(apps/desktop)的源代码只有两个文件:
apps/desktop/src/├── env-bootstrap.ts # 139 行:环境自举(副作用模块)└── main.ts # 315 行:窗口 / 生命周期 / 内存治理没有 preload、没有 renderer 目录、没有 electron-vite 之类的编排插件。UI 直接复用 apps/web(React 19 + Vite),API 直接复用 apps/api(Hono + Mastra Agent)。desktop 包本身只是一个进程宿主——它把 Web 应用和 HTTP 服务装进同一个原生窗口里,然后用 9333 端口的 CDP 协议把内置浏览器变成爬虫和 Agent 的执行沙箱。
「Electron as a local server」不是新想法(VS Code、Obsidian 的内核都是这个思路的变体),但把它完整落地并做好每个工程细节的文章很少。本文不是教程,而是一份从生产代码里提炼的实践清单:每个决策都标注了它解决的真实问题,每个坑都来自真实提交历史。
🏗️ 总体架构:Electron as a Local Server
先看全景图:
┌────────────────────────────────────────────────────────────┐│ FeedMind Desktop App ││ ││ UI Main Window Main Process (main.js) ││ ┌──────────────────┐ HTTP ┌───────────────────────┐ ││ │ React 19 SPA │◄───────►│ Hono API :18790 │ ││ │ sandbox:true │localhost│ SQLite (WAL) │ ││ │ zero IPC │ │ Mastra AI Agent │ ││ └──────────────────┘ └──────────┬────────────┘ ││ │ CDP :9333 ││ Hidden Windows │ ││ ┌──────────────────┐ ▼ ││ │ feedmind-crawler │◄──── Playwright connectOverCDP ││ │ feedmind-agent │ (marker-based page selection) ││ └──────────────────┘ │└────────────────────────────────────────────────────────────┘三类窗口,各司其职:
| 窗口 | 可见性 | 职责 | 关键配置 |
|---|---|---|---|
| UI 主窗口 | 可见 | 加载 React SPA | sandbox: true、伪装 Chrome UA |
标记窗口 feedmind-crawler | 隐藏 | 爬虫执行的页面沙箱 | 内存 partition、不节流 |
标记窗口 feedmind-agent | 隐藏 | AI Agent 浏览器工具 | 内存 partition、空闲 2 分钟回收 |
这个决策的收益远比表面看起来大:
- UI 零改造复用 Web 端——同一个 React 应用既能跑在浏览器里,也能跑在 Electron 里,渲染层完全不知道自己在桌面环境
- Vite HMR 原生可用——开发时主窗口加载 Vite dev server,改 UI 热更新,不感知 Electron 的存在
- API 可以独立于 GUI 运行——同一个
startApi()也能从 CLI 启动,桌面端只是「宿主」之一 - 调试体验——接口直接
curl http://127.0.0.1:18790就能测,不需要开 Electron - 省掉一整层复杂度——preload 桥、IPC 通道类型、channel 常量全部不存在
代价是 API 崩溃会连带 GUI 一起挂——这个风险用全局异常落盘 + 弹窗兜底(见后文日志一节)。
🚀 环境自举:把「打包前后差异」收口到第一行 import
桌面应用最烦人的问题之一是:dev 和打包后的运行环境差异。数据目录在哪、日志写到哪、.env 从哪读、CDP 端口怎么配——这些逻辑如果散落在各处,每次打包都会踩一次。
FeedMind 的解法是一个副作用自举模块,作为主进程的第一条 import:
// main.ts —— 第一行就是它import "./env-bootstrap.js";ESM 按声明顺序求值,这保证了后续任何模块在读取 process.env 时,环境已经就绪。env-bootstrap.ts 集中处理了所有「打包前后差异」:
// env-bootstrap.ts(节选)app.setName("FeedMind"); // 规范 userData 路径为 %APPDATA%/FeedMind
// .env:打包后读 resources/.env,dev 读仓库根目录 —— 用 Node 原生 loadEnvFile,不依赖 dotenvconst envPath = app.isPackaged ? path.join(process.resourcesPath, ".env") : path.resolve(app.getAppPath(), "../../.env");try { process.loadEnvFile(envPath);} catch { /* .env 可选,静默跳过 */}
// 数据目录彻底解耦只读 asar:可写文件绝不进安装目录const dataDir = process.env["DATA_DIR"] ?? (app.isPackaged ? path.join(app.getPath("userData"), "data") : path.resolve(app.getAppPath(), "../../data"));process.env["DATA_DIR"] = dataDir;process.env["DATABASE_PATH"] = process.env["DATABASE_PATH"] ?? path.join(dataDir, "feedmind.db");
// 打包后 stdout 无人消费(GUI 没有控制台),日志直接落盘if (app.isPackaged && !process.env["LOG_FILE"]) { process.env["LOG_FILE"] = path.join(app.getPath("userData"), "logs", "app.log");}
// 零配置加密密钥:未配置时自动生成并持久化,桌面端开箱即用if (!process.env["ENCRYPTION_KEY"]) { const keyFile = path.join(dataDir, ".secret_key"); // 读 → 读不到则生成 → 写 → 用}这个模式可以泛化成一条规则——凡是依赖运行环境(打包态/dev 态/系统路径)的初始化逻辑,收口到一个 import 即生效的副作用模块,并让它成为主进程的第一条语句。它的好处是:
- 主流程代码永远面对「已就绪」的环境,不需要到处判空
- 环境差异只在一个文件里排查,review 时一眼看完
- 新增环境变量时有唯一的注册点
Node 22+ 还可以顺手在里面调 enableCompileCache() 开启原生字节码缓存,降低冷启动堆内存——一行代码的免费优化。
Chromium 命令行开关:在同一个文件里调优内核
env-bootstrap.ts 还承担了 Chromium 启动参数的统一调优,这些开关背后全是实测数据:
| 开关 | 目的 |
|---|---|
remote-debugging-port + remote-debugging-address=127.0.0.1 | 开放 CDP,但只绑回环地址 |
v8-cache-options=code | V8 代码缓存,加速脚本启动 |
js-flags=--expose-gc | 给渲染进程暴露 globalThis.gc()(见内存治理一节) |
disable-accelerated-2d-canvas | 2D canvas 光栅化走 CPU,省 GPU 进程纹理 |
disable-renderer-backgrounding | 隐藏窗口不被降速——爬虫窗口的保命符 |
disable-breakpad / disable-component-update 等 | 关闭崩溃上报、组件更新等桌面端无意义的服务 |
renderer-process-limit=2 | 限制渲染子进程并发,防内存激增 |
关于 GPU 加速,这里有一个反直觉的决策:FeedMind 默认保留硬件加速,只提供一个 FEEDMIND_DISABLE_GPU=1 的逃生门:
// 默认启用 GPU 合成:禁用硬件加速后 backdrop-filter(弹窗遮罩 blur)与 WebGL 图// 全部走 SwiftShader 软件光栅化,弹窗动画掉到 15-20 FPS(electron#29420 实证),// 与内存收益不成比例(业界 VS Code/Slack 均保留硬件合成)。if (process.env["FEEDMIND_DISABLE_GPU"] === "1") { app.disableHardwareAcceleration();}appendSwitch("js-flags", "--expose-gc") 只影响子进程。主进程的 V8 在 main.js 执行之前就已初始化,所以主进程里永远不会有 global.gc——这是 Electron 的硬限制,不是 bug。想显式 GC 只能在渲染进程做(后文有完整方案)。
🪟 窗口管理:三类窗口,各司其职
主窗口:安全默认值,一行 preload 都不写
function createMainWindow(): BrowserWindow { const win = new BrowserWindow({ width: 1440, height: 900, show: false, // 先隐藏,ready-to-show 再显示,避免白屏 backgroundColor: "#0f172a", // 预置背景色,配合 show:false 双保险 webPreferences: { sandbox: true, spellcheck: false }, }); win.removeMenu(); win.webContents.setUserAgent(CHROME_UA); // 伪装标准 Chrome,抹掉 Electron 指纹 win.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith("http://") || url.startsWith("https://")) { void shell.openExternal(url); // 一切 window.open 转交系统浏览器 } return { action: "deny" }; }); showOnReady(win); return win;}注意 webPreferences 只显式写了 sandbox: true——nodeIntegration: false、contextIsolation: true 都是 Electron 的现代默认值,没必要重复声明,重复声明反而会掩盖未来默认值变化的影响。
showOnReady 里藏着一个容易被忽略的坑:
function showOnReady(win: BrowserWindow): void { let shown = false; const doShow = () => { if (!shown && !win.isDestroyed()) { shown = true; win.show(); } }; win.once("ready-to-show", doShow); // 5 秒超时兜底:防止因渲染/GPU 卡顿错过 ready-to-show 事件导致窗口永久隐藏 setTimeout(doShow, 5000);}ready-to-show 不是绝对可靠的——GPU 驱动异常等场景下它可能永远不触发,窗口就永久黑屏。给所有「等事件」的逻辑加超时兜底,是桌面端开发的通用法则。
标记窗口:给 CDP 一个「信标」
爬虫和 Agent 需要真实浏览器环境,但绝不能污染 UI 会话,也不能互相踩踏。FeedMind 的方案是每类任务一个隐藏标记窗口:
async function createMarkedWindow(marker: string): Promise<BrowserWindow> { const win = new BrowserWindow({ show: false, webPreferences: { partition: `memory:${marker}`, // 纯内存 session:cookie/存储与 UI 完全隔离 backgroundThrottling: false, // 后台任务不被 Chromium 节流 sandbox: true, autoplayPolicy: "user-gesture-required", }, }); await win.loadURL(`data:text/html,<title>${marker}</title>`); return win;}这个窗口最妙的设计是最后一句:加载的 URL 本身就是标记。data:text/html,<title>feedmind-crawler</title> 这个标题随后会成为 CDP 连接后按 URL 选页的「信标」——不需要维护窗口 ID 映射表,标记即寻址。
窗口生命周期同样有讲究:
- 幂等复用:已存在的标记窗口直接返回并刷新空闲定时器,绝不因已导航到目标 URL 而强制重载空白页——这条注释背后是一个真实 bug:曾因误重置窗口 URL 导致多步交互死循环
- 空闲回收:Agent 窗口空闲 2 分钟自动销毁,释放 Chromium 渲染子进程;crawler 窗口由任务生命周期显式销毁
- 销毁即清理:窗口关闭后对
memory:partition 执行clearStorageData(serviceworkers / cachestorage / indexdb / localstorage / cookies)+clearCache(),防止跨任务状态泄漏
生命周期:把「退出」做对
// 单例锁:防止多开导致 API 端口 (18790) / CDP 端口 (9333) 与 SQLite 冲突const gotTheLock = app.requestSingleInstanceLock();if (!gotTheLock) { app.quit();} else { app.on("second-instance", () => { // 第二次启动时把已有窗口拉到前台 if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); // ...}单实例锁在这里不只是「防止用户多开」的产品需求,更是架构正确性的保证——三个固定端口和 SQLite 文件都隐含了「单进程」假设。
退出时的处理更值得一学。GUI 应用退出前必须考虑「后台任务写了一半怎么办」:
// 退出前等待 AI Memory 后台观察/反射周期写完数据库,防止后台写被进程退出截断let memoryFlushed = false;app.on("before-quit", (event) => { if (memoryFlushed) return; event.preventDefault(); void waitForMemorySettled() .catch(() => {}) .finally(() => { memoryFlushed = true; app.quit(); });});event.preventDefault() 拦截退出 → 等待落盘(带超时兜底)→ 置位标志 → 重入 app.quit()。这是GUI 应用退出前 flush 后台写的标准范式,任何有后台写任务的 Electron 应用(自动保存、同步、数据库 WAL)都值得抄。
🌐 内嵌 API:把 Hono 装进主进程
主进程内嵌 API 不是简单 app.listen,FeedMind 的 startApi() 做了一整套生产级处理:
async function bootstrap(): Promise<void> { Menu.setApplicationMenu(null);
// 拒授媒体权限,防常驻媒体进程 const MEDIA_PERMISSIONS = new Set(["media", "mediaKeySystem", "geolocation", "notifications"]); session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => { callback(!MEDIA_PERMISSIONS.has(permission)); });
// 计算 SPA 静态目录:打包后是 resources/web-dist,dev 是仓库 apps/web/dist const webDist = process.env["VITE_DEV_SERVER_URL"] ? undefined : app.isPackaged ? path.join(process.resourcesPath, "web-dist") : path.resolve(app.getAppPath(), "../web/dist");
await startApi(webDist ? { webDist } : {}); // 内嵌 API,同源托管 SPA // ...创建主窗口,带重试加载}几个关键设计:
同源 SPA 托管。生产模式下 API 进程同时是静态资源服务器:非 /api 的 GET 请求走手写的 tryServeStatic(自带 MIME 表、拒绝 .. 路径穿越、畸形编码容错),其余回退到 index.html。UI 加载 http://127.0.0.1:18790,与 API 同源——没有 CORS、没有端口漂移。
端口跟随。UI URL 直接从 API_PORT 环境变量推导,改 API 端口 UI 自动跟随:
function getUiUrl(): string { const devUrl = process.env["VITE_DEV_SERVER_URL"]; // dev 优先走 Vite if (devUrl) return devUrl; const port = process.env["API_PORT"] ? Number.parseInt(process.env["API_PORT"], 10) : 18790; return `http://127.0.0.1:${port}`;}加载重试。内嵌 API 是异步启动的,主窗口加载时 API 可能还没就绪,一个朴素的竞态问题:
async function loadWithRetry(win: BrowserWindow, url: string, maxRetries = 15, intervalMs = 500) { for (let i = 0; i < maxRetries; i++) { try { await win.loadURL(url); return; } catch (err) { if (i === maxRetries - 1) throw err; await new Promise((resolve) => setTimeout(resolve, intervalMs)); } }}15 次 × 500ms 的重试窗口,覆盖了 API 冷启动(数据库初始化、Agent 装配)的耗时。「进程内服务 + 窗口加载」永远存在启动竞态,要么先 await 服务就绪,要么带重试地加载。
后台任务让路首屏。Ingest Worker、调度器等后台任务用 setImmediate 推迟到 HTTP server 就绪之后启动,UI 首屏不被拖累。
🕸️ CDP 浏览器沙箱:复用内置 Chromium
这是整个架构里最有意思的部分。
爬虫和 AI Agent 都需要一个真实浏览器来执行操作。传统做法是 Playwright/Puppeteer 自己 launch()——意味着应用要额外下载一个 ~150MB 的 Chromium,内存里同时跑两套内核。FeedMind 的选择:直接复用 Electron 自带的 Chromium,通过 CDP(Chrome DevTools Protocol)把隐藏标记窗口暴露成 Playwright 可驱动的页面。
连接之前:先验证你连的是谁
CDP 端口一旦开放(--remote-debugging-port=9333),本机任何进程都能连。如果不做校验,一个写错的端口号就可能把 Playwright 连到用户日常使用的 Chrome 上——爬虫会在用户的浏览器里导航页面、注入 Cookie,这是灾难级事故。FeedMind 的防御是「先连后验,验不过立刻断」:
async function connectElectron(): Promise<Browser> { const browser = await chromium.connectOverCDP(CDP_ENDPOINT, { timeout: 30_000, noDefaults: true, isLocal: true, }); try { const cdp = await browser.newBrowserCDPSession(); const { userAgent } = (await cdp.send("Browser.getVersion")) as { userAgent: string }; await cdp.detach().catch(() => {}); if (!userAgent.includes("Electron")) { throw new Error( `CDP 端点不是 FeedMind Electron(实际 UA: ${userAgent}),已拒绝连接以隔离用户主机浏览器`, ); } } catch (err) { await browser.close().catch(() => {}); // 验不过,立刻断开 throw err; } return browser;}配合自举模块里的 remote-debugging-address=127.0.0.1(只绑回环),构成了完整的隔离边界:物理上只监听本机,逻辑上校验 UA 必须含 Electron。
按信标选页:两轮重试协议
连上 CDP 后如何找到「该操作哪个页面」?答案就是前文的标记信标:
for (let attempt = 0; attempt < 2; attempt++) { const browser = await connectElectron(); browser.on("disconnected", resetConnection); // 断连自愈
const pages = browser.contexts()[0]?.pages() ?? []; const selected = pages.find((p) => p.url().includes(CRAWLER_MARKER)); if (selected) { selected.on("close", () => { if (_page === selected) resetConnection(); }); return selected; }
// 第一轮没找到:断开连接 → 请求主进程补建标记窗口 → 等 300ms 再试一轮 await browser.close().catch(() => {}); if (attempt === 0) { await ensureMarkedWindow(CRAWLER_MARKER); await new Promise((r) => setTimeout(r, 300)); }}两轮协议覆盖了「窗口已被销毁」的场景:第一轮找不到信标 → 通过工厂回调让主进程补建窗口 → 第二轮必然能选到。
回调注入:纯 Node 包与 Electron 主进程的解耦
值得单独说的是这里的架构巧思。crawler-core 是一个纯 Node 包(API 的 CLI 模式也要用它),但它需要「创建 Electron 窗口」这个能力。如果直接 import electron,这个包就被 Electron 绑死了。
FeedMind 的解法是能力注入:
// crawler-core 侧:只定义接口,不依赖 electronexport type MarkedWindowFactory = (marker: string) => Promise<void>;let _markedWindowFactory: MarkedWindowFactory | null = null;export function setMarkedWindowFactory(factory: MarkedWindowFactory): void { _markedWindowFactory = factory;}
// Electron 主进程启动时注入实现setMarkedWindowFactory(ensureMarkedWindow);setMarkedWindowDestroyer(destroyMarkedWindow);同一个包在两种宿主下行为正确:桌面模式下有工厂回调,能自动补建窗口;API 独立进程模式下回调为空,找不到页面就报「请确认桌面应用已启动」。依赖倒置不用接口和类,两个函数指针就够了——这是 Ponytail 原则(最懒但能用)的典型体现。
沙箱内的其他细节
// 任务级串行互斥:promise-chain 锁,多爬取任务排队共享同一页面export async function createBrowser(): Promise<Page> { const prev = _lockTail; const { promise, resolve: release } = Promise.withResolvers<void>(); _lockTail = promise; await prev; // ...}
// 爬取提速:拦截并 abort 图片/媒体/字体/样式表await page.route("**/*", (route) => { const type = route.request().resourceType(); if (["image", "media", "font", "stylesheet"].includes(type)) { void route.abort(); } else { void route.continue(); }});Cookie 注入也有讲究:站点登录态(B 站 SESSDATA、微信读书 wr_skey 等)先检查目标域已有的 Cookie,避免用旧值覆盖刚保活刷新的新值。
🔐 安全实践核对表
把上面分散在各节的安全措施汇总成一张核对表:
| # | 实践 | 为什么 |
|---|---|---|
| 1 | 渲染层零 Node:无 preload / contextBridge / ipcRenderer | 攻击面直接归零,不需要「安全地用 IPC」 |
| 2 | 所有窗口 sandbox: true | 渲染进程无 Node 环境可逃逸 |
| 3 | API 只监听 127.0.0.1 | 内嵌服务不暴露到局域网 |
| 4 | CDP 绑定回环 + UA 必须含 Electron | 防止驱动用户本机浏览器 |
| 5 | setWindowOpenHandler 全拒 + shell.openExternal | 新窗口不受控,外链交给系统浏览器 |
| 6 | 权限处理器拒授 media / geolocation / notifications | 防常驻媒体进程与权限滥用 |
| 7 | 标记窗口用 memory: partition + 销毁即清理 | 会话隔离,防跨任务状态泄漏 |
| 8 | 静态服务防 .. 路径穿越 | 手写服务器最容易忽略的洞 |
| 9 | 日志 pino redact 自动脱敏 apiKey / cookie / authorization | 落盘日志也是攻击面 |
| 10 | 敏感数据 AES 加密落盘,密钥自动生成持久化 | 密钥不进代码库、不进环境变量 |
没有做完美主义的地方也值得记录:渲染层 HTML 没有配 CSP meta(渲染层无 Node + 仅 localhost 通信,风险可控但非最优);未配置自动更新与签名校验。安全是威胁模型驱动的工程决策,不是清单打钩——知道自己在防什么、放掉了什么,比全勾更重要。
📦 打包与分发:单文件 Bundle 的白名单艺术
主进程构建:tsc + esbuild 的组合拳
// build.mjs —— 主进程构建脚本// 1. 先用 tsc 生成 TypeScript 声明与类型检查execSync("tsc -b", { cwd: desktopDir, stdio: "inherit" });
// 2. 用 esbuild 把主进程 + 整个 API 服务 bundle 成单文件await build({ entryPoints: [resolve(desktopDir, "src/main.ts")], outfile: resolve(desktopDir, "dist/main.js"), bundle: true, platform: "node", target: "node22", format: "esm", sourcemap: true, external: [ "electron", "@libsql/*", "better-sqlite3", "playwright-core", "@remotion/*", // ...原生模块与带动态 require 的重型库 ], banner: { // ESM 模式下注入 require/__filename/__dirname 垫片,保障 CJS 依赖正常加载 js: `import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url); /* ... */`, }, minify: true,});两处关键:
ESM 垫片。主进程声明为 ESM("type": "module"),但 bundle 里的外部依赖(原生模块)是通过 require() 加载的 CJS。esbuild 不会自动补这个洞,需要用 banner 注入 createRequire / __filename / __dirname 垫片——「ESM Electron 主进程 + esbuild bundle」几乎必踩这个坑,现成解法就是这三行。
external 白名单的判断标准:原生模块(.node 二进制)和带动态 require / worker 的重型库不打包,其余全部 bundle。bundle 后整个 API 服务(含 Mastra、AI SDK、drizzle 等几十个包)收敛成单个 main.js,这是后面体积治理的基础。
三张清单必须严格对齐
「bundle + external」模式有一个隐藏约束:三张清单必须一致,否则打包产物要么缺文件要么多文件:
┌─────────────────────────────┐│ esbuild external 白名单 │ ← 哪些依赖不进 main.js├─────────────────────────────┤│ desktop dependencies │ ← external 的依赖必须真实存在于 node_modules├─────────────────────────────┤│ electron-builder files │ ← 哪些 node_modules 收进安装包└─────────────────────────────┘三组排除各有含义:external 的依赖必须收进 files(bundle 里是裸 require,运行时要能找到);bundle 过的纯 JS 依赖必须排除(已经在 main.js 里了,再收一遍是纯浪费);平台变体按目标平台裁剪(NSIS 只出 Windows 包,macOS 的原生二进制全是死重)。
afterPack 钩子:最后 50MB 的裁剪
electron-builder 的 afterPack 钩子是体积治理的最后机会:
// after-pack.mjs —— 打包后裁剪 Chromium 冗余文件export default async function afterPack(context) { const { appOutDir } = context;
// 1. 剔除大型无用文档(19.4MB) fs.unlinkSync(path.join(appOutDir, "LICENSES.chromium.html"));
// 2. 裁剪语言包:50+ 个 .pak 只留中英文(省 ~30MB) const kept = new Set(["zh-CN.pak", "en-US.pak", "en-GB.pak"]); for (const item of fs.readdirSync(path.join(appOutDir, "locales"))) { if (!kept.has(item)) fs.unlinkSync(path.join(appOutDir, "locales", item)); }
// 短暂让出 IO 循环,确保 Windows 文件系统句柄稳定释放 await new Promise((resolve) => setTimeout(resolve, 300));}安装包体积从「默认配置」到「裁剪完成」能差出近百 MB。这 300ms 的 setTimeout 是 Windows 特有的温柔:文件句柄释放有延迟,钩子里删文件太快会失败。
其他分发细节
- NSIS 配置:
oneClick: false+allowToChangeInstallationDirectory: true(尊重用户选择安装路径的习惯),differentialPackage: false关闭差分更新包(没有自动更新就用不上,白占体积) - 国内镜像:
.npmrc的electron_mirror+electron_builder_binaries_mirror,builder 配置的electronDownload.mirror,外加 pnpmoverrides锁定@electron/get版本——Electron 下载是 CI 最慢的一步,镜像配置能救命 - 源码归档:
desktop:dist脚本在产出安装包后用git archive把当前 HEAD 导出为源码 zip——发布二进制的同时归档可复现源码,成本一行命令
🧠 内存治理三件套
长跑型桌面应用(开机自启、常驻后台、周期任务)的内存治理和 Web 应用完全不同——用户不会「刷新页面」,内存只会单调增长,直到系统开始换页。FeedMind 的方案是三件套:
第一件:监控 + 熔断
function startMemoryMonitor(): void { let lastHeap = process.memoryUsage().heapUsed; let risingStreak = 0; setInterval(() => { const { rss, heapUsed } = process.memoryUsage(); risingStreak = heapUsed > lastHeap ? risingStreak + 1 : 0; lastHeap = heapUsed; // 每 5 分钟采样:连续 3 次上涨升级为 warn console.log(JSON.stringify({ event: "memory-sample", rssMB: ..., risingStreak })); void checkMarkedWindowMemory(); }, MEMORY_SAMPLE_MS);}
async function checkMarkedWindowMemory(): Promise<void> { // 用 getAppMetrics 按 PID 定位 Agent 窗口的渲染进程,工作集超 1.5GB 即熔断销毁重建 const metrics = app.getAppMetrics(); const pid = entry.win.webContents.getOSProcessId(); const proc = metrics.find((m) => m.pid === pid); if (proc && proc.memory.workingSetSize > MARKED_WINDOW_MEMORY_LIMIT_MB * 1024 * 1024) { await destroyMarkedWindow(AGENT_MARKER); // 销毁窗口 = 回收整个渲染子进程 }}思路是把窗口当缓存条目管理:内存超标就销毁重建,反正标记窗口的会话是纯内存 partition,销毁重建的成本远低于让系统被拖垮。app.getAppMetrics() 按 PID 找到对应渲染进程是 Electron 独有的能力——主进程的 process.memoryUsage() 看不到渲染进程。
第二件:空闲回收
Agent 窗口空闲 2 分钟自动销毁(渲染子进程直接消失);爬虫窗口由任务生命周期显式销毁。配合销毁时的 clearStorageData,状态不泄漏。
第三件:渲染进程显式 GC
这是最精妙的一件。主进程拿不到 global.gc(前文说过是硬限制),但渲染进程可以通过 Chromium 开关拿到:
--js-flags=--expose-gc → 渲染进程获得 globalThis.gc然后 Web 端配合(纯浏览器环境运行时零侵入):
// apps/web/src/main.tsx —— 页面隐藏 30 秒后主动回收渲染堆document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { setTimeout(() => globalThis.gc?.(), 30_000); // 无 gc 时 ?. 静默跳过 }});用户切走窗口的瞬间,正是回收的最佳时机——不影响任何前台交互。
内存不是「防泄漏」问题,而是「治理」问题。长跑应用不可能零泄漏(Chromium 自身都有),正确的姿势是:监控发现趋势 → 超标就熔断重建 → 空闲时主动回收。接受不完美,用架构兜底。
🪵 日志与崩溃:桌面端没有 stdout
GUI 应用打包后没有控制台——所有 console.log 都石沉大海。这是桌面端和 Web 服务端日志策略的根本差异。
FeedMind 的三层方案:
自举层:打包态自动设置 LOG_FILE=%APPDATA%/FeedMind/logs/app.log,日志库据此路由——文件写原生 JSON 行,落盘失败降级 stdout。
日志库层:API 用 pino 结构化日志 + 自动脱敏。一个打包特有的坑:
// pino-pretty 必须用进程内 stream,而非官方推荐的 worker transport:// worker 的 thread-stream 依赖动态 require,在 esbuild bundle 的打包环境里直接炸崩溃层:全局异常落盘 + 弹窗,绝不静默退出:
process.on("uncaughtException", (error) => { // 追加写入 userData/logs/crash.log writeFileSync(path.join(logDir, "crash.log"), `[${new Date().toISOString()}] ...`, { flag: "a", }); // 弹窗告知用户,而不是无声消失 dialog.showErrorBox("FeedMind 运行时异常", message);});主进程内嵌了 API 服务,意味着 API 崩溃 = 整个应用崩溃。这种架构下启动失败的可见性是生命线:bootstrap() 抛错时 dialog.showErrorBox + 退出,让用户和开发者都能看到失败原因,而不是双击图标后什么都没发生。
🧨 踩坑实录
最后汇总几个真实踩过的坑,每个都有「现象 → 根因 → 解法」:
1. ESM 主进程加载 CJS 依赖报 require is not defined
esbuild bundle 成 ESM 后,外部依赖仍走 require() 加载,但 ESM 作用域里没有 require。解法:banner 注入 createRequire 垫片(前文已详述)。
2. 打包后原生模块崩溃:.node cannot be loaded from asar
Electron 的 asar 归档是虚拟文件系统,原生 .node 二进制和原生 exe 无法从其中加载。解法:asarUnpack 白名单把原生依赖解包到磁盘。
3. bundle 后 import.meta.dirname 指向错误位置
源码里用 resolve(import.meta.dirname, "../../remotion") 定位运行时需要的资源目录,bundle 后 import.meta.dirname 变成了 bundle 文件所在目录,相对路径全部失效。解法:这类路径要基于 app.getAppPath() / process.resourcesPath 这类 Electron 提供的运行时锚点解析,或者干脆用 extraResources 打到固定位置。bundle 是有代价的:一切「以源码目录结构为前提」的路径逻辑都会被破坏。
4. 关掉 GPU 加速后弹窗动画卡成幻灯片
为了省内存 disableHardwareAcceleration(),结果 backdrop-filter 遮罩模糊全部走 SwiftShader 软件光栅化,动画掉到 15-20 FPS(electron#29420 有实证)。解法:保留硬件合成,用更精细的开关(disable-accelerated-2d-canvas 等)收敛 GPU 进程内存。一刀切的优化常常得不偿失。
5. 标记窗口被误重载导致多步交互死循环
幂等复用窗口时顺手 loadURL 重置到了空白页,导致依赖窗口状态的多步操作每次都从头开始。解法:已存在的活跃窗口直接复用,绝不重载。
6. pino-pretty 的 worker transport 打包后崩溃
官方推荐的 worker transport 依赖 thread-stream 的动态 require,bundle 后路径解析失败。解法:打包环境改用进程内 stream。
7. Windows 终端中文乱码
desktop:dev 脚本第一句是 chcp 65001 >nul——Windows 控制台默认 GBK 编码,切 UTF-8 才能正常显示中文日志。
✅ 结语:一张可复用的核对清单
把 FeedMind 的实践浓缩成一张可以直接用的清单:
📋 点开查看完整核对清单(11 项)
架构层
- 明确 Electron 的角色:壳 + IPC,还是本地服务器宿主?两者都成立,但要选边站
- 渲染层尽量零 Node:能走 HTTP 就走 HTTP,preload/contextBridge 按需才引入
- 所有「打包前后差异」收口到一个自举模块,作为主进程第一行 import
- 单实例锁保证固定端口与本地存储的进程唯一性
- GUI 退出前 flush 后台写:
before-quit+preventDefault+ 超时兜底
窗口与安全层
- 全窗口
sandbox: true,window.open全拒 + 外链转系统浏览器 - 需要 CDP 时:只绑回环 + UA 校验 + 信标选页 + 内存 partition 隔离
- 后台窗口
backgroundThrottling: false,否则隐藏任务会被 Chromium 降速
构建与运行层
- bundle 主进程时:external 原生依赖,三张清单(external / dependencies / files)严格对齐
- afterPack 钩子裁剪 LICENSES 与语言包,平台变体按目标平台排除
- 内存治理:定期采样 + 超标熔断重建 + 空闲回收 + 渲染进程显式 GC
回到开头的问题:Electron 应该怎么做?FeedMind 的答案是——别把 Electron 当 UI 框架用,把它当一台你完全控制的本地主机。窗口是可以随时销毁重建的资源,浏览器内核是可以被 CDP 驱动的执行沙箱,主进程是可以内嵌完整后端的运行时。想清楚这个定位,很多「Electron 应用很卡很重」的老问题,其实都有很工程化的解法。
本文所有代码均来自 FeedMind 的真实提交,感兴趣可以阅读 apps/desktop/src/main.ts(315 行)与 apps/desktop/src/env-bootstrap.ts(139 行)——两个文件加起来不到 500 行,装下了这篇文章的全部内容。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!



