Skip to main content
字节码缓存是一种构建时优化,通过将JavaScript预编译为字节码来显著提高应用程序启动时间。例如,启用字节码编译TypeScript的tsc时,启动时间提高了2倍

用法

基本用法

使用--bytecode标志启用字节码缓存:
terminal
bun build ./index.ts --target=bun --bytecode --outdir=./dist
这将生成两个文件:
  • dist/index.js - 您的打包JavaScript
  • dist/index.jsc - 字节码缓存文件
在运行时,Bun自动检测并使用.jsc文件:
terminal
bun ./dist/index.js  # 自动使用index.jsc

与独立可执行文件结合

使用--compile创建可执行文件时,字节码嵌入到二进制文件中:
terminal
bun build ./cli.ts --compile --bytecode --outfile=mycli
生成的可执行文件包含代码和字节码,为您提供单个文件的最大性能。

与其他优化结合

字节码与压缩和源映射配合得很好:
terminal
bun build --compile --bytecode --minify --sourcemap ./cli.ts --outfile=mycli
  • --minify在生成字节码之前减小代码大小(更少代码 -> 更少字节码)
  • --sourcemap保留错误报告(错误仍指向原始源码)
  • --bytecode消除解析开销

性能影响

性能提升随代码库大小而增加:
应用程序大小典型启动改进
小型CLI (< 100 KB)快1.5-2倍
中大型应用 (> 5 MB)快2.5x-4x倍
较大的应用程序受益更多,因为它们有更多代码需要解析。

何时使用字节码

非常适合:

CLI工具

  • 频繁调用(linters、formatters、git hooks)
  • 启动时间就是整个用户体验
  • 用户会注意到90毫秒和45毫秒启动时间的差异
  • 示例:TypeScript编译器、Prettier、ESLint

构建工具和任务运行器

  • 在开发过程中运行数百或数千次
  • 每次运行节省的毫秒数迅速累积
  • 提升开发者体验
  • 示例:构建脚本、测试运行器、代码生成器

独立可执行文件

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

跳过的情况:

  • 小型脚本
  • 一次运行的代码
  • 开发构建
  • 空间受限的环境
  • 具有顶级await的代码(不支持)

限制

仅CommonJS

字节码缓存当前仅适用于CommonJS输出格式。Bun的打包器会自动将大多数ESM代码转换为CommonJS,但顶级await是个例外:
// 这会阻止字节码缓存
const data = await fetch("https://api.example.com");
export default data;
原因: 顶级await需要异步模块评估,这在CommonJS中无法表示。模块图变成异步的,CommonJS包装函数模型失效。 解决方法: 将异步初始化移到函数中:
async function init() {
  const data = await fetch("https://api.example.com");
  return data;
}

export default init;
现在模块导出一个消费者可以在需要时等待的函数。

版本兼容性

字节码不能跨Bun版本移植。字节码格式与JavaScriptCore的内部表示绑定,版本之间会发生变化。 更新Bun时,必须重新生成字节码:
terminal
# 更新Bun后
bun build --bytecode ./index.ts --outdir=./dist
如果字节码与当前Bun版本不匹配,则会自动忽略,代码回退到解析JavaScript源码。您的应用程序仍然运行 - 只是失去了性能优化。 最佳实践: 将字节码生成作为CI/CD构建过程的一部分。不要将.jsc文件提交到git。每当更新Bun时重新生成它们。

仍需要源代码

  • [.js]文件(您的打包源代码)
  • [.jsc]文件(字节码缓存)
在运行时:
  1. Bun加载[.js]文件,看到@bytecode指令,并检查[.jsc]文件
  2. Bun加载[.jsc]文件
  3. Bun验证字节码哈希与源码匹配
  4. 如果有效,Bun使用字节码
  5. 如果无效,Bun回退到解析源码

字节码不是混淆

字节码不会隐藏您的源代码。它是一种优化,而不是安全措施。

生产部署

Docker

