最近我们整理了一轮 AirJelly 的打包依赖。9 月 10 日,把 9 月 3 日优化前的源码和最新优化后的源码分别重新构建,macOS arm64 应用的文件体积从 976.8 MB 降到了 529.8 MB,减少 45.8%。用于下载的 DMG 从 323.9 MB 降到了 199.4 MB,减少 38.4%。
大部分收益来自发布包里多带的东西。有些库已经编译进页面,又被完整复制了一遍;有些本地模型运行库挂在可选依赖下面,产品却根本没走那条调用路径。把这些关系理清以后,才轮到压缩主进程 JavaScript。
这两个数字记录的是几次优化和同期代码变化叠加后的净结果。最近一轮单独节省了 33.72 MB,后面会把它拆开。Electron 框架和语言资源在本次比较中保持不变。

先拆开安装包,看体积花在哪里
AirJelly 用 electron-vite 编译代码,再由 electron-builder 组装桌面应用。最终的 .app 里,既有 Electron 框架,也有应用代码、依赖和原生二进制。
应用文件主要集中在 app.asar 和 app.asar.unpacked。前者将许多文件组织成一个归档,后者存放需要以独立文件形式存在的内容,例如部分原生模块。ASAR 清单里也会列出 unpacked 文件,统计时要避免算两遍。Electron 的 ASAR 文档解释了这种归档与外置文件的关系。
| 测量对象 | 优化前 | 优化后 | 减少 |
|---|---|---|---|
整个 .app | 976.8 MB | 529.8 MB | 447.0 MB |
app.asar | 387.0 MB | 106.8 MB | 280.2 MB |
app.asar.unpacked | 313.3 MB | 146.6 MB | 166.8 MB |
Contents/Frameworks | 275.0 MB | 275.0 MB | 0 |
| DMG 下载文件 | 323.9 MB | 199.4 MB | 124.5 MB |
表里的项目有包含关系,不能整列相加。这里统一用十进制 MB,1 MB 等于 1,000,000 bytes。.app 按普通文件长度求和,跳过符号链接,因此表示文件内容的体积;APFS 实际分配了多少磁盘空间,要另行测量。
DMG 是另一个有压缩的分发文件,它的降幅和应用文件体积并不相同。对用户而言,这次变化意味着下载少了约 124.5 MB,应用文件少了约 447.0 MB。
框架部分的体积没变,应用内容从 700.4 MB 降到了 253.4 MB。这次的变化集中在应用自己的代码和依赖里。
一个库可能被打包两次
electron-vite 和 electron-builder 处理依赖的方式不同。
electron-vite 可以把库的代码编译进 JavaScript 产物。electron-builder 还会沿生产依赖收集文件,把需要在运行时独立加载的包带上。一个 renderer 库如果已经进入编译产物,却仍列在 dependencies 中,就可能又被复制一份完整目录。
renderer 源码及依赖
→ electron-vite 编译
→ out/ 中的页面和 JavaScript
out/ + 生产依赖目录 + 原生模块 + 运行资源
→ electron-builder 组装
→ 桌面应用
9 月 3 日,我们将 38 个 renderer 库和 3 个构建工具移到了 devDependencies。以 Mermaid 为例,页面使用的代码仍然在 renderer 编译产物里,发布包不必再携带完整的 node_modules/mermaid。
这次复测的旧包里,Mermaid 的独立目录约为 27.3 MB,@phosphor-icons/react 约为 16.7 MB。只看依赖名字,很容易把问题理解成“这个库太大,需要替换”。看完产物,才能发现先去掉重复副本就有收益。
这里的判断要跟着加载方式走。在我们使用的构建配置中,main 和 preload 的生产依赖通常保留为 external,在运行时加载;renderer 依赖以及实际引用的开发依赖可以由构建器编译进去。electron-vite 的依赖处理说明也区分了这些路径。
因此,移动依赖前要确认 main、preload 和 Worker 有没有外置引用。纯 JavaScript 库可以评估内联,SQLite、Sharp 这样的原生模块则需要保留相应的二进制和加载路径。把所有依赖一股脑移进 devDependencies,会得到一个更小、也可能启动不了的应用。
可选依赖会继续带来子依赖
另一块明显的体积来自 LanceDB。
AirJelly 用它在本地存储和检索向量,embedding 由服务端提供。客户端不需要通过 Transformers 在本机运行模型,但当时的依赖关系里包含这一条可选分支。
AirJelly
├─ LanceDB
│ └─ 可选 Transformers
│ ├─ ONNX 运行库
│ └─ Sharp 0.33.5 → 旧版 libvips
└─ Sharp 0.34.5 → 图片处理需要的 libvips
旧包中的 onnxruntime-node 约占 122.7 MB,onnxruntime-web 约占 72.4 MB,Transformers 本身约占 28.8 MB。这些是包内文件内容的大小,不能直接当作 DMG 的下载节省量。
最初,我们用打包文件规则排除了这条路径里的模型运行库。后来继续查包,又看到了旧版 Sharp 和 libvips。文件过滤只决定哪些文件被复制,依赖收集器仍可能沿父包的声明继续遍历,把子依赖提升到发布目录里。
所以这一轮又往前处理了一步。我们给 @lancedb/lancedb@0.31.0 加了一个很小的 Bun patch,从它的包清单中移除未使用的 Transformers 可选依赖声明。这样,收集器就不再沿这条分支找到旧版图片运行库。补丁和 patchedDependencies 一起提交,后续安装会应用同一份修改。Bun patch提供了保存这类依赖修改的机制。
这一步单独减少了约 16.1 MB,LanceDB、当前 Sharp 和当前 libvips 都继续保留。测试还特意在模拟的安装目录里留下旧版依赖,确认收集器不会把它们重新带进包。
这个补丁成立的前提,是产品没有使用 LanceDB 的本地 embedding 路径。以后如果要启用它,就得重新审视这份补丁。依赖升级时也需要复查,不能把某个版本的裁剪结论当成永久规则。
需要保留的库,也不必带上所有文件
还有一类内容没有用错依赖,只是发布得太全。
例如 SQLite 的原生模块需要保留,构建它所用的整套源码却不一定要跟着分发。一个 SDK 已有运行入口,旁边的源码、示例和开发资料,也应该按用途分别检查。
我们收紧了这部分发布规则,对已经确认用途的目录做裁剪,为额外资源列出明确的保留范围,再在 afterPack 阶段检查真实产物。这样,之后升级依赖或调整资源目录,缺少必要文件就能在打包时被发现。
最容易误删的是没有静态 import 的文件。插件可能靠隐藏的 manifest 被发现,Skill 会执行 Python 脚本,Pi 的导出功能会动态读取 HTML、CSS 和 JavaScript 模板。这些都属于运行资源,许可证也需要保留。
因此我们没有使用“删除所有 .ts 文件”或“删除所有脚本目录”这样的全局规则。每条排除规则都限定到已经查过的库,同时检查它的入口和动态资源是否还在。
只用一个日期函数,先让构建器按需编译
主进程里还有一个很小的调用,带来了约 10.2 MB 的完整 date-fns 目录。
format(startTime, "yyyy-MM-dd");
这段代码用本地日期保存应用使用记录。原先 date-fns 作为外置运行依赖发布,即使只调用了一个函数,也要携带完整包目录。
这次保留了 date-fns 和原有调用,把依赖移到 devDependencies,让现有构建器处理静态引用和未使用代码。真正用到的部分随主进程发布,完整目录便不再需要。
更换一个更轻的日期库当然也是选项,但它会引入额外的语义迁移。本地日期尤其容易被写错。toISOString().slice(0, 10) 取得的是 UTC 日期,在部分时区的午夜前后,会把记录归到另一天。
我们直接构建真实的日期处理代码,只隔离数据库、窗口采样和日志这些外部部分,再让编译产物在不同的时区进程中运行。测试覆盖台北跨年、洛杉矶仍处于上一年、闰日和纽约夏令时切换,检查最终保存的日期与时间戳。
业务调用没变,仍需要验证编译后的行为。依赖从外置加载变成内联,改变的是发布路径,测试就应该走到这条路径上。
压缩代码时,把错误定位留下来
多余目录处理完,剩下的 JavaScript 才适合继续压缩。我们给生产 main 构建启用了 esbuild minify,同一配置也覆盖它的独立 Worker 入口。
main: {
esbuild: {
keepNames: true,
},
build: {
minify: isDev ? false : "esbuild",
sourcemap: isDev ? true : "hidden",
},
}
keepNames 保留函数和类在运行时的 name 值,局部标识符仍然可以缩短。esbuild 的说明解释了两者的区别。保留名称有助于维持依赖名称的行为,具体报错位置则要靠 source map 映射回源码。
我们继续生成隐藏 source map,并通过打包规则排除 .map 文件,保留已有的 Sentry 上传流程。本次验证了压缩位置到源码的映射,没有验证线上上传。
压缩还让一个构建检查报了错。原来的生产身份隐私检查依靠固定函数名和换行格式查找函数体,minify 改变了这些文本形态,检查就无法正确识别。
修复时,我们用已有的 TypeScript 解析器读取 AST,也就是代码的语法结构,通过保留的运行时名称定位函数,仍然严格要求它只返回空对象。额外字段、展开属性和副作用都会被拒绝,异步函数和生成器也不能通过。
安全要求继续执行,检查方式改成了识别代码结构。类似检查如果依赖源码排版,开启压缩时就需要一起核对。
这一轮主进程 JavaScript 从 15.95 MB 降到了 9.50 MB。这里包含 date-fns 内联后的变化,不能把全部差值都归给 minify。
每一步省了多少,要单独记账
前面 447.0 MB 的累计变化跨过了两个历史源码快照,中间还有其他功能和依赖调整。它适合回答最终应用小了多少,无法独立证明某一个改动的贡献。
最近一轮保留了分阶段的审计包,可以在更窄的范围里比较。
| 阶段 | app.asar 与 unpacked 合计 | 本步减少 |
|---|---|---|
| 本轮开始前 | 287.07 MB | 不适用 |
| 断开未使用的 Transformers 依赖分支 | 271.02 MB | 16.05 MB |
| date-fns 按需编译并压缩 main | 253.35 MB | 17.67 MB |
三项改动合计节省 33.72 MB。后一步的 17.67 MB 除了日期库目录和 JavaScript 的变化,还包括约 1.02 MB 的 ASAR 索引缩减。文件变少,记录路径和校验信息的元数据也会随之变少。
最终比较用两个独立 Git worktree,分别按各自的锁文件安装和构建。两端使用相同的 macOS arm64 平台,以及 Electron 41.7.1、electron-builder 26.8.1、electron-vite 5.0.0 和 Vite 7.3.3。原生模块版本及已有补丁核对一致后复用,OCR 和辅助功能二进制按各自源码重新构建。两份 DMG 都未签名、未公证,用于体积测试。
复测时,原生资源准备不能省略。安装时跳过脚本、打包时关闭 rebuild,如果没有补齐二进制,就可能拿到一个缺文件的“小包”。我们保留了源码提交、锁文件和产物哈希,用来识别样本;构建身份及 DMG 元数据仍可能变化,这次没有做到逐字节可重复构建。
本地 macOS 的 2,765 个单元与集成用例通过,两份 DMG 均通过完整性检查。新包还通过了 30 项模块与 Worker 检查,覆盖 SQLite 加密读写、Sharp 图片处理、LanceDB 向量操作,以及动态模板和 Worker 执行。220 个语言资源文件的 SHA-256 完全一致,约 48.4 MB 的语言文件全部保留。
这些检查证明了本次测量和所覆盖的运行路径。Windows、macOS x64、签名公证及完整桌面流程仍需要分别验收,体积变小本身不能替代发布测试。
剩下的体积有不同的成本
优化后,275.0 MB 的 Contents/Frameworks 已占整个应用约 51.9%。继续追求同样幅度的下降,面对的成本就不同了。
如果继续保留现有 Electron 架构和全部语言资源,下一轮就得在剩余的应用内容里寻找空间。是否值得继续裁剪某个依赖,要看新的文件清单,以及它会增加多少升级和运行验证的工作,不能沿用这次的节省比例来预估。