在 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。

于是它变成了一个逐页往返的 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 图片压缩Mainsharp 原生模块 + jszip
图片处理Mainsharp 原生模块
架构图(生成)Renderer纯前端 SVG 生成,无 AI、无 IO
架构图(导出 PNG / PPT)Main离屏渲染 + pptxgenjs
PDF 压缩RendererCanvas 重编码
双层 PDF拆分渲染需 Canvas / 叠加需重写 PDF 对象
PDF 去水印Rendererpdfjs-dist 渲染校验 + 无新依赖
Word 转 PDFMainchild_process 调外部引擎

顺着表往下读,会发现一个反直觉的现象:架构图的「生成」其实完全不重。 它是四个手写的 SVG 生成器,纯前端字符串拼接。真正需要主进程的只是导出环节(PNG 走离屏渲染、PPT 走 pptxgenjs)。

也就是说,「要不要放主进程」和「这段代码重不重」经常无关。它只和「需要什么能力」有关。

边界的代价

把去水印放渲染进程,代价是大文件受 UI 进程内存限制——这条写在界面上。

把 Word 转 PDF 放主进程,代价是依赖外部程序:要么装 LibreOffice,要么有 Microsoft Word。这个代价大到目前仍在调试中。

小结

这条边界可以压成下面这句。

不要问「哪边更安全」或「哪边更快」,先问「这个能力长在哪一侧」。

具体三条:

  1. 硬约束优先。 Canvas / DOM / 字体 → Renderer;原生模块 / 文件系统 / 子进程 → Main。这条没有折中。
  2. 软约束用工程手段兜。 卡 UI 用 yield + 进度回调 + 分批解决,而不是违背硬约束把代码搬到能力不对的一侧。
  3. 允许拆分。 一个功能的两个阶段需要不同侧的能力时,就用 IPC 把两边连起来——双层 PDF 就是模板。

还有一个附带的收获:能力边界选对了,依赖成本往往也最低。 去水印整个功能零新增依赖,就是因为 pdf-lib 和 pdfjs-dist 本来就装在渲染进程这一侧。