字節碼緩存是一種構建時優化,通過將 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 的組合為您提供最佳性能,同時保持可調試性。