在Dockerfile中包含字节码生成:
Dockerfile
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .
RUN bun build --bytecode --minify --sourcemap \
  --target=bun \
  --outdir=./dist \
  --compile \
  ./src/server.ts --outfile=./dist/server

FROM oven/bun:1 AS runner
WORKDIR /app
COPY --from=builder /dist/server /app/server
CMD ["./server"]
字节码是与架构无关的。

CI/CD

在构建管道期间生成字节码:
workflow.yml
# GitHub Actions
- name: Build with bytecode
  run: |
    bun install
    bun build --bytecode --minify \
      --outdir=./dist \
      --target=bun \
      ./src/index.ts

调试

验证字节码是否正在使用

检查[.jsc]文件是否存在:
terminal
ls -lh dist/
-rw-r--r--  1 user  staff   245K  index.js
-rw-r--r-- 1 user  staff   1.1M  index.jsc
[.jsc]文件应该是[.js]文件的2-8倍大。 要记录是否正在使用字节码,请在环境中设置BUN_JSC_verboseDiskCache=1 成功时,它将记录如下内容:
[Disk cache] cache hit for sourceCode
如果看到缓存未命中,它将记录如下内容:
[Disk cache] cache miss for sourceCode
由于Bun目前不会对内置模块中使用的JavaScript代码进行字节码缓存,多次记录缓存未命中是正常的。

常见问题

字节码被静默忽略: 通常是由Bun版本更新引起的。缓存版本不匹配,因此字节码被拒绝。重新生成即可修复。 文件太大: 这是预期的。考虑:
  • 使用--minify在字节码生成前减小代码大小
  • 压缩[.jsc]文件以进行网络传输(gzip/brotli)
  • 评估启动性能增益是否值得大小增加
顶级await: 不支持。重构为使用异步初始化函数。

什么是字节码?

运行JavaScript时,JavaScript引擎不会直接执行源代码。相反,它经历几个步骤:
  1. 解析: 引擎读取JavaScript源代码并将其转换为抽象语法树(AST)
  2. 字节码编译: AST编译为字节码 - 一种更低级的表示,执行速度更快
  3. 执行: 字节码由引擎的解释器或JIT编译器执行
字节码是一种中间表示 - 它比JavaScript源代码更低级,但比机器代码更高级。将其视为虚拟机的汇编语言。每个字节码指令代表一个单一操作,如”加载此变量”、“加两个数字”或”调用此函数”。 这发生在每次运行代码时。如果您有一个每天运行100次的CLI工具,代码会被解析100次。如果您有一个频繁冷启动的无服务器函数,每次冷启动都会发生解析。 通过字节码缓存,Bun将步骤1和2移到构建步骤。在运行时,引擎加载预编译的字节码并直接跳转到执行。

为什么延迟解析使其更好

现代JavaScript引擎使用一种称为延迟解析的巧妙优化。它们不会预先解析所有代码 - 相反,只有在首次调用时才解析函数:
// 没有字节码缓存:
function rarely_used() {
  // 这个500行函数只在实际调用时才解析
}

function main() {
  console.log("Starting app");
  // rarely_used()从未被调用,因此从未解析
}
这意味着解析开销不仅仅是启动成本 - 当不同代码路径执行时,它在整个应用程序生命周期中发生。通过字节码缓存,所有函数都预编译,即使是延迟解析的函数。解析工作在构建时发生一次,而不是分布在应用程序执行过程中。

字节码格式

[.jsc]文件内部

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

字节码不包含的内容

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

权衡: 文件大小

字节码文件明显大于源代码 - 通常大2-8倍。

为什么字节码如此大?

字节码指令很冗长: 一行压缩的JavaScript可能编译为几十个字节码指令。例如:
const sum = arr.reduce((a, b) => a + b, 0);
编译为字节码执行:
  • 加载arr变量
  • 获取reduce属性
  • 创建箭头函数(本身有字节码)
  • 加载初始值0
  • 设置具有正确参数数量的调用
  • 实际执行调用
  • 将结果存储在sum
