为什么我放弃了 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 白名单的"沙箱"------以及,为什么它其实算不上真正的安全沙箱。

相关推荐
牧艺41 分钟前
cos-design 4.0:91 个特效组件一次捅成 React / Vue / Web Components / Core
前端·vue.js·web components
沙蒿同学41 分钟前
Wails v2 实战:用 Go + Vue3 做一个真正能用的 AI 桌面应用
前端·javascript·后端
Blanche150042 分钟前
优化 RAG 应用提升问答准确度
前端
颜进强42 分钟前
09 · NestJS Middleware 中间件:链路最外层那个"最像 Express"的家伙
前端·后端·ai编程
创新技术阁42 分钟前
FastapiAdmin 实战:演示模式开关失效的排查记录
前端·后端·fastapi
拖孩42 分钟前
一个人 + AI 做的小程序,一个半月把服务器钱赚回来一半了
前端·后端·微信小程序
风骏时光牛马42 分钟前
后端服务接口开发与业务逻辑实现
前端
编程老船长43 分钟前
权限不只是菜单按钮——QuickBlue 的 RBAC 与行级数据权限是怎么落地的
java·前端·后端
子兮曰5 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent