Skip to main content
字节码缓存是一种构建时优化,通过将 JavaScript 预编译为字节码来改善启动时间。例如,在启用字节码的情况下编译 TypeScript 的 tsc 时,启动时间提升了 2 倍

用法

基本用法(CommonJS)

使用 --bytecode 标志启用字节码缓存。未指定 --format 时,输出格式默认为 CommonJS:
terminal
构建会写入两个文件:
  • dist/index.js - 打包后的 JavaScript(CommonJS)
  • dist/index.js.jsc - 字节码缓存文件
运行时,Bun 会自动检测并使用 .jsc 文件:
terminal

使用独立可执行文件

当你使用 --compile 创建可执行文件时,Bun 将字节码嵌入到二进制文件中。ESM 和 CommonJS 都支持:
terminal
生成的可执行文件同时包含代码和字节码。

ESM 字节码

ESM 字节码需要 --compile,因为 Bun 将模块元数据(导入/导出信息)嵌入到编译后的二进制文件中。有了这些元数据,JavaScript 引擎在运行时可以完全跳过解析。 没有 --compile 时,ESM 字节码仍然需要解析源代码以分析模块依赖关系,这就违背了字节码缓存的目的。

与其他优化结合使用

将字节码与压缩和 source map 结合使用:
terminal
  • --minify 在生成字节码之前减少代码体积(更少的代码 -> 更少的字节码)
  • --sourcemap 保留错误报告(错误仍然指向原始源代码)
  • --bytecode 消除解析开销

性能影响

性能提升随你的代码库规模而扩展: 越大的应用受益越多,因为它们有更多代码需要解析。

何时使用字节码

非常适合:

CLI 工具

  • 频繁调用(linter、格式化工具、Git hooks)
  • 启动时间就是整个用户体验
  • 用户能察觉 90ms 和 45ms 启动的差异
  • 示例:TypeScript 编译器、Prettier、ESLint

构建工具和任务运行器

  • 开发期间运行成百上千次
  • 每次节省的毫秒数快速累积
  • 改善开发者体验
  • 示例:构建脚本、测试运行器、代码生成器

独立可执行文件

  • 分发给关心性能的用户
  • 单文件分发很方便
  • 文件大小不如启动时间重要
  • 示例:通过 npm 或作为二进制分发的 CLI

跳过它,当:

  • 小型脚本
  • 只运行一次的代码
  • 开发构建
  • 对大小敏感的环境

限制

版本兼容性

字节码在不同 Bun 版本之间不可移植。字节码格式与 JavaScriptCore 的内部表示绑定,该表示在不同版本之间会变化。 当你更新 Bun 时,必须重新生成字节码:
terminal
如果字节码与当前 Bun 版本不匹配,Bun 会忽略它并回退到解析 JavaScript 源代码。 最佳实践:在 CI/CD 构建过程中生成字节码。不要将 .jsc 文件提交到 git。每次更新 Bun 时重新生成它们。

仍然需要源代码

字节码不会替代你的 JavaScript。你必须同时部署两个文件:
  • .js 文件(打包后的源代码)
  • .jsc 文件(字节码缓存)
运行时:
  1. Bun 加载 .js 文件,看到 @bytecode 编译指示,检查 .jsc 文件
  2. Bun 加载 .jsc 文件
  3. Bun 验证字节码哈希与源代码匹配
  4. 如果有效,Bun 使用字节码
  5. 如果无效,Bun 回退到解析源代码

字节码不是混淆

字节码不会模糊你的源代码。它是一种优化,而非安全措施。

生产部署

Docker

在你的 Dockerfile 中包含字节码生成:
Dockerfile
字节码与架构无关。

CI/CD

在构建流水线期间生成字节码:
workflow.yml

调试

验证正在使用字节码

检查 .jsc 文件是否存在:
terminal
.jsc 文件应该比 .js 文件大 2-8 倍。 要记录是否使用了字节码,设置环境变量 BUN_JSC_verboseDiskCache=1 缓存命中时,Bun 会记录:
缓存未命中时:
几条缓存未命中的日志是正常的:Bun 不会对其内置模块中的 JavaScript 进行字节码缓存。

常见问题

字节码被静默忽略:通常是由 Bun 版本更新引起的。缓存版本不匹配,因此字节码被拒绝。重新生成即可修复。 文件体积过大:这是预期的。考虑以下方式:
  • 使用 --minify 在字节码生成前减少代码体积
  • 为网络传输压缩 .jsc 文件(gzip/brotli)
  • 评估启动增益是否值得体积增加

什么是字节码?