每一步都是一个单独的带自己元数据的字节码指令。 常量池存储一切: 每个字符串字面值、数字、属性名 - 一切都存储在常量池中。即使源代码中有100次出现的”hello”,常量池也只存储一次,但标识符表和常量引用增加了开销。 每函数元数据: 每个函数 - 即使是小的一行函数 - 都有自己的完整元数据:
  • 寄存器分配信息
  • 代码特性位掩码
  • 解析模式
  • 异常处理程序
  • 用于调试的表达式信息
包含1000个小函数的文件有1000组元数据。 分析数据结构: 即使分析数据尚未填充,保存分析数据的_结构_也被分配了。这包括:
  • 值分析槽(跟踪每种操作中流动的类型)
  • 数组分析槽(跟踪数组访问模式)
  • 二元算术分析槽(跟踪数学运算中的数字类型)
  • 一元算术分析槽
即使为空,这些也会占用空间。 预计算的控制流: 跳转目标、switch表和异常处理程序边界都预先计算和存储。这使执行更快,但增加了文件大小。

缓解策略

压缩: 字节码通过gzip/brotli压缩效果极佳(60-70%压缩率)。重复的结构和元数据压缩效率高。 先压缩: 在字节码生成前使用--minify有帮助:
  • 较短的标识符 → 较小的标识符表
  • 死代码消除 → 生成较少的字节码
  • 常量折叠 → 池中较少的常量
权衡: 您正在用2-4倍大的文件换取2-4倍快的启动。对于CLIs,这通常是值得的。对于不在意几兆字节磁盘空间的长时间运行服务器,这更是小问题。

版本和可移植性

跨架构可移植性: ✅

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

跨版本可移植性: ❌

字节码在Bun版本间不稳定。原因如下: 字节码格式变化: JavaScriptCore的字节码格式在演变。添加新操作码,删除旧操作码或更改旧操作码,元数据结构改变。JavaScriptCore的每个版本都有不同的字节码格式。 版本验证: [.jsc]文件头中的缓存版本是JavaScriptCore框架的哈希。当Bun加载字节码时:
  1. 它从[.jsc]文件提取缓存版本
  2. 它计算当前JavaScriptCore版本
  3. 如果不匹配,字节码被静默拒绝
  4. Bun回退到解析[.js]源代码
您的应用程序仍然运行 - 您只是失去了性能优化。 优雅降级: 这种设计意味着字节码缓存”失败开放” - 如果出现问题(版本不匹配、损坏的文件、缺失的文件),您的代码仍正常运行。您可能看到较慢的启动,但不会看到错误。

未链接vs已链接字节码

JavaScriptCore在”未链接”和”已链接”字节码之间做出重要区分。这种分离使得字节码缓存成为可能:

未链接字节码(缓存的)

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

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

当Bun运行字节码时,它”链接”它 - 创建一个运行时包装器,添加:
  • 调用链接信息: 随着代码运行,引擎了解哪些函数调用哪些,并优化那些调用站点。
  • 分析数据: 引擎跟踪每个指令执行多少次,什么类型的值流经代码,数组访问模式等。
  • JIT编译状态: 对热代码的基础JIT或优化JIT(DFG/FTL)编译版本的引用。
  • 运行时对象: 指向实际JavaScript对象、原型、作用域等的指针。
此链接表示在每次运行代码时都新建。这允许:
  1. 缓存昂贵的工作(解析和编译为未链接字节码)
  2. 仍收集运行时分析数据以指导优化
  3. 仍应用JIT优化基于实际执行模式
字节码缓存将昂贵的工作(解析和编译为字节码)从运行时移到构建时。对于频繁启动的应用程序,这可以将启动时间减半,代价是磁盘上的文件更大。 对于生产CLIs和无服务器部署,--bytecode --minify --sourcemap的组合为您提供最佳性能,同时保持可调试性。