为什么我放弃了 esbuild,改用 typescript 包做运行时编译

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

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 一个容易被遗忘的不变量。

总结

这次选型的完整心路:

  1. 选 esbuild:被交叉打包的二进制污染和 asar spawn 限制劝退
  2. 选 esbuild-wasm:被隐藏的 spawn('node') 劝退,且错误只在用户机器上暴露
  3. 选 typescript:纯 JS、无 spawn、无 wasm、无 asar 路径问题,同步 API,小文件性能完全够

两条可复用的经验:

  • 评估一个要在客户端分发的依赖时,先看它的运行时模型:有没有原生二进制?有没有 spawn 子进程?有没有对环境(PATH、node、libc)的隐式假设?这些比 benchmark 数字重要得多。
  • 警惕"开发环境永远正常"的坑:所有依赖环境假设的问题,都只在打包后的干净机器上暴露。这类问题的验证成本,必须在选型阶段就计入。

打包至今,稳如老狗。


下一篇会讲脚本编译完之后的另一半故事:如何用 node:vm 给远程脚本造一个带 require 白名单的"沙箱"------以及,为什么它其实算不上真正的安全沙箱。

相关推荐
卸任5 小时前
Vibe coding工具使用心得
前端·ai编程
brien96 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance6 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M7 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写7 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李7 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易7 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马7 小时前
前沿视界
前端
kyriewen7 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易7 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员