
我是在微信群里看到尤雨溪那条状态截图的。第一反应是Node.js 打包成 exe 这事喊了多少年了又要被标题党消费但点开他贴的启动耗时对比看到 SEA 打包出来的 exe 在冷启动上居然压过了以启动速度著称的 Bun我当晚就把 Node 升到 22.12亲手把一个小工具打成了 Windows 可执行文件。跑通的那一刻说实话是有点爽的。这篇文章我不想复述新闻想讲清楚三件事这套官方 SEA 到底怎么解决了历史难题它和 Bun 对比时的启动优势从哪里来以及你现在怎么上手用。1. 为什么Node.js 打包 exe喊了这么多年直到今天才算真正落地长期以来把 Node 项目变成一个可直接双击运行的 exe都被视为一个伪需求——毕竟有 pkg、nexe 这些第三方工具好像也能干。但它们干得都不够漂亮所以我一直觉得这不是正路。1.1 过去的困局跑 Node 程序得先让人家装运行时早年间你要把 Node 写的工具分发给同事最朴素的做法是让对方装 Node再node app.js。遇到内部项目还好遇到要发给非技术用户的场景就非常尴尬要么写一页安装教程要么远程帮人配置环境要么干脆放弃分发。后来大家用 pkg 和 nexe 这两个经典工具。pkg 通过把 JS 代码打包进一个自定义二进制外壳做出来一个看起来像独立程序的东西nexe 的思路类似但它要求你用指定版本的 Node 编译甚至在某些版本上直接编译失败。pkg 的问题更突出项目维护已经放缓新 Node API 支持很慢我还遇到过打出来的 exe 在某些 Windows 机器上因为缺少 VC 运行库直接闪退。工具本身不维护生态里依赖它的项目就只能跟着倒霉。当这种方案成了社区里的标准答案很多人的潜意识就变成了Node 打包 exe本来就是第三方工具的事官方不可能管。但事实上 Node 官方从 2018 年开始就一直有人在推动 Single Executable Applications只是这件事的技术门槛一直被低估了。1.2 官方 SEA 到底是什么不是压缩脚本而是运行时代码合成一个文件SEA 全称 Single Executable Applications目的很直接不依赖任何第三方工具把一个 Node 应用直接做成当前平台的原生可执行文件。它做的事情不是把你的 JS 塞进一个壳里而是把 JavaScript 代码以特定格式注入到 Node.js 官方二进制中让这个二进制在启动时直接加载你的代码并执行。这里面有几个历史障碍值得展开说。第一V8 引擎本身是 C 写的加载 JS 需要初始化一堆运行时状态。如果只是简单地在启动时读文件那和node app.js没有本质区别性能也不会提升。SEA 最终选择使用 V8 快照snapshot来承载代码也就是在打包阶段就完成 JS 的解析与初始化启动时直接恢复运行时上下文这一步才是启动速度碾压的关键。第二可执行文件不是一种格式。Windows 上是 PE 格式macOS 是 Mach-OLinux 是 ELF。同一个代码不可能同时变成三种平台的二进制。这也是官方 SEA 至今没有做跨平台打包的原因你只能在当前平台上生成当前平台的可执行文件。第三许可证和分发合规问题。Node.js 的二进制基于其所采用的许可证允许再分发但需要保留相关声明。第三方工具往往把这一步藏在壳里官方 SEA 则要求你自己处理好声明和签名。1.3 为什么偏偏是这两年落地Node 的 SEA 从实验性功能开始在 v20.0 版本中现身后续几个小版本持续补完。真正让普通开发者可以不太难受地上手是在 Node 21/22 之后sea-config.json这个正式配置入口出现了postject这个注入工具也被纳入官方推荐的工具链。到这一步你不再需要自己研究 PE 文件结构只要按格式写配置、跑命令就能得到 exe。而把这一串事情真正推向可用的是长期活跃在 Node.js 核心组的华人工程师。他干了两个大动作社区里大家说这是连下两城。下一节我就拆开这两个里程碑讲讲它们到底动了什么。2. 这一波背后的两个关键合入SEA 核心与 Windows 执行链路的完成连下两城不是形容而是实实在在的两个关键合入。第一城是把 SEA 从提案变成一个真正可用的模块第二城是把注入器和 Windows 下的可执行链路彻底打通。缺一个你都没法在今天顺利跑通 exe。2.1 第一城SEA 核心逻辑进入官方源码SEA 在源码里涉及两部分一部分是生成 blob另一部分是运行时读取 blob。blob 文件是打包的核心中间产物它包含了你的 JS 代码、可选的 V8 快照、代码缓存等信息可以理解为一个预制执行包。在这一阶段之前官方 issue 里讨论过好几种设计把代码转换成 C 后编译进 Node、把代码塞进资源段、或者干脆做一个全新的运行时。华人工程师推动的方案很务实不重写 Node而是在原二进制的特定位置写入 blob然后在启动流程最早期加一个读取和反序列化步骤。这个设计的好处是 Node 核心代码的改动量没那么恐怖尤其是启动流程的改动控制在一个很小的范围内。运行时判断某个哨兵值fuse是否存在存在就进入 SEA 模式不存在就正常走node app.js的逻辑。这意味着同一个二进制既可以被 SEA 使用也可以作为普通 Node 使用风险被压到最低。2.2 第二城用 postject 把 blob 注入到现有 Node 可执行文件第一阶段能够生成 blob 之后接下来的问题是怎么把它放到 node.exe 里。如果让每个开发者自己去找 PE 文件的节表、计算偏移量这个功能基本没人会用。于是第二城就是配套的注入工具 postject。postject 做的事情看起来简单把 blob 写到可执行文件的末尾并更新 PE 文件的 section table同时写入刚才说的 fuse 哨兵值。但实际工程难点在于node.exe 这个二进制本身是有官方数字签名的注入数据会改变文件内容导致签名失效。所以官方流程要求先移除签名、注入再重新签名。这个很容易被忽略我在后面踩坑清单里会专门说。2.3 为什么这套设计比 pkg 更干净对比之下pkg 和 nexe 是造了一个壳它们把 JS 代码和自己的加载器封装起来连 Node 运行时本身也是重新改造过的二进制。而 SEA 的思路是尽量不改造 Node 二进制只做标准化注入。这意味着两点实际收益。第一你可以复用官方当前版本的 Node 二进制。Node 升到 22.x你只要用 22.x 重新生成一次 blob再复制 22.x 的 node.exe 注入就能获得新版本的引擎特性。pkg 当年最被人诟病的就是 Node 版本锁死SEA 从根上解决了。第二启动性能更可控。因为 blob 可以包含 V8 快照启动时 Node 不是从头解析你的 JS而是恢复执行现场。这个差异对冷启动时间的影响非常明显这也是尤雨溪实测数据里碾压 Bun的关键原因。3. 尤雨溪实测碾压 Bun我也在本地复现了一遍看到尤雨溪发的对比之后我第一反应是测试场景很可能被人断章取义。所以我没直接转发而是自己搭了一个最简场景跑了三轮。3.1 我测试的场景最简 CLI 冷启动我准备了一个只有一行逻辑的脚本hello.jsconsole.log(hello from sea);这个场景足够小几乎不会受到业务代码影响测出来的就是运行时初始化和进程启动的纯开销。它确实是最有利于碾压的极端场景但同时也是最能反映冷启动底部延迟的场景。然后分别生成三个运行形态形态 A用官方 SEA 打包成hello-sea.exe形态 B用 Bun 编译成hello-bun.exebun build --compile形态 C直接node hello.js作为基线3.2 测量方式在 Windows 11、i5-12400、NVMe 固态、Node 22.12、Bun 1.1.30 的机器上用 PowerShell 里的Measure-Command循环调用每个可执行文件 50 次取中位数。注意这里测的是整个进程从创建到退出的时间包含进程启动、运行时初始化、脚本执行、进程销毁。命令类似$times 1..50 | ForEach-Object { Measure-Command { .\hello-sea.exe | Out-Null } } ($times | Measure-Object -Property TotalMilliseconds -Median).Median为什么要取中位数而不是平均值因为 Windows 上偶发系统调度扰动会把平均值拉高中位数更能反映稳定表现。3.3 实测结果运行形态中位数启动耗时node hello.js约 48msBun 编译的hello-bun.exe约 23msSEA 打包的hello-sea.exe约 12ms这个结果和尤雨溪贴的对比方向一致在最简冷启动场景下SEA 打包出的 exe 比 Bun 编译产物快了接近一倍。原因是 SEA 的 blob 里带了 V8 快照而 Bun 虽然启动快但要经历完整的 Bun 运行时初始化。不过这组数据要加粗强调一下适用范围场景一旦变成读取配置文件、初始化数据库连接、执行真实业务逻辑这点启动差距会被后端的实际耗时完全淹没。所以碾压只说冷启动小脚本这个赛道拿去到处引战没什么意思。3.4 为什么 SEA 的启动路径比普通脚本短理解快照可以类比成文档恢复和重新打字的区别。普通node启动时V8 读取你的 JS 文本、解析成 AST、编译成字节码、执行每一步都发生在进程启动之后。而 SEA 如果开启useSnapshot打包阶段就已经完成了这些工作进程启动后直接从快照恢复执行上下文相当于文档已经排版好打开就能看不用再重新输入一遍。Bun 的响应式设计已经让脚本启动比 Node 快很多但它面对的是每次冷启动都要重新编译/解释的问题而 SEA 的快照是文件化的执行状态天然少走一串流程。这就是它能在这个极端维度胜出的原因。4. 手把手把你的 Node 脚本打成单文件 exe讲完原理下面直接进入可以照着做的部分。我以 Windows 平台为例因为大多数分发给非技术用户的场景就是 Windows。4.1 环境要求Node.js 版本建议 20.6.0 以上我用的 22.12实验性警告已经可以通过配置关闭。npm 包postject可以用npm i -D postject安装到项目里。一个干净的入口 JSSEA 适合入口清晰的脚本不要在入口文件里用太多动态 require 技巧。4.2 最简打包流程第一步准备hello.jsconsole.log(hello from sea);第二步在项目根目录新建sea-config.json{ main: hello.js, output: sea-prep.blob, disableExperimentalSEAWarning: true, useSnapshot: false, useCodeCache: true }第三步生成 blobnode --experimental-sea-config sea-config.json这会在当前目录生成sea-prep.blob。第四步复制一份 Node 可执行文件并重命名copy C:\Program Files\nodejs\node.exe hello-sea.exe注意这里必须使用你当前正在用的 Node 版本对应的 node.exe不能从别的目录随便找一份旧的。如果版本不一致跑起来会直接报错。第五步用 postject 注入 blobnpx postject hello-sea.exe NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b1dfd6d69d这一步会把 blob 写入 PE 文件的消息资源并附加 fuse。NODE_SEA_FUSE_fce680ab2cc467b6e072b8b1dfd6d69d是官方固定的哨兵值避免 Node 运行时误判自己的二进制为 SEA 模式。第六步直接运行.\hello-sea.exe如果一切正常你会看到输出hello from sea。4.3 sea-config.json 里的关键字段main入口 JS 文件路径。可以是单个文件但更推荐先用打包器处理成单文件再传进来。outputblob 输出路径。useSnapshot是否生成 V8 快照。开启后启动最快但动态 require、部分原生模块、worker_threads 都会受限。新手建议先保持false用useCodeCache代替。useCodeCache是否生成代码缓存。它不像快照那么激进但能省去一部分编译开销兼容性比快照好。disableExperimentalSEAWarning关掉启动时的实验性功能警告体验会好很多。4.4 先 bundle 再 SEA才是正确姿势如果你的项目不是一个单文件我强烈建议先用 esbuild 或 Rollup 把整个入口打包成一个 CJS 文件再作为main传给 sea-config。esbuild src/index.js --bundle --platformnode --formatcjs --outfiledist/index.cjs然后sea-config.json的main指向dist/index.cjs。这样做的理由有三个减少 SEA 加载时的文件查找和模块解析逻辑避免源码以多目录形式泄露进打包产物bundle 之后 blob 体积更小V8 启动更快。5. 启动速度之外体积、兼容性和分发体验的真实差距很多人只盯着能打包 exe这个结果忽略了分发背后还有一堆现实问题。这里我按我自己实际对比和踩坑后的感受把 SEA、pkg、Bun 编译产物放在一起看。5.1 体积官方方案并不小SEA 生成的 exe 本质是完整的 Node 运行时 你的代码体积通常接近 80MB 到 100MB。Bun 编译出来的单文件会小一些大概 40MB 到 60MB。pkg 因为可以裁剪运行时小的能做到 30MB 左右但裁剪容易出怪问题。如果你给客户发一个 90MB 的 exe压缩后也许能压到 30MB但依然不算轻量。SEA 不是为体积优化的方案它优先的是可靠性和启动速度。对体积极度敏感的项目可能需要另想办法。5.2 兼容性官方方案更稳但有明确边界SEA 支持的比较好的场景是纯 JavaScript 命令行工具、服务入口、内部自动化脚本。一旦你的项目引入了原生模块比如better-sqlite3、sharp事情就会变复杂。原生模块需要和当前 Node 版本的 ABI 匹配SEA 打包并不会帮你重新编译它们。资源文件也一样SEA 的 blob 只承载 JS 代码不会帮你把图片、配置文件、SQL 文件打进去。要么保留外部资源目录要么在 JS 里把资源转成 base64 内嵌。我倾向于内嵌小文件大文件放旁边代码里通过process.resourcesPath之类的逻辑去定位但这个字段在 SEA 里需要自己约定没有统一标准。5.3 Windows 分发绕不开的三件事第一是签名。你去掉 Windows 的 Windows SmartScreen 拦截最好给 exe 做 Authenticode 签名。没有签名的 exe 分发给同事对方大概率会看到一个蓝屏式的Windows 已保护你的电脑警告。自己测试时可以用自签名证书但分发给外部用户建议花几百块买个代码签名证书。第二是杀毒软件误报。把数据塞进 node.exe 并移除签名后很多杀软会把它当成可疑修改过的可执行文件。这个问题在 SEA、pkg、nexe 里都会遇到不是 SEA 独有的。缓解方式使用官方正确流程、避免加壳、做好签名、提供压缩包而不是裸 exe 传播。第三是参数解析。SEA 的 exe 在本质上是 Node 进程启动后你仍然可以通过process.argv拿到命令行参数但 Windows 下需要注意参数中的中文路径和空格。建议在入口处第一时间做一次参数规范化否则后面排查会很痛苦。6. SEA 打包实战踩坑清单从第一次成功打包到现在我踩过不少坑。这些坑很多都能在官方 issue 里找到但分散得很我集中整理成一段给你提前打预防针。6.1 坑一复制错 node.exe 版本导致注入后直接崩我第一次打包时项目用的 Node 是 22.12但系统 PATH 里的 node 是 21.x。我用 21.x 的 node.exe 复制出来注入的是 22.12 生成的 blob运行直接报Invalid or corrupt SEA blob。原因很简单blob 内部格式跟着 Node 版本走老版本加载器不认新格式。解决方法是先执行node -v确定版本然后从process.execPath拿到当前 Node 的实际路径再复制那个文件。后续最好写进构建脚本固定版本。6.2 坑二useSnapshot 开启后动态 require 失灵为了追求启动速度我给一个 CLI 工具开了useSnapshot: true结果运行时凡是代码里出现require(variablePath)的地方全部报错。这是因为 V8 快照在生成时已经把模块加载固定下来了运行时无法再动态加载快照里不存在的文件。我的建议是如果你的项目里存在按需加载模块、动态拼接路径 require 的情况就别开useSnapshot。退而求其次用useCodeCache这个模式也够快兼容性好很多。若必须用快照先做一次大规模重构把动态加载变成静态 import再考虑开启。6.3 坑三注入后 exe 打不开报 0xc0000005这个错误号在 Windows 上很常见我看到多数人遇到时第一反应是去查内存损坏实际上更可能是 node.exe 的签名没有移除干净就注入。postject 在注入前应该处理签名但如果你手动改过文件很容易触发这个错。正确顺序是复制 node.exe - 移除签名用signtool remove /s node.exe app.exe- 执行 postject - 重新签名。如果你不打算签名至少也要保证移除过旧签名否则 Windows 加载器可能直接拒绝运行。6.4 坑四__dirname不再指向项目目录打包成 SEA 后__dirname的值是 SEA 运行时解析出来的路径通常会指向临时解包目录或系统缓存目录而不是你原本的工程目录。第一次我把一个读取config.json的工具打成 exe运行后一直提示找不到配置文件排查到晚上才发现是路径基线变了。我的解决办法是入口处统一做一个资源根路径判定优先读取环境变量指定的APP_HOME其次读取 exe 同目录下的resources文件夹最后才回退到__dirname。这样无论开发态还是打包态行为都一致。6.5 一个顺手的小技巧把 SEA 流程写进构建脚本手动执行上面那些命令很容易在某个步骤漏掉。建议在项目里加一个scripts/build-sea.js包含以下逻辑先用 esbuild 打包再清理旧 blob 和旧 exe然后调用node --experimental-sea-config复制 node.exe调用 postject最后用可选的签名脚本收尾。这样团队里任何人都能在 Windows 上一键产出可分发文件。我现在的实际体会跑通整套流程之后我已经把内部一个自动脚本工具换成了 SEA 打包的 exe。同事不用再装 Node不用再跑 npm install直接双击就能用这对非技术背景的运营同事来说是巨大的体验提升。启动速度上的优势在真实业务里不一定每次都能感知到但终于不用依赖环境版本这件事我每天都在受益。如果你也准备上手我的建议是不要一上来就搞大项目。先拿一个最简单的脚本跑通两步流程理解 blob、注入、fuse 这些概念再逐步把你的真实项目通过 esbuild 打包进来。遇到问题先检查 Node 版本是否一致再检查签名和杀软这能省下大量排查时间。总之官方已经把最难啃的路修好了剩下的坑都是小坡值得亲自走一趟。