当你运行 JavaScript 时,JavaScript 引擎不会直接执行你的源代码。相反,它会经历几个步骤:
  1. 解析:引擎读取你的 JavaScript 源代码并将其转换为抽象语法树(AST)
  2. 字节码编译:AST 被编译为字节码——一个较低层次的表示,执行速度更快
  3. 执行:字节码由引擎的解释器或 JIT 编译器执行
字节码是一种中间表示——它比 JavaScript 源代码层次更低,但比机器码层次更高。可以把它理解为虚拟机的汇编语言。每条字节码指令代表一个单一的操作,如”加载这个变量”、“将两个数相加”或”调用这个函数”。 所有这一切每次运行你的代码时都会发生。一个每天运行 100 次的 CLI 工具会被解析 100 次;一个 serverless 函数在每次冷启动时都会被解析。 有了字节码缓存,Bun 将步骤 1 和 2 移到了构建步骤。运行时,引擎加载预编译的字节码并直接进入执行阶段。

为什么延迟解析让它变得更好

现代 JavaScript 引擎使用一种称为延迟解析的优化。它们不会一次性解析所有代码——相反,函数只在首次被调用时才被解析:
这意味着解析开销不仅仅是启动时的成本——它在应用程序的整个生命周期中,当不同的代码路径被执行时都会发生。有了字节码缓存,所有函数都被预编译,即使是引擎原本会延迟解析的那些函数。

字节码格式

.jsc 文件内部

.jsc 文件包含序列化的字节码结构。 头部部分(每次加载时验证):
  • 缓存版本:与 JavaScriptCore 框架版本绑定的哈希值。这确保一个 Bun 版本生成的字节码只能在该精确版本上运行。
  • 代码块类型标签:标识这是 Program、Module、Eval 还是 Function 代码块。
SourceCodeKey(验证字节码与源代码匹配):
  • 源代码哈希:原始 JavaScript 源代码的哈希值。Bun 在使用字节码前验证其匹配。
  • 源代码长度:源代码的确切长度,用于额外验证。
  • 编译标志:编译上下文,如严格模式、脚本 vs 模块、eval 上下文类型。相同的源代码使用不同的标志编译会产生不同的字节码。
字节码指令
  • 指令流:字节码操作码——你的 JavaScript 的编译表示,以变长指令序列存储。
  • 元数据表:每个操作码关联的元数据,如性能计数器、类型提示和执行计数(即使尚未填充)。
  • 跳转目标:控制流的预计算地址(if/else、循环、switch 语句)。
  • Switch 表:switch 语句的优化查找表。
常量和标识符
  • 常量池:代码中的所有字面值——数字、字符串、布尔值、null、undefined。它们存储为 JavaScript 值(JSValues),这样就不需要在运行时从源代码解析。
  • 标识符表:代码中使用的所有变量和函数名。存储为去重后的字符串。
  • 源代码表示标记:指示常量应如何表示的标志(整数、双精度浮点数、大整数等)。
函数元数据(代码中的每个函数):
  • 寄存器分配:函数需要的寄存器(局部变量)数量——thisRegisterscopeRegisternumVarsnumCalleeLocalsnumParameters
  • 代码特性:函数特征的位掩码:它是构造函数吗?箭头函数?是否使用 super?是否有尾调用?这些影响函数的执行方式。
  • 词法作用域特性:严格模式和其他词法上下文。
  • 解析模式:函数被解析的模式(普通、async、generator、async generator)。
嵌套结构
  • 函数声明和表达式:每个嵌套函数递归获得自己的字节码块。一个有 100 个函数的文件有 100 个独立的字节码块,全部嵌套在结构中。
  • 异常处理器:try/catch/finally 块及其预计算的边界和处理程序地址。
  • 表达式信息:将字节码位置映射回源代码位置,用于错误报告和调试。

字节码中不包含什么

字节码不嵌入你的源代码。相反:
  • JavaScript 源代码单独存储(在 .js 文件中)
  • 字节码只存储源代码的哈希值和长度
  • 加载时,Bun 验证字节码与当前源代码匹配
这就是为什么你需要同时部署 .js.jsc 文件:没有对应的 .js 文件,.jsc 文件是无用的。

权衡:文件大小

字节码文件通常比源代码大 2-8 倍。

为什么字节码要大得多?

字节码指令很冗长: 一行压缩后的 JavaScript 可能编译成数十条字节码指令。例如:
编译成字节码需要:
  • 加载 arr 变量
  • 获取 reduce 属性
  • 创建箭头函数(其本身也有字节码)
  • 加载初始值 0
  • 使用正确数量的参数设置调用
  • 实际执行调用
  • 将结果存储到 sum
