工具箱里有八个工具页面。加到第八个的时候,已经能闭着眼睛说出要动哪些文件了。

这里想聊的重点是怎么让「再加一个工具」这件事变得方便。一个功能页面的价值,有一半取决于它接入系统的成本——如果每加一个工具都要动十处、漏一处就静默失效,那这个工具箱就长不大。


五处同步:一条不能漏的清单

在这个项目里,新增一个工具箱页面必须同步修改五处:

#文件作用
1src/shared/types/navigation.ts把 SectionId 联合类型加上新的 id
2src/app/menuConfig.ts在工具箱分组里加菜单项(id、标题、描述、图标)
3src/app/AppRouter.tsx加一个 case,把 id 映射到页面组件
4src/components/Sidebar.tsx在图标映射表里登记 id → 图标

前四处是编译期约束,漏了会被 TypeScript 挡住——SectionId 是联合类型,新增 id 不加就报错;Sidebar 的映射表是 Record<SectionId, ...>,缺键也报错。这正是把 id 定义成联合类型、把映射表定义成完整 Record 的价值:让编译器替你记住清单。

IPC 的四层契约

工具箱里的工具大多需要主进程配合:选文件、读文件、写文件、调原生库。这些能力通过 IPC 暴露,而且要同步改四层:

层文件职责
1. 处理器electron/ipc/<tool>Ipc.cjsipcMain.handle(...) 真正干活
2. 注册electron/ipc/index.cjsrequire + 调用注册函数
3. 桥接electron/preload.cjscontextBridge 暴露给 window.smartuedtool
4. 类型src/shared/types/ipc.ts声明 window.smartuedtool 的形状

四层里,第 1、3、4 层是契约(谁调用、叫什么名字、传什么参、返回什么),第 2 层是装配。

这里有一条架构约定很重要:

**新业务 IPC 默认只注册/转发,业务逻辑放 electron/services/.cjs。*

也就是说,ipc/*Ipc.cjs 应该薄——只做参数接收和结果回传,真正的逻辑在 service 里。这条约定的好处是可测试:service 是普通 CommonJS 模块,可以脱离 Electron 单独跑。

但也有例外。PDF 去水印的 IPC 就是薄的,因为它只暴露了三个纯 IO 操作,真正的引擎跑在渲染进程:

// pdfWatermarkIpc.cjs —— 只有三个动作,没有任何业务逻辑
ipcMain.handle('pdf-watermark:select-files', ...);    // 选文件
ipcMain.handle('pdf-watermark:show-in-folder', ...);  // 在文件夹里显示
ipcMain.handle('pdf-watermark:read-file-raw', ...);   // 直读 base64

IPC 的职责边界,应该和「能力边界」重合。 需要什么能力就暴露什么能力,不多暴露。去水印需要主进程的能力只有「弹文件对话框」和「读文件」,那就只暴露这两个——引擎本身不需要主进程。

一个反例是死代码。Word 图片压缩曾经有一套用 electron.nativeImage 做二分搜索的旧实现(imageCompressorService.cjs),后来被 sharp 的压缩阶梯版取代。但旧文件没有删,只是从 ipc/index.cjs 里取消了注册——它还在仓库里,还能被搜到,还长得像正经代码,只是永远不会被执行。直到很久以后,增加新工具我才发现。这类代码的害处不在于占用空间,而在于它会让人建立错误的认识。过代码时,第一眼看到的就是那份 nativeImage 二分搜索实现,差点以为它是现役逻辑。

零新增依赖:不是巧合

上一篇文章提到,PDF 去水印整个功能没有引入任何新依赖——pdf-lib 和 pdfjs-dist 都是项目里已有的(PDF 压缩用了 pdf-lib,双层 PDF 用了 pdfjs-dist)。

所以「零新增依赖」是前面几个决策的结果。它带来的收益很实在:打包体积不变、原生模块的编译风险不变、npm audit 的维护面不变。

反过来也说明一件事:如果某个工具必须引入原生依赖或外部程序,那它对工具箱的「接入成本」就不只是代码量的问题。 Word 转 PDF 一直解决不了效果和外部依赖的问题,最终选择移除,直接用wps或word导出。工具不能替代全部外部程序。

样式:一个文件,按工具分节

所有工具箱页面共用一个样式文件 src/styles/feature-toolbox.css(约 1200 行),按工具分节,每节带横幅注释:

/* ========== image compressor ========== */
/* ========== image processor ========== */
/* ========== architecture diagram ========== */
/* ========== pdf compress ========== */
/* ========== pdf watermark ========== */
/* ========== pdf layer ========== */

类名按工具命名空间隔离,互不干扰。不用 Tailwind,用全局 CSS + 基础组件库。

这个选择的取舍很清楚:单文件会随工具数量线性增长,但换来的是「没有构建期样式工具链」和「一眼能看完全部工具样式」。 对八个工具的规模来说,这个取舍是划算的。如果涨到三十个,就该考虑拆分或引入 CSS Modules 了。

懒加载与首屏

有一件事一开始想错了:以为页面是按需懒加载的。实际并非如此。AppRouter.tsx 是静态导入,所有页面组件在文件顶部一次性 import。

import ImageCompressorPage from '../features/toolbox/pages/ImageCompressorPage';
import PdfWatermarkPage from '../features/toolbox/pages/PdfWatermarkPage';
// ... 全部静态导入

首屏因此要把八个工具箱页面 + 去水印引擎 + pdfjs 全部加载进来。这个也要后续考虑优化。

把这条写出来,是因为它正好是「接入成本」的另一面:新增工具时,不仅要改五个文件,还在给首屏体积加码。 一个只做字符串拼接的 SVG 生成器,和一个会拖进 pdfjs 的 PDF 引擎,接入成本并不相同。

小结

让「再加一个工具」变便宜,靠的是三件事:

  1. 让编译器记住清单。 id 用联合类型、映射表用完整 Record——漏了会报错,这是最便宜的保险。
  2. 识别静默失效点。 服务端埋点白名单、Dashboard 中文名映射——这些跨端、跨语言、跨部署单元的同步项,编译器管不到,必须靠流程和清单兜住。
  3. 把边界写进约定。 IPC 只暴露需要的能力、业务逻辑放 service、样式按工具分节、能力重的工具单独隔离。约定清楚了,新工具就是照着填,而不是每次重新决策。

一个工具箱能不能长大,取决于「加一个工具」的成本是不是常数。

代码量可以随功能增长,但要记住的清单、要做的决策、要付的隐性成本,应该尽量保持不变。