快并不等于适合分发:真正的选择发生在构建性能与用户机器上的运行稳定性之间。

Electron 打包分发场景下的一次工具选型翻车实录。结论先行:在"要在用户机器上跑"的场景里,工具链的分发成本远比构建性能重要。那个最重、最慢、最"笨"的官方编译器,反而是最稳的选择。
背景:我要在 Electron 主进程里执行远程下发的 TS 脚本
我们的桌面端有一个"官网投递"功能:用户在企业招聘官网(各类 ATS 招聘系统)上投简历,表单结构千奇百怪,而且网站随时可能改版。
如果把填表逻辑写死在客户端里,网站每改一次版,我们就得发一次客户端版本------这显然不现实。所以架构上做了一个关键决策:
表单扫描(scan)和填充(fill)逻辑以 TypeScript 源码的形式由服务端动态下发,客户端在运行时编译并执行。
流程大致是:
arduino
服务端返回 { scan: "...ts源码", fill: "...ts源码" }
↓
客户端编译 TS → JS
↓
vm 沙箱中加载执行(准确说是受限执行上下文,"沙箱"细节是另一篇的话题)
↓
注入 runtime(page/context/log),驱动 Playwright 操作浏览器
于是问题只剩一个:在 Electron 主进程的运行时环境里,用什么把 TS 编译成 JS?
第一直觉当然是 esbuild。快,现代,社区标配。然后我就开始踩坑了。
三次失败看似互不相关,根因却一致:方案依赖了安装包之外的原生二进制、进程或运行环境。
第一坑:交叉打包时原生二进制互相污染
我们的发布矩阵是:Windows x64(NSIS 安装包 + 便携版)、macOS arm64、macOS x64,且打包机是一台 Mac。
esbuild 的核心是 Go 编译的原生二进制,按平台拆成 @esbuild/darwin-arm64、@esbuild/win32-x64 这样的 optional dependencies。在 Mac 上打包 Windows 安装包时,必须让 Windows 版的 esbuild 二进制也进包。
常规做法是 npm install --os=win32 --cpu=x64 esbuild 强制装 Windows 版的二进制。但这个操作的副作用非常阴间:
- 它会改写 node_modules 里的 optional 依赖解析结果,装完 Windows 版,macOS 版的 binary 可能就被顶掉了
- 接下来你再打 Mac 包,要么失败,要么打出来的是带着 Windows 二进制的畸形包
- 想恢复?删 node_modules 重装。两个平台来回切换打包,等于反复横跳反复重装
构建脚本开始变得像玄学仪式:打 Windows 包之前要做一件事,打完要记得做另一件事复原。这种"顺序敏感的构建流程"就是定时炸弹。
第二坑:asar 里 spawn 不了原生可执行文件
好,假设二进制的问题解决了,下一个问题更本质:
esbuild 的 JS API 底层是 spawn 一个原生子进程 (esbuild 可执行文件),通过 stdin/stdout 通信。
而 electron-builder 默认把源码打进 asar 归档。asar 是个虚拟文件系统,Node 的 fs 能读它,但操作系统的进程加载器不认它------你没法从 asar 里 spawn 一个可执行文件。
官方药方是 asarUnpack 把二进制拆到 asar 外面。我们试了,但在"多平台 optional dependencies + 交叉打包"的叠加场景下没能彻底兜住:要么 unpack 的路径在目标平台上对不上,要么某个平台的 binary 压根没进包。每次打包都像开盲盒,开出来运行时报错才知道少了什么。
到这一步我意识到:问题的根源不是配置没调对,而是**"原生二进制 + 子进程"这个模型和"Electron 打包分发"天然八字不合**。
第三坑:esbuild-wasm 是个伪装者
原生二进制走不通,那用 wasm 版本总行吧?esbuild-wasm 纯 WebAssembly,理论上没有原生依赖。
实测发现它是个伪装者:esbuild-wasm 的 Node 入口会 spawn 一个字面量为 "node" 的子进程来跑它的 wasm 运行时。
开发环境下完全无感------你机器上当然装着 node。但 Electron 打包后的用户机器上呢?用户没装 Node.js,PATH 里哪来的 node?
于是生产环境直接报 EOF------子进程 spawn 失败,通信管道立刻断开。这个错误信息毫无指向性,排查了半天才定位到是 wasm 版在偷偷 spawn node。
顺带一提,这个问题的隐蔽性在于:开发环境永远是好的。只有打包后的安装包在干净机器上才炸。这类"开发/生产环境不对称"的坑,是最贵的坑。
最终方案:typescript 包,"最笨"但最稳
踩完这三个坑,我回过头看需求本身:
- 要编译的文件很小(服务端下发的单文件脚本,几百行级别)
- 只需要类型剥离 + 转译,不需要完整类型检查(类型检查在脚本作者那边做)
- 不需要 tree-shaking、不需要打包、不需要 sourcemap
- 要在任意用户的机器上、asar 内、无 Node 环境稳定运行
这个需求画像下,typescript 官方包反而是完美匹配:
js
import ts from 'typescript'
function compileTsToCjs(source, filename) {
const result = ts.transpileModule(source, {
fileName: filename,
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
allowJs: true,
isolatedModules: true, // 单文件转换语义,跟 esbuild 行为对齐
skipLibCheck: true,
},
reportDiagnostics: false, // 仅做类型剥离,不做完整类型检查
})
return result.outputText
}

