位元組碼快取
使用 Bun 打包器中的位元組碼快取加速 JavaScript 執行
位元組碼快取是一種建置時最佳化,通過預先將 JavaScript 編譯為位元組碼來縮短啟動時間。例如,啟用位元組碼後編譯 TypeScript 的 tsc,啟動時間可提升 2 倍。
使用方法
基本用法(CommonJS)
使用 --bytecode 標誌啟用位元組碼快取。不使用 --format 時,輸出格式預設為 CommonJS:
bun build ./index.ts --target=bun --bytecode --outdir=./dist
建置會寫入兩個檔案:
dist/index.js- 打包後的 JavaScript(CommonJS)dist/index.js.jsc- 位元組碼快取檔案
執行環境,Bun 會自動檢測並使用 .jsc 檔案:
bun ./dist/index.js # 自動使用 index.js.jsc
生成獨立執行檔時
使用 --compile 建立執行檔時,Bun 會將位元組碼嵌入二進位制檔案中。ESM 和 CommonJS 均可使用:
# ESM(需要 --compile)
bun build ./cli.ts --compile --bytecode --format=esm --outfile=mycli
# CommonJS(使用或不使用 --compile 均可)
bun build ./cli.ts --compile --bytecode --outfile=mycli
生成的執行檔同時包含程式碼和位元組碼。
ESM 位元組碼
ESM 位元組碼需要使用 --compile,因為 Bun 會將模組後設資料(匯入/匯出資訊)嵌入編譯後的二進位制檔案中。藉助這些後設資料,JavaScript 引擎可以在執行環境完全跳過解析。
如果不使用 --compile,ESM 位元組碼仍需要解析原始碼以分析模組相依,這會違背位元組碼快取的目的。
限制位元組碼深度
預設情況下,Bun 會將打包中的每個函式編譯為位元組碼,包括巢狀在其他函式中的函式。使用 --bytecode-depth(非負整數)只編譯最外層的 N 層巢狀。超出此限制的函式會在首次呼叫時從原始碼編譯。
# 僅編譯每個模組的頂層程式碼,不編譯巢狀函式
bun build ./index.ts --target=bun --bytecode --bytecode-depth=0 --outdir=./dist
# 編譯頂層程式碼及其直接宣告的函式
bun build ./index.ts --target=bun --bytecode --bytecode-depth=1 --outdir=./dist
深度越小,.jsc 檔案越小。代價是深層巢狀函式會在執行環境解析,而不是提前解析。Bun.build 也提供相同的選項 bytecodeDepth。
與其他最佳化結合使用
將位元組碼與壓縮和源對映結合使用:
bun build --compile --bytecode --minify --sourcemap ./cli.ts --outfile=mycli
--minify在生成位元組碼前壓縮程式碼體積(程式碼更少,位元組碼更小)--sourcemap保持錯誤定位(錯誤仍指向原始原始碼)--bytecode消除解析開銷。
效能影響
效能提升與程式碼體量成正比:
| 應用大小 | 典型啟動速度提升 |
|---|---|
| 小型 CLI(< 100 KB) | 快 1.5-2 倍 |
| 中大型應用(> 5 MB) | 快 2-4 倍 |
體量越大,受益越明顯,因為解析的程式碼更多。
何時使用位元組碼
非常適合:
CLI 工具
- 經常呼叫(如 linter、格式化工具、git 鉤子)
- 啟動時間即全部使用者體驗
- 使用者能明顯感受到 90ms 與 45ms 啟動時間的差異
- 例子:TypeScript 編譯器、Prettier、ESLint
建置工具和任務執行器
- 在開發過程中執行數百次甚至數千次
- 每次節省數毫秒,累計提升明顯
- 改善開發者體驗
- 例子:建置指令碼、測試執行器、程式碼生成器
獨立執行檔
- 釋出給重視效能的使用者
- 單檔案分發方便
- 檔案體積不如啟動速度重要
- 例子:通過 npm 或二進位制釋出的 CLI
不適合:
- ❌ 小型指令碼
- ❌ 只執行一次的程式碼
- ❌ 開發建置
- ❌ 受大小限制的環境
限制
僅支援 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 版本不匹配,Bun 會忽略它,並回退到解析 JavaScript 原始碼。
最佳實踐:將位元組碼生成納入 CI/CD 建置流程,不要將 .jsc 檔案提交到 Git。更新 Bun 時重新生成位元組碼。
仍需原始碼檔案
位元組碼不會替代 JavaScript。你必須同時部署兩個檔案:
.js檔案(打包後的原始碼).jsc檔案(位元組碼快取)
執行環境流程:
- Bun 載入
.js檔案,發現@bytecode指令,檢查.jsc檔案 - Bun 載入
.jsc - 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 \
--compile \
./src/server.ts --outfile=./dist/server
FROM oven/bun:1 AS runner
WORKDIR /app
COPY --from=builder /app/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.js.jsc
.jsc 檔案通常比 .js 大 2-8 倍。
若要記錄 Bun 是否使用位元組碼,請在環境中設定 BUN_JSC_verboseDiskCache=1。
快取命中時,Bun 會記錄:
[Disk Cache] Cache hit for sourceCode
快取未命中時:
[Disk Cache] Cache miss for sourceCode
出現多條快取未命中日誌是正常的:Bun 不會對其內建模組中的 JavaScript 進行位元組碼快取。
常見問題
位元組碼被靜默忽略:通常由 Bun 版本更新引起。快取版本不匹配,因此 Bun 會拒絕使用位元組碼。重新生成即可解決。
檔案太大:這是預期的。建議:
- 使用
--minify在生成位元組碼前減小程式碼體積 - 壓縮
.jsc檔案以便通過網路傳輸(gzip/brotli) - 評估啟動速度的提升是否值得檔案體積增加
頂層 await:不支援,請用非同步初始化函式替代。
什麼是位元組碼?
-
解析:引擎讀取 JavaScript 原始碼,並將其轉換為抽象語法樹(AST)
-
位元組碼編譯:引擎將 AST 編譯為位元組碼——一種更低階、執行速度更快的表示形式
-
執行:引擎的直譯器或 JIT 編譯器執行位元組碼
-
解析:讀取程式碼,生成抽象語法樹(AST)
-
位元組碼編譯:將 AST 編譯為位元組碼,一種更低階但執行更快的中間表示
-
執行:引擎通過直譯器或 JIT 編譯器執行位元組碼
以上步驟會在每次執行程式碼時執行。一個每天執行 100 次的 CLI 工具會被解析 100 次;一個無伺服器函式會在每次冷啟動時進行解析。
每次執行你的程式碼都會執行以上步驟。如果你的 CLI 每天執行 100 次,則程式碼被解析 100 次。如果是無伺服器函式發生頻繁冷啟動,解析在每次冷啟動時都會進行。
位元組碼快取將步驟 1 和 2 移到建置時。執行環境直接載入預編譯的位元組碼,加快啟動。
現代 JavaScript 引擎使用一種稱為延遲解析的最佳化。它們不會預先解析所有程式碼,而是在每個函式首次呼叫時才進行解析:
// 沒有位元組碼快取時:
function rarely_used() {
// 這段 500 行的函式僅在呼叫時被解析
}
function main() {
console.log("Starting app");
// rarely_used() 從未呼叫,故無解析
}
延遲解析意味著解析開銷不只是啟動時的開銷。隨著應用執行不同的程式碼路徑,整個執行期間都會發生解析。使用位元組碼快取後,Bun 會預編譯所有函式,包括引擎原本會延遲解析的函式。
位元組碼格式
.jsc 檔案內部結構
.jsc 檔案包含一個序列化的位元組碼結構。
頭部部分(每次載入驗證):
- 快取版本:與 JavaScriptCore 框架版本關聯的雜湊,確保位元組碼只在對應版本的 Bun 中執行。
- 程式碼塊型別標籤:指示這是程式、模組、eval 還是函式程式碼塊。
SourceCodeKey(驗證位元組碼對應原始碼):
- 原始碼雜湊:原始 JavaScript 原始碼的雜湊值。Bun 在使用位元組碼前會驗證該雜湊是否匹配。
- 原始碼長度:原始碼的確切長度,用於額外驗證。
- 編譯標誌:編譯上下文,例如嚴格模式、指令碼與模組,以及 eval 上下文型別。相同原始碼使用不同標誌編譯會生成不同的位元組碼。
位元組碼指令:
- 指令流:位元組碼操作碼,即 JavaScript 的編譯表示,以可變長度的指令序列儲存。
- 後設資料表:每個操作碼都有相關後設資料,例如效能分析計數器、型別提示和執行次數(即使這些資料尚未填充)。
- 跳轉目標:為控制流(if/else、迴圈、switch 語句)預先計算的地址。
- Switch 表:用於 switch 語句的最佳化查詢表。
常量與識別符號:
- 常量池:程式碼中的所有字面量值——數字、字串、布林值、null、undefined。這些值以 JavaScript 值(JSValues)的形式儲存,因此執行環境無需從原始碼解析。
- 識別符號表:程式碼中使用的所有變數名和函式名,以去重後的字串形式儲存。
- 原始碼錶示標記:指示常量應如何表示的標誌(例如整數、雙精度浮點數、大整數等)。
函式後設資料(每個函式對應):
- 暫存器分配:函式需要多少個暫存器(區域性變數)——
thisRegister、scopeRegister、numVars、numCalleeLocals、numParameters。 - 程式碼特性:函式特徵的位掩碼:它是否為建構函式?箭頭函式?是否使用
super?是否有尾呼叫?這些特性會影響引擎執行函式的方式。 - 詞法作用域特性:嚴格模式及其他詞法上下文。
- 解析模式:解析函式時使用的模式(普通、async、生成器、async 生成器)。
巢狀結構:
- 函式宣告和表示式:每個內嵌函式含獨立位元組碼塊。檔案包含 100 個函式即有 100 個巢狀位元組碼塊。
- 異常處理:try/catch/finally 邊界及處理地址經過預計算。
- 表示式資訊:位元組碼位置映射回原始碼位置,方便報錯及除錯。
位元組碼不包含什麼
位元組碼不會嵌入你的原始碼。相反:
- JavaScript 原始碼儲存在
.js檔案中 - 位元組碼只儲存原始碼的雜湊和長度
- 載入時 Bun 驗證位元組碼與當前原始碼匹配
這就是為什麼你需要同時部署 .js 和 .jsc 檔案:沒有對應的 .js 檔案,.jsc 檔案就毫無用處。
權衡:檔案大小
位元組碼檔案通常比原始碼大 2-8 倍。
為什麼位元組碼更大?
位元組碼指令冗長 一行壓縮後的 JS 程式碼可能對應數十條位元組碼指令,例如:
const sum = arr.reduce((a, b) => a + b, 0);
產生的位元組碼包括:
- 載入
arr變數 - 獲取
reduce屬性 - 建立箭頭函式(它本身也有位元組碼)
- 載入初始值
0 - 按正確的引數數量設定呼叫
- 執行呼叫
- 將結果儲存到
sum
每步都是獨立操作指令附帶元資訊。
常量池儲存所有內容:
每個字串字面量、數字、屬性名——所有內容都會儲存在常量池中。即使原始碼中有 100 個 "hello",常量池也只儲存一次。識別符號表和常量引用仍會增加開銷。
每函式後設資料 即使是一行小函式,也有完整的後設資料:
- 暫存器分配資訊
- 程式碼特性掩碼
- 解析模式
- 異常處理
- 除錯用表示式資訊
檔案中有 1000 個小函式即有 1000 份這類資料。
效能分析結構 即使未填充,也有用來記錄效能資料的結構:
- 值型別分析槽
- 陣列訪問分析槽
- 二元算術分析槽
- 一元算術分析槽
都佔用空間。
預計算控制流 跳轉目標、switch 表、異常邊界預先計算儲存,加快執行也增加大小。
減少體積的策略
壓縮: 位元組碼通過 gzip/brotli 壓縮效果很好(壓縮率 60-70%)。重複的結構和後設資料能夠高效壓縮。
先壓縮程式碼
先用 --minify 可帶來:
- 較短的識別符號 → 識別符號表更小
- 消除死程式碼 → 減少位元組碼
- 常量摺疊 → 常量池變小
權衡: 你需要在檔案增大 2-8 倍與啟動速度加快 2-4 倍之間權衡。對於 CLI,這通常很值得。對於佔用幾兆位元組磁碟空間無關緊要的長時間執行伺服器,這就更不成問題了。
版本與可移植性
跨架構可移植:✅
位元組碼與架構無關。可以:
- macOS ARM64 建置,部署到 Linux x64
- Linux x64 建置,部署到 AWS Lambda ARM64
- Windows x64 建置,部署到 macOS ARM64
位元組碼是抽象指令,由執行環境 JIT 編譯針對架構最佳化。
跨版本可移植:❌
位元組碼不相容不同 Bun 版本,原因:
位元組碼格式變化: JavaScriptCore 的位元組碼格式會隨版本變化。新的操作碼會被新增,舊的操作碼會被移除或修改,後設資料結構也會發生變化。
版本驗證
.jsc 檔案頭部包含快取版本雜湊。載入時:
- 從
.jsc檔案中提取快取版本 - 計算當前 JavaScriptCore 版本
- 如果兩者不匹配,Bun 會靜默拒絕使用位元組碼
- Bun 會回退到解析
.js原始碼
優雅降級: 這種設計意味著位元組碼快取採用“失敗時放行”策略。如果出現任何問題(版本不匹配、檔案損壞、檔案缺失),程式碼仍會正常執行。你可能會看到啟動速度變慢,但不會遇到錯誤。
未連結與已連結位元組碼
JavaScriptCore 區分“未連結”和“已連結”位元組碼。這種分離機制使位元組碼快取成為可能:
未連結位元組碼(快取內容)
.jsc 檔案儲存的是未連結位元組碼。包含:
- 編譯的位元組碼指令
- 程式碼結構資訊
- 常量與識別符號
- 控制流資訊
不包含:
- 指向執行環境物件的指標
- JIT 編譯的機器碼
- 執行環境效能分析資料
- 呼叫連結資訊(函式呼叫關係)
未連結位元組碼是不可變且可共享的,同一程式碼多次執行共用一份。
已連結位元組碼(執行環境)
執行環境,Bun 對位元組碼完成“連結”過程,生成執行環境結構,附加:
- 呼叫連結資訊:最佳化函式呼叫路徑
- 效能分析資料:統計指令執行次數、型別流、陣列模式等
- JIT 編譯狀態:基線 JIT 或最佳化 JIT(DFG/FTL)的程式碼
- 執行環境物件:JavaScript 物件、原型、作用域指標等
Bun 每次執行程式碼時都會建立這個已連結表示。這種分離機制可以:
- 快取成本高昂的解析編譯結果(未連結位元組碼)
- 收集執行環境分析資料
- 啟用基於分析的 JIT 最佳化
對於生產環境中的 CLI 和無伺服器部署,結合使用 --bytecode --minify --sourcemap 可以在將錯誤映射回原始原始碼的同時,提供最佳的啟動速度。