把 Electron 当本地服务器用:FeedMind 桌面端的工程实践

7258 字
36 分钟
把 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 SPAsandbox: true、伪装 Chrome UA
标记窗口 feedmind-crawler隐藏爬虫执行的页面沙箱内存 partition、不节流
标记窗口 feedmind-agent隐藏AI Agent 浏览器工具内存 partition、空闲 2 分钟回收
为什么用 localhost HTTP 取代 IPC

这个决策的收益远比表面看起来大:

  1. UI 零改造复用 Web 端——同一个 React 应用既能跑在浏览器里,也能跑在 Electron 里,渲染层完全不知道自己在桌面环境
  2. Vite HMR 原生可用——开发时主窗口加载 Vite dev server,改 UI 热更新,不感知 Electron 的存在
  3. API 可以独立于 GUI 运行——同一个 startApi() 也能从 CLI 启动,桌面端只是「宿主」之一
  4. 调试体验——接口直接 curl http://127.0.0.1:18790 就能测,不需要开 Electron
  5. 省掉一整层复杂度——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,不依赖 dotenv
const 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");
// 读 → 读不到则生成 → 写 → 用
}
模式总结:Bootstrapping Module

这个模式可以泛化成一条规则——凡是依赖运行环境(打包态/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=codeV8 代码缓存,加速脚本启动
js-flags=--expose-gc渲染进程暴露 globalThis.gc()(见内存治理一节)
disable-accelerated-2d-canvas2D 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: falsecontextIsolation: 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 侧:只定义接口,不依赖 electron
export 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 环境可逃逸
3API 只监听 127.0.0.1内嵌服务不暴露到局域网
4CDP 绑定回环 + UA 必须含 Electron防止驱动用户本机浏览器
5setWindowOpenHandler 全拒 + 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 收进安装包
└─────────────────────────────┘
# electron-builder.yml(节选)
asar: true
asarUnpack:
# .node 与原生 exe 不能从 asar 内加载,必须解包到磁盘
- "**/better-sqlite3/**/*"
- "**/@libsql/**/*"
- "**/@remotion/compositor-win32-x64-msvc/**/*"
files:
- dist/main.js
- package.json
# 只收必需的原生依赖与二进制
- node_modules/@libsql/win32-x64-msvc/**/*
- node_modules/playwright-core/**/*
# 剔除已被 bundle 进 main.js 的纯 JS 重型依赖
- "!node_modules/@mastra/**"
- "!node_modules/@ai-sdk/**"
- "!node_modules/drizzle-orm/**"
- "!node_modules/zod/**"
# 剔除非 win32-x64 平台的无效变体(macOS/Linux/arm64 的二进制全都用不上)
- "!node_modules/**/@rspack/binding-{darwin,linux}-*/**"
- "!node_modules/**/@esbuild/{aix-ppc64,android-*,darwin-*,linux-*}/**"
extraResources:
# Web 静态产物 → resources/web-dist,API 同源托管
- from: ../web/dist
to: web-dist

三组排除各有含义: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 关闭差分更新包(没有自动更新就用不上,白占体积)
  • 国内镜像.npmrcelectron_mirror + electron_builder_binaries_mirror,builder 配置的 electronDownload.mirror,外加 pnpm overrides 锁定 @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: truewindow.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 行,装下了这篇文章的全部内容。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

把 Electron 当本地服务器用:FeedMind 桌面端的工程实践
https://rushzb-blog.pages.dev/posts/feedmind-electron/
作者
rushzb
发布于
2026-08-29
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
rushzb
初级 Vibe Coder
公告
欢迎浏览我的学习报告~
分类
标签
最新动态
站点统计
文章
6
动态
1
分类
1
标签
25
总字数
56,556
运行时长
0
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.14.2
文章许可
CC BY-NC-SA 4.0