在 Electron 项目里做工具类功能,绕不开一个问题:这段计算写在主进程还是渲染进程?
网上常见的答案是「重活放主进程,别卡 UI」。这个答案不算错,但太粗——它解释不了同一个项目里那些看起来互相矛盾的决定:同样是重计算,Word 图片压缩放主进程,PDF 压缩放渲染进程,双层 PDF 拆成两边各做一半。
这里用工具箱里六个工具的真实取舍,说明这条边界到底该怎么划。
先纠正一个前提:这不是「安全 vs 不安全」
很多讨论把这个问题框成「主进程有完整 Node 能力,所以更安全/更正确」。在这个项目里,这个前提不成立。
这是一个开源桌面客户端,所有数据传输层都在用户本机。渲染进程、preload、主进程、内部 IPC 都属于用户本机可信边界,不存在跨信任域的威胁模型。所以不会因为「主进程更安全」而把计算往主进程搬——那是把安全叙事用在了不需要它的地方。
真正决定边界的只有一件事:
这个计算需要的能力,长在哪一侧?
顺着这个问法,判断可以拆成四个轴。
轴一:需要 DOM 吗?
这是最硬的一条约束,没有商量空间。
PDF 压缩需要把内嵌图片解码出来、重新编码成 JPEG。解码和编码必须走 canvas——也就是 createImageBitmap + canvas.getContext('2d')。渲染进程有 Canvas API,主进程没有。
// PDF 压缩:遍历所有 /Subtype /Image 对象
for (const obj of context.enumerateObjects()) { ... }
// 用 canvas 尝试解码,只有解出来的才能重编码
const bitmap = await createImageBitmap(blob); // ← 只有 Renderer 有
所以 PDF 压缩整套逻辑(pdf-lib 遍历 Image XObject + canvas 重编码)必须在渲染进程。
同理,上一篇文章里 PDF 去水印的像素级校验要读 getImageData、做灰度化——也只能在渲染进程。
结论:涉及 Canvas / 布局 / 字体的计算,天然属于渲染进程。
轴二:需要原生能力吗?
反过来的约束同样硬。
Word 图片压缩和图片处理都用 sharp——一个带原生 .node 二进制的图像库。原生模块要能被加载,还要能直接读写文件系统、解压 docx 的 zip 包。这些都在主进程。
// 主进程:sharp + jszip 处理 Word 内嵌图片
const { data } = await sharp(buffer).jpeg({ quality: q, mozjpeg: true }).toBuffer();
Word 转 PDF 更极端:它需要 child_process 去调外部的 LibreOffice(soffice)或者 Windows 上的 Microsoft Word。
// 主进程:枚举三平台的 soffice 路径,找不到就退到 Word COM
env.MallocNanoZone = '1'; // ← LibreOffice macOS app 需要这个,否则起不来
结论:原生模块、文件系统、子进程,天然属于主进程。
轴三:会不会卡住 UI?
到这里就出现真正的张力了。
按「重活放主进程」的说法,PDF 去水印这种 CPU 密集的解析 + 改写应该放主进程。但实现落在了渲染进程。
理由是能力:引擎依赖 pdf-lib 和 pdfjs-dist,而 pdfjs-dist 的文本抽取和渲染都需要 DOM 环境(Canvas、DOMMatrix、Worker)。搬到主进程要么放弃渲染级校验,要么把 pdfjs 的 Node 适配(@napi-rs/canvas)拖进打包——为了一处调用引入一套原生依赖,不划算。
那「卡 UI」怎么处理?
第一,让出事件循环。 长任务里插入 yield,让界面保持可响应:
function yieldToEventLoop(): Promise<void> {
return new Promise((resolve) => { setTimeout(resolve, 0); });
}
第二,用进度回调把状态推给界面。 引擎接受一个 onProgress(message, ratio),在解析、探测、移除、写出、校验五个阶段各报一次:
emit('正在解析 PDF…', 0.04);
emit('正在探测水印特征…', 0.12);
emit('正在移除水印…', 0.3);
emit('正在写出文件…', 0.62);
emit('正在校验结果…', 0.74);
第三,代价。 既然引擎跑在渲染进程,超大 PDF 就会受 UI 进程内存上限约束。界面上要做好用户体验提示,不要等用户遇到崩溃再猜。
轴四:产物要落到哪里?
不管计算在哪做,写文件一律回主进程。渲染进程不碰 fs,只通过 window.smartuedtool 走 IPC。
这里踩过一个坑,值得单独说:大文件的 data URL 不可靠。
最初读文件用的是 FileReader 转 data URL。小文件没问题,大文件(十几 MB 的 PDF)在解码环节会失败。最后改成让主进程直接读成 base64 返回:
// 直接读取文件为 base64(绕过 data URL,更可靠)
ipcMain.handle('pdf-watermark:read-file-raw', (_event, filePath) => {
try {
return fs.readFileSync(filePath).toString('base64');
} catch { return null; }
});
PDF 压缩和 PDF 去水印两个工具都改成了同一个方案。这是个典型的「能力边界没划错、但数据通路选错了」的坑——计算位置是对的,只是传输方式该换。
拆成两边各做一半:双层 PDF
最能说明「边界不是非此即彼」的,是双层 PDF 工具。
它的目标:给扫描件 PDF 加一层图片,保留原始文字层,生成可搜索的双层 PDF。
- 渲染:用
pdfjs-dist把每一页渲染成 JPEG。需要 Canvas → 渲染进程。 - 叠加:用
pdf-lib把图片嵌进去、覆盖整页。需要重写 PDF 对象 → 主进程。
于是它变成了一个逐页往返的 IPC 协议:
主进程 ──pdf-layer:render-page──→ 渲染进程
←─pdf-layer:page-image──── (scale = dpi/72,JPEG 质量 0.85)
每页 30s 超时,超时即报错
页面图片超过体积阈值时,质量按 0.85 → 0.7 → 0.5 → 0.35 → 0.25 逐级降。
同一个功能,两个进程各承担一半——因为它的两个阶段需要的能力确实长在不同侧。 强行统一到一侧,要么丢能力,要么引依赖。
决策表
把六个工具摊开看,规律很清楚:
| 工具 | 计算位置 | 决定性能力 |
|---|---|---|
| Word 图片压缩 | Main | sharp 原生模块 + jszip |
| 图片处理 | Main | sharp 原生模块 |
| 架构图(生成) | Renderer | 纯前端 SVG 生成,无 AI、无 IO |
| 架构图(导出 PNG / PPT) | Main | 离屏渲染 + pptxgenjs |
| PDF 压缩 | Renderer | Canvas 重编码 |
| 双层 PDF | 拆分 | 渲染需 Canvas / 叠加需重写 PDF 对象 |
| PDF 去水印 | Renderer | pdfjs-dist 渲染校验 + 无新依赖 |
| Word 转 PDF | Main | child_process 调外部引擎 |
顺着表往下读,会发现一个反直觉的现象:架构图的「生成」其实完全不重。 它是四个手写的 SVG 生成器,纯前端字符串拼接。真正需要主进程的只是导出环节(PNG 走离屏渲染、PPT 走 pptxgenjs)。
也就是说,「要不要放主进程」和「这段代码重不重」经常无关。它只和「需要什么能力」有关。
边界的代价
把去水印放渲染进程,代价是大文件受 UI 进程内存限制——这条写在界面上。
把 Word 转 PDF 放主进程,代价是依赖外部程序:要么装 LibreOffice,要么有 Microsoft Word。这个代价大到目前仍在调试中。
小结
这条边界可以压成下面这句。
不要问「哪边更安全」或「哪边更快」,先问「这个能力长在哪一侧」。
具体三条:
- 硬约束优先。 Canvas / DOM / 字体 → Renderer;原生模块 / 文件系统 / 子进程 → Main。这条没有折中。
- 软约束用工程手段兜。 卡 UI 用 yield + 进度回调 + 分批解决,而不是违背硬约束把代码搬到能力不对的一侧。
- 允许拆分。 一个功能的两个阶段需要不同侧的能力时,就用 IPC 把两边连起来——双层 PDF 就是模板。
还有一个附带的收获:能力边界选对了,依赖成本往往也最低。 去水印整个功能零新增依赖,就是因为 pdf-lib 和 pdfjs-dist 本来就装在渲染进程这一侧。