最终链路保持在应用进程内:TypeScript 转为 CommonJS,再进入能力受控的执行上下文,不再依赖原生可执行文件或额外子进程。
它赢在哪:
| 维度 | esbuild | esbuild-wasm | typescript |
|---|---|---|---|
| 原生二进制 | 有(按平台分发) | 无(但 spawn node) | 无,纯 JS |
| spawn 子进程 | 是 | 是 | 否,同步 API |
| asar 内可用 | 需 unpack 且不稳 | 否 | 直接可用 |
| 交叉打包 | 坑 | 坑 | 零感知 |
| 速度 | 极快 | 快 | 慢(但小文件无感) |
transpileModule 是同步 API,调用即返回。对几百行的脚本来说,编译耗时是毫秒级------用户点击"投递"到浏览器弹出的耗时里,它连零头都算不上。
性能是理论上的劣势,但在我们的真实场景里根本不是瓶颈;分发稳定性是理论上的优势,却是每天都可能炸的生死线。
附赠的一个坑:vm 沙箱里的 exports 同源问题
换编译器还顺带踩了个小坑,值得单独说。
我们的脚本最终加载进 node:vm 的沙箱执行,沙箱里要模拟 CommonJS 的 module / exports。一开始这么写的:
js
const sandbox = {
module: { exports: {} },
exports: {}, // ← 和 module.exports 不是同一个对象!
}
vm.createContext(sandbox)
vm.runInContext(compiledCode, sandbox)
结果脚本加载完,module.exports 里空空如也,拿不到 default 导出。
原因在于两种编译器的产物风格不同:
- esbuild 输出
module.exports = __toCommonJS({...})------整体重新赋值module.exports,exports是不是同源无所谓 - typescript 输出
exports.default = scan------只改exports的属性,依赖exports === module.exports这个 Node CJS 的默认约定
沙箱里两个对象不同源,TS 产物的 exports.default = ... 就写到了空气里。修复只要一行:
js
const moduleObj = { exports: {} }
const sandbox = {
module: moduleObj,
exports: moduleObj.exports, // ← 同源,模拟真实 Node CJS 行为
}
这个坑的教训是:模拟一个模块系统时,必须模拟它的全部不变量 。exports 和 module.exports 同源,就是 Node CJS 一个容易被遗忘的不变量。
总结
这次选型的完整心路:
- 选 esbuild:被交叉打包的二进制污染和 asar spawn 限制劝退
- 选 esbuild-wasm:被隐藏的
spawn('node')劝退,且错误只在用户机器上暴露 - 选 typescript:纯 JS、无 spawn、无 wasm、无 asar 路径问题,同步 API,小文件性能完全够
两条可复用的经验:
- 评估一个要在客户端分发的依赖时,先看它的运行时模型:有没有原生二进制?有没有 spawn 子进程?有没有对环境(PATH、node、libc)的隐式假设?这些比 benchmark 数字重要得多。
- 警惕"开发环境永远正常"的坑:所有依赖环境假设的问题,都只在打包后的干净机器上暴露。这类问题的验证成本,必须在选型阶段就计入。
打包至今,稳如老狗。
下一篇会讲脚本编译完之后的另一半故事:如何用
node:vm给远程脚本造一个带 require 白名单的"沙箱"------以及,为什么它其实算不上真正的安全沙箱。