这些步骤中的每一步都是一条独立的字节码指令,带有自己的元数据。 常量池存储所有内容: 每个字符串字面量、数字、属性名——所有内容都存储在常量池中。即使你的源代码有 "hello" 一百次,常量池也只存储一次,但标识符表和常量引用会增加开销。 每个函数的元数据: 每个函数——即使是一行的小函数——都有自己完整的元数据:
  • 寄存器分配信息
  • 代码特性位掩码
  • 解析模式
  • 异常处理器
  • 用于调试的表达式信息
一个有 1,000 个小函数的文件就有 1,000 组元数据。 性能分析数据结构: 即使性能分析数据尚未填充,承载这些数据的_结构_已经被分配。这包括:
  • 值分析槽(跟踪每个操作流经的类型)
  • 数组分析槽(跟踪数组访问模式)
  • 二元算术分析槽(跟踪数学运算中的数字类型)
  • 一元算术分析槽
即使是空的,它们也占用空间。 预计算的控制流: 跳转目标、switch 表和异常处理程序边界都被预计算并存储。这使得执行更快,但增加了文件大小。

缓解策略

压缩: 字节码用 gzip/brotli 压缩效果很好(60-70% 压缩率)。重复的结构和元数据能被高效压缩。 先压缩: 在生成字节码之前使用 --minify 有助于:
  • 更短的标识符 → 更小的标识符表
  • 死代码消除 → 生成更少的字节码
  • 常量折叠 → 池中更少的常量
权衡: 你是在用 2-4 倍的文件大小换取 2-4 倍的启动速度。对于 CLI 来说,这通常是值得的。对于数兆字节磁盘空间不重要的长时间运行的服务器来说,这就更不是问题了。

版本控制与可移植性

跨架构可移植性:✅

字节码是架构无关的。你可以:
  • 在 macOS ARM64 上构建,部署到 Linux x64
  • 在 Linux x64 上构建,部署到 AWS Lambda ARM64
  • 在 Windows x64 上构建,部署到 macOS ARM64
字节码包含在任何架构上都能工作的抽象指令。架构特定的优化在运行时的 JIT 编译中发生,而不是在缓存的字节码中。

跨版本可移植性:❌

字节码在不同 Bun 版本之间不稳定。原因如下: 字节码格式变化: JavaScriptCore 的字节码格式在各个版本之间会变化。新的操作码被添加,旧的被移除或改变,元数据结构也会变化。 版本验证.jsc 文件头中的缓存版本是 JavaScriptCore 框架的哈希值。当 Bun 加载字节码时:
  1. 它从 .jsc 文件中提取缓存版本
  2. 它计算当前的 JavaScriptCore 版本
  3. 如果它们不匹配,字节码会被静默拒绝
  4. Bun 回退到解析 .js 源代码
优雅降级: 这种设计意味着字节码缓存”故障打开”——如果出现问题(版本不匹配、文件损坏、文件丢失),你的代码仍然正常运行。你可能会看到启动变慢,但不会看到错误。

未链接 vs 链接字节码

JavaScriptCore 区分”未链接”和”链接”字节码。这种分离使得字节码缓存成为可能:

未链接字节码(被缓存的)

保存在 .jsc 文件中的字节码是未链接字节码。它包含:
  • 编译后的字节码指令
  • 代码的结构信息
  • 常量和标识符
  • 控制流信息
但它不包含
  • 指向实际运行时对象的指针
  • JIT 编译后的机器码
  • 之前运行时的性能分析数据
  • 调用链接信息(哪个函数调用了哪个)
未链接字节码是不可变且可共享的。同一代码的多次执行都可以引用相同的未链接字节码。

链接字节码(运行时执行)

当 Bun 运行字节码时,它会”链接”它——创建一个运行时包装器,添加:
  • 调用链接信息:随着代码运行,引擎会了解哪些函数互相调用,并优化这些调用点。
  • 性能分析数据:引擎跟踪每条指令执行了多少次、流经代码的值类型、数组访问模式等。
  • JIT 编译状态:对热点代码的 baseline JIT 或优化 JIT(DFG/FTL)编译版本的引用。
  • 运行时对象:指向实际 JavaScript 对象、原型、作用域等的指针。
这种链接表示每次运行代码时都会重新创建。这种分离允许:
  1. 缓存昂贵的工作(解析和编译为未链接字节码)
  2. 仍然收集运行时性能分析数据以指导优化
  3. 仍然应用 JIT 优化基于实际的执行模式
对于生产环境的 CLI 和 serverless 部署,--bytecode --minify --sourcemap 的组合为你提供了最佳的启动时间,同时保持错误映射到原始源代码。