字节码缓存是一种构建时优化,通过将 JavaScript 预编译为字节码来显著提高应用程序启动时间。例如,当使用字节码编译 TypeScript 的 tsc 时,启动时间可提高 2 倍。
用法
基本用法
使用 --bytecode 标志启用字节码缓存:
bun build ./index.ts --target=bun --bytecode --outdir=./dist这将生成两个文件:
dist/index.js- 打包的 JavaScriptdist/index.jsc- 字节码缓存文件
在运行时,Bun 会自动检测并使用 .jsc 文件:
bun ./dist/index.js # 自动使用 index.jsc使用独立可执行文件
使用 --compile 创建可执行文件时,字节码会嵌入到二进制文件中:
bun build ./cli.ts --compile --bytecode --outfile=mycli生成的可执行文件包含代码和字节码,在单个文件中提供最大性能。
与其他优化结合
字节码与压缩和 source map 配合良好:
bun build --compile --bytecode --minify --sourcemap ./cli.ts --outfile=mycli--minify在生成字节码之前减少代码大小(更少的代码 -> 更少的字节码)--sourcemap保留错误报告(错误仍然指向原始源代码)--bytecode消除解析开销
性能影响
性能改进随代码库大小而变化:
| 应用程序大小 | 典型启动改进 |
|---|---|
| 小型 CLI (< 100 KB) | 1.5-2 倍更快 |
| 中大型应用 (> 5 MB) | 2.5-4 倍更快 |
较大的应用程序受益更多,因为它们有更多的代码需要解析。
何时使用字节码
适用于:
CLI 工具
- 频繁调用(linter、格式化器、git hooks)
- 启动时间是整个用户体验
- 用户会注意到 90ms 和 45ms 启动之间的差异
- 示例:TypeScript 编译器、Prettier、ESLint
构建工具和任务运行器
- 在开发期间运行数百或数千次
- 每次运行节省的毫秒数会迅速累积
- 开发者体验改进
- 示例:构建脚本、测试运行器、代码生成器
独立可执行文件
- 分发给关心快速性能的用户
- 单文件分发很方便
- 文件大小不如启动时间重要
- 示例:通过 npm 或二进制文件分发的 CLI
不适用于:
- ❌ 小型脚本
- ❌ 只运行一次的代码
- ❌ 开发构建
- ❌ 大小受限的环境
- ❌ 带有顶层 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;现在模块导出一个函数,消费者可以在需要时 await。
版本兼容性
字节码不能在不同 Bun 版本之间移植。字节码格式与 JavaScriptCore 的内部表示绑定,后者在版本之间会变化。
更新 Bun 时,必须重新生成字节码:
# 更新 Bun 后
bun build --bytecode ./index.ts --outdir=./dist如果字节码与当前 Bun 版本不匹配,它会被自动忽略,代码会回退到解析 JavaScript 源代码。您的应用程序仍然运行——只是失去了性能优化。
最佳实践:将字节码生成作为 CI/CD 构建过程的一部分。不要将 .jsc 文件提交到 git。每当更新 Bun 时重新生成它们。
仍需要源代码
.js文件(打包的源代码).jsc文件(字节码缓存文件)
在运行时:
- Bun 加载
.js文件,看到@bytecode指令,并检查.jsc文件 - Bun 加载
.jsc文件 - Bun 验证字节码哈希与源代码匹配
- 如果有效,Bun 使用字节码
- 如果无效,Bun 回退到解析源代码
字节码不是混淆
字节码不会混淆您的源代码。它是一种优化,而不是安全措施。
生产部署
Docker
在 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
在构建管道期间生成字节码:
# GitHub Actions
- name: Build with bytecode
run: |
bun install
bun build --bytecode --minify \
--outdir=./dist \
--target=bun \
./src/index.ts调试
验证是否使用字节码
检查 .jsc 文件是否存在:
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 引擎不直接执行源代码。而是经过几个步骤:
- 解析:引擎读取 JavaScript 源代码并将其转换为抽象语法树(AST)
- 字节码编译:AST 被编译为字节码——一种执行更快的低级表示
- 执行:字节码由引擎的解释器或 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),因此无需在运行时从源代码解析。
- 标识符表:代码中使用的所有变量和函数名。存储为去重字符串。
- 源代码表示标记:指示常量应如何表示的标志(作为整数、双精度、大整数等)。
函数元数据(用于代码中的每个函数):
- 寄存器分配:函数需要多少寄存器(局部变量)——
thisRegister、scopeRegister、numVars、numCalleeLocals、numParameters。 - 代码特征:函数特征位掩码:它是构造函数吗?箭头函数吗?使用
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中
这些步骤中的每一个都是单独的字节码指令,带有自己的元数据。
常量池存储所有内容: 每个字符串字面量、数字、属性名——所有内容都存储在常量池中。即使源代码有 "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 的字节码格式演变。添加新操作码,删除或更改旧操作码,元数据结构变化。每个版本的 JavaScriptCore 有不同的字节码格式。
版本验证: .jsc 文件头中的缓存版本是 JavaScriptCore 框架的哈希。当 Bun 加载字节码时:
- 从
.jsc文件中提取缓存版本 - 计算当前 JavaScriptCore 版本
- 如果不匹配,字节码被静默拒绝
- Bun 回退到解析
.js源代码
您的应用程序仍然运行——只是失去了性能优化。
优雅降级: 这种设计意味着字节码缓存"开放失败"——如果出现任何问题(版本不匹配、文件损坏、文件丢失),您的代码仍然正常运行。您可能会看到启动较慢,但不会看到错误。
未链接与链接字节码
JavaScriptCore 在"未链接"和"链接"字节码之间做出关键区分。这种分离使字节码缓存成为可能:
未链接字节码(缓存内容)
.jsc 文件中保存的字节码是未链接字节码。它包含:
- 编译的字节码指令
- 代码的结构信息
- 常量和标识符
- 控制流信息
但它不包含:
- 指向实际运行时对象的指针
- JIT 编译的机器码
- 以前运行的分析数据
- 调用链接信息(哪些函数调用哪些)
未链接字节码是不可变且可共享的。同一代码的多次执行都可以引用相同的未链接字节码。
链接字节码(运行时执行)
当 Bun 运行字节码时,它"链接"它——创建运行时包装器添加:
- 调用链接信息:随着代码运行,引擎了解哪些函数调用哪些并优化这些调用点。
- 分析数据:引擎跟踪每条指令执行多少次、什么类型的值流经代码、数组访问模式等。
- JIT 编译状态:对热代码的基线 JIT 或优化 JIT(DFG/FTL)编译版本的引用。
- 运行时对象:指向实际 JavaScript 对象、原型、作用域等的指针。
这种链接表示每次运行代码时都会重新创建。这允许:
- 缓存昂贵的工作(解析和编译为未链接字节码)
- 仍然收集运行时分析数据以指导优化
- 仍然应用 JIT 优化基于实际执行模式
字节码缓存将昂贵的工作(解析和编译为字节码)从运行时移到构建时。对于频繁启动的应用程序,这可以将启动时间减半,代价是磁盘上的文件更大。
对于生产 CLI 和无服务器部署,--bytecode --minify --sourcemap 的组合为您提供最佳性能,同时保持可调试性。