工具箱里有八个工具页面。加到第八个的时候,已经能闭着眼睛说出要动哪些文件了。
这里想聊的重点是怎么让「再加一个工具」这件事变得方便。一个功能页面的价值,有一半取决于它接入系统的成本——如果每加一个工具都要动十处、漏一处就静默失效,那这个工具箱就长不大。
五处同步:一条不能漏的清单
在这个项目里,新增一个工具箱页面必须同步修改五处:
| # | 文件 | 作用 |
|---|---|---|
| 1 | src/shared/types/navigation.ts | 把 SectionId 联合类型加上新的 id |
| 2 | src/app/menuConfig.ts | 在工具箱分组里加菜单项(id、标题、描述、图标) |
| 3 | src/app/AppRouter.tsx | 加一个 case,把 id 映射到页面组件 |
| 4 | src/components/Sidebar.tsx | 在图标映射表里登记 id → 图标 |
前四处是编译期约束,漏了会被 TypeScript 挡住——SectionId 是联合类型,新增 id 不加就报错;Sidebar 的映射表是 Record<SectionId, ...>,缺键也报错。这正是把 id 定义成联合类型、把映射表定义成完整 Record 的价值:让编译器替你记住清单。
IPC 的四层契约
工具箱里的工具大多需要主进程配合:选文件、读文件、写文件、调原生库。这些能力通过 IPC 暴露,而且要同步改四层:
| 层 | 文件 | 职责 |
|---|---|---|
| 1. 处理器 | electron/ipc/<tool>Ipc.cjs | ipcMain.handle(...) 真正干活 |
| 2. 注册 | electron/ipc/index.cjs | require + 调用注册函数 |
| 3. 桥接 | electron/preload.cjs | contextBridge 暴露给 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)。
- 能力边界选对了,引擎落在渲染进程,而 PDF 相关的成熟库恰好都在那一侧的生态里
- 工具箱已经积累了同类工具,PDF 压缩和双层 PDF 把
pdf-lib/pdfjs-dist都带进来了 - 功能本身是纯计算,不需要外部程序(对比 Word 转 PDF 必须依赖 LibreOffice)
所以「零新增依赖」是前面几个决策的结果。它带来的收益很实在:打包体积不变、原生模块的编译风险不变、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 引擎,接入成本并不相同。
小结
让「再加一个工具」变便宜,靠的是三件事:
- 让编译器记住清单。 id 用联合类型、映射表用完整
Record——漏了会报错,这是最便宜的保险。 - 识别静默失效点。 服务端埋点白名单、Dashboard 中文名映射——这些跨端、跨语言、跨部署单元的同步项,编译器管不到,必须靠流程和清单兜住。
- 把边界写进约定。 IPC 只暴露需要的能力、业务逻辑放 service、样式按工具分节、能力重的工具单独隔离。约定清楚了,新工具就是照着填,而不是每次重新决策。
一个工具箱能不能长大,取决于「加一个工具」的成本是不是常数。
代码量可以随功能增长,但要记住的清单、要做的决策、要付的隐性成本,应该尽量保持不变。