一、从第一性原理看:什么是"打包"?
在本地开发 Electron 应用时,我们执行 npm run dev 就能启动应用,是因为开发机上已经装好了 Node.js 、Vite 开发服务器 、成千上万个零散的 node_modules 依赖文件,以及编译工具链。
但普通用户的电脑上既没有 Node.js,也没有编译器。所谓的"打包(Packaging)",本质上就是把「编译后的业务代码 + 生产依赖 + 自带的运行引擎(Chromium + Node.js)」组装成一个脱离开发环境也能双击运行的独立系统应用(macOS .app / Windows .exe / Linux .deb)的过程。
安装包体积公式
一个 Electron 安装包的真实体积构成如下:
安装包体积=固定大头(压缩后约 90 100MB) Electron 运行底座(Chromium + Node.js)+很小(几 MB 到十几 MB) 编译后的业务代码(dist + dist-electron)+随依赖增减 生产依赖(dependencies)
关于"依赖越多,安装包是否越大",需要区分两类依赖:
| 依赖类型 | 典型代表 | 是否进入安装包 | 对安装包体积的影响 |
|---|---|---|---|
devDependencies(开发依赖) |
typescript, vite, vitest, eslint, electron-builder |
否 | 0 影响。仅在编译/打包阶段干活,打包完成后不会进入最终产物。 |
dependencies(生产依赖) |
react, monaco-editor, pdfjs-dist, three, node-pty |
是 | 直接增加体积 。普通工具库可通过 Vite 的 Tree-shaking(摇树优化)只打包用到的函数;但像编辑器内核、PDF 渲染引擎、3D 引擎这类重型模块无法裁剪,且 electron-builder 默认会将 dependencies 下的 node_modules 一并打入包内。 |
二、Electron 打包的四步核心流水线
一次完整的生产环境打包,按顺序经历以下 4 个阶段:
第 1 步:源码编译与压缩混淆(Build & Minify)
浏览器和 Node.js 不能直接运行 .ts / .tsx 源码,必须先编译成纯 .js:
- 渲染进程(Renderer,前端界面) :通常由
vite build将 React/Vue 组件、CSS 样式打包输出到dist/目录。 - 主进程与预加载脚本(Main & Preload) :通常由
tsup/esbuild/tsc将控制窗口、本地 I/O 的代码编译输出到dist-electron/目录(如main.cjs、preload.cjs)。
第 2 步:封装单文件归档(app.asar 与 asarUnpack)
编译产物加上 node_modules 往往有上万个小文件。如果直接散装进安装包,操作系统拷贝安装会极慢。
app.asar:electron-builder会将代码和依赖合并封装为一个叫app.asar的只读归档单文件(类似不压缩的.tar包,头部带有一份 JSON 格式的文件目录索引)。Electron 内部重写了 Node 的fs模块,运行时能把app.asar当作虚拟文件夹直接秒读;但对操作系统的 Shell(如cd命令)来说,它只是一个普通文件,不是文件夹。asarUnpack(外置解包) :像node-pty(终端模拟器)这类包含 C++ 原生动态库(.node/.dll/ 二进制可执行文件)的模块,操作系统内核的动态链接器无法从虚拟的app.asar单文件中加载它们。因此必须通过asarUnpack配置,将它们单独拎出来放在旁边的app.asar.unpacked/真实文件夹中。
第 3 步:套壳组装应用包(以 macOS .app 为例)
electron-builder 下载官方预编译好的 Electron 裸壳,把应用名称、图标(.icns)、系统权限声明(Info.plist)和第 2 步生成的 app.asar 塞进去。
安装到 macOS /Applications/MyApp.app 后,其内部真实的目录结构如下:
text
MyApp.app/
└── Contents/
├── Info.plist # 应用身份证(AppId、版本号、系统权限声明)
├── MacOS/
│ └── MyApp # 真正的可执行二进制入口(原 Electron 引擎)
├── Frameworks/ # Chromium 渲染引擎、V8 引擎、系统框架库
└── Resources/
├── app.asar # 【核心】业务编译产物 + package.json + JS 依赖
├── app.asar.unpacked/ # 必须裸露给操作系统的 C++ 原生模块(如 node-pty)
├── icon.icns # 应用图标
├── zh_CN.lproj/ # Chromium 自带的 50+ 种系统原生界面语言包
└── en.lproj/
为什么
Resources/下会有 50 多个*.lproj文件夹?.lproj(Language Project)是 macOS 标准的多语言本地化目录。它们不是业务代码生成的,而是 Electron 底座(Chromium 内核)自带的,用于提供系统原生文件选择框、原生菜单栏、打印弹窗在 50 多种语言下的翻译。
第 4 步:代码签名、Apple 公证与安装包压制
为什么本机打包出来的 .app 自己双击能打开,通过网络/聊天软件发给别人就打不开?
-
隔离属性(
com.apple.quarantine) :通过浏览器、IM 软件、AirDrop 接收的文件,macOS 会自动给它打上com.apple.quarantine扩展属性。 -
Gatekeeper 拦截机制 :首次打开带隔离属性的
.app时,macOS 的 Gatekeeper 强制要求该应用同时满足两项条件(从 macOS 15 Sequoia 起,右键"打开"已不再允许直接绕过):- 代码签名(Code Signing) :必须使用 Apple Developer Program($99/年)签发的
Developer ID Application证书完成codesign(免费或开发调试用的Apple Development证书、自签名证书均不被认可)。 - Apple 公证(Notarization) :签名后将应用上传至 Apple 云端服务(
notarytool)自动扫描恶意软件,通过后执行xcrun stapler staple将公证票据钉入.app包内。
- 代码签名(Code Signing) :必须使用 Apple Developer Program($99/年)签发的
-
内部测试免签绕过方法 :若仅内部联调测试、未做签名公证,接收方可在终端执行一行命令清除隔离属性,即可直接打开:
bashxattr -cr /Applications/MyApp.app
三、实战踩坑与排查复盘
在检验生产安装包安全性与体积时,可以通过官方工具 @electron/asar 检查 app.asar 内部内容。围绕这一过程,总结三处极易踩中的坑:
坑 1:electron-builder 使用 files: ["**/*"] 黑名单模式,导致 app.asar 泄露全部源码与文档
现象与风险
app.asar 本质上只做归档,没有任何加密 。任何用户安装应用后,都可以用一行命令解包 app.asar。 如果在 electron-builder.yml 中配置了黑名单模式:
yaml
# ❌ 错误示范:黑名单模式
files:
- "**/*"
- "!.git"
- "!logs"
- "!release"
这会导致除了被显式 ! 排除的目录外,仓库里的 src/(全部 TypeScript 原始源码) 、docs/(全部内部架构与设计文档) 、scripts/(构建与发布脚本) 、test/ 乃至 vendor/(几十 MB 的离线构建工具包) 被原封不动地打进 app.asar! 不仅任何人解包后都能看到一模一样的源码和设计文档,还白白把安装包撑大了数千万字节。
正确做法:改为严格白名单模式
运行时真正需要的只有编译后的产物目录。应将 files 改为白名单:
yaml
# ✅ 正确示范:白名单模式(并兜底排除所有 Source Map)
files:
- "dist/**/*"
- "dist-electron/**/*"
- "package.json"
- "!**/*.map"
electron-builder 会自动包含 package.json 以及生产所需的 node_modules,而 src/、docs/、scripts/、vendor/ 等源码与构建期文件一概不会进入安装包。
延伸案例:Claude Code 源码泄露事故(Source Map 陷阱)
客户端与 CLI 工具的打包泄露问题在行业内屡见不鲜,最典型的案例就是 Anthropic 的 Claude Code 源码泄露事件(v2.1.88):
- 事故经过 :Anthropic 在向 npm 发布
@anthropic-ai/claude-code@2.1.88时,虽然将业务代码打包压缩(Minify)成了单文件cli.js,但在打包配置中误将一个高达 60 MB 的调试映射文件cli.js.map(Source Map)一起打进了发布包中。 - 为什么一个
.map文件等于完整源码?- 代码开启
minify混淆后,为了方便开发者在报错时定位原始 TypeScript 行号,构建工具(Vite / esbuild / tsup / webpack)支持生成.js.map文件。 .map文件本质是一个 JSON,里面不仅记录了压缩前后代码的行列号映射,还默认包含一个名为sourcesContent的数组字段------该字段直接内嵌了所有原始.ts/.tsx源文件的完整源码字符串和目录路径!
- 代码开启
- 事故后果 :外部开发者发现后,仅用几行脚本解析
cli.js.map的sourcesContent,瞬间 100% 还原出 Claude Code 的完整源码树(约 1,900 个 TypeScript 文件、超过 51 万行代码,包含全部内部注释与未公开特性)。
Electron 应用会有同样的 Source Map 风险吗?
完全一样。 虽然 Electron 分发的是 .dmg / .exe(内部为 app.asar)而非 npm 的 .tgz 包,但 .js.map 是由前端构建工具(vite build / tsup)在编译阶段直接输出到 dist/ 和 dist-electron/ 目录下的:
Vite和tsup默认sourcemap: false;- 但如果开发者为了本地调试生产包报错,在
vite.config.ts或tsup.config.ts中开启了sourcemap: true且忘记关闭,生成的.js.map就会随dist/**/*一起被打进app.asar。用户解开app.asar后同样能一键还原全部源码。
总结:JS 客户端/桌面端源码泄露的三大途径与防线
| 泄露途径 | 触发原因 | 攻击者还原难度 | 防御手段 |
|---|---|---|---|
| 1. 原目录直出 | electron-builder 配了 files: ["**/*"] 黑名单,漏排 src/、docs/ |
零门槛 :asar extract 解包直接看到原始 .ts 文件与设计文档 |
files 改为严格白名单,只打 dist/**、dist-electron/**、package.json |
| 2. Source Map 还原(Claude Code 事故) | 构建开启了 sourcemap: true,且未在打包时过滤 .map 文件 |
极低 :用工具读取 .js.map 的 sourcesContent,1 秒 100% 还原 src/ 目录树 |
生产构建保持 sourcemap: false,并在打包 files 中加 "!**/*.map" 双重防呆 |
| 3. 产物未混淆 | 排除了 src/ 和 .map,但编译工具(如主进程 tsup)未开 minify |
中等:丢失了目录结构和 TS 类型,但单文件内的函数名、变量名和逻辑分支完全可读 | 前端(Vite)与主进程(tsup)生产构建统一开启 minify: true |
坑 2:前端与主进程构建配置不对称,主进程漏开 minify
现象与原理
即便用白名单排除了 src/ 源码,用户解包后依然能看到 dist/ 和 dist-electron/ 下的 .js / .cjs 编译产物。
- 前端代码走
vite build,默认开启了minify(代码压缩与混淆)。 - 但主进程代码走
tsup/esbuild时,默认往往是minify: false(或仅在特定环境变量下开启)。如果生产构建未开启minify,编译出的dist-electron/main.cjs会保留完整的业务函数名、变量名和缩进格式,几乎等同于去掉类型后的源码。
minify 到底做了什么?
V8 引擎执行代码时不需要注释、空格,也不关心变量名叫 minimumSupportedVersion 还是 e。开启 minify: true 后,打包器在不改变运行逻辑的前提下完成三件事:
- 剥离空白与注释:删除所有注释、换行与缩进,压成单行。
- 标识符混淆(Mangle) :将局部变量、内部函数名不可逆地重命名为
e,t,n等单字母。 - 语法等价精简 :如将
true替换为!0,false替换为!1,合并分支语句。
开启前后对比:
javascript
// ❌ minify: false(解包后直接可读)
async function checkForceUpdate(currentVersion, serverManifest) {
const minimumRequired = serverManifest.minSupportedVersion;
if (compareSemver(currentVersion, minimumRequired) < 0) {
return { shouldBlock: true, reason: "version_too_low" };
}
return { shouldBlock: false, reason: null };
}
// ✅ minify: true(压缩混淆后)
async function x(e,t){return c(e,t.minSupportedVersion)<0?{shouldBlock:!0,reason:"version_too_low"}:{shouldBlock:!1,reason:null}}
建议 :在
tsup.config.ts中对生产构建开启minify: true(若需保留线上报错堆栈中的函数名以便排查日志,可搭配keepNames: true)。
四、Electron 打包安全与体积自检清单(Checklist)
每次上线或调整构建配置前,用以下 2 条标准核对产物:
- 1.
files白名单与 Source Map 检查 :electron-builder配置严禁使用files: ["**/*"],必须使用白名单并加上"!**/*.map"兜底;解包后顶层目录只能有dist/、dist-electron/、node_modules/、package.json,绝不能出现src/、docs/、scripts/、test/、vendor/或任何.js.map文件。 - 2. 双端 Minify 检查 :确认前端(Vite)与主进程(tsup/esbuild)在生产构建下均已开启
minify,打开dist-electron/main.cjs应为去空白、短变量名的压缩形态。