Skip to content

字節碼緩存是一種構建時優化,通過將 JavaScript 預編譯為字節碼來顯著提高應用程序啟動時間。例如,當使用字節碼編譯 TypeScript 的 tsc 時,啟動時間可提高 2 倍

用法

基本用法

使用 --bytecode 標志啟用字節碼緩存:

bash
bun build ./index.ts --target=bun --bytecode --outdir=./dist

這將生成兩個文件:

  • dist/index.js - 打包的 JavaScript
  • dist/index.jsc - 字節碼緩存文件

在運行時,Bun 會自動檢測並使用 .jsc 文件:

bash
bun ./dist/index.js  # 自動使用 index.jsc

使用獨立可執行文件

使用 --compile 創建可執行文件時,字節碼會嵌入到二進制文件中:

bash
bun build ./cli.ts --compile --bytecode --outfile=mycli

生成的可執行文件包含代碼和字節碼,在單個文件中提供最大性能。

與其他優化結合

字節碼與壓縮和 source map 配合良好:

bash
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 是例外:

js
// 這會阻止字節碼緩存
const data = await fetch("https://api.example.com");
export default data;

原因:頂層 await 需要異步模塊求值,這無法用 CommonJS 表示。模塊圖變為異步的,CommonJS 包裝器函數模型會崩潰。

解決方法:將異步初始化移入函數中:

js
async function init() {
  const data = await fetch("https://api.example.com");
  return data;
}

export default init;

現在模塊導出一個函數,消費者可以在需要時 await。

版本兼容性

字節碼不能在不同 Bun 版本之間移植。字節碼格式與 JavaScriptCore 的內部表示綁定,後者在版本之間會變化。

更新 Bun 時,必須重新生成字節碼:

bash
# 更新 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

在構建管道期間生成字節碼:

yaml
# GitHub Actions
- name: Build with bytecode
  run: |
    bun install
    bun build --bytecode --minify \
      --outdir=./dist \
      --target=bun \
      ./src/index.ts

調試

驗證是否使用字節碼

檢查 .jsc 文件是否存在:

bash
ls -lh dist/
txt
-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

成功時,它將記錄類似內容:

txt
[Disk cache] cache hit for sourceCode

如果您看到緩存未命中,它將記錄類似內容:

txt
[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 引擎使用一種稱為惰性解析的巧妙優化。它們不會一次性解析所有代碼——相反,函數僅在首次調用時解析:

js
// 沒有字節碼緩存:
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 可能編譯為數十個字節碼指令。例如:

js
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 加載字節碼時:

  1. .jsc 文件中提取緩存版本
  2. 計算當前 JavaScriptCore 版本
  3. 如果不匹配,字節碼被靜默拒絕
  4. Bun 回退到解析 .js 源代碼

您的應用程序仍然運行——只是失去了性能優化。

優雅降級: 這種設計意味著字節碼緩存"開放失敗"——如果出現任何問題(版本不匹配、文件損壞、文件丟失),您的代碼仍然正常運行。您可能會看到啟動較慢,但不會看到錯誤。

未鏈接與鏈接字節碼

JavaScriptCore 在"未鏈接"和"鏈接"字節碼之間做出關鍵區分。這種分離使字節碼緩存成為可能:

未鏈接字節碼(緩存內容)

.jsc 文件中保存的字節碼是未鏈接字節碼。它包含:

  • 編譯的字節碼指令
  • 代碼的結構信息
  • 常量和標識符
  • 控制流信息

但它不包含

  • 指向實際運行時對象的指針
  • JIT 編譯的機器碼
  • 以前運行的分析數據
  • 調用鏈接信息(哪些函數調用哪些)

未鏈接字節碼是不可變且可共享的。同一代碼的多次執行都可以引用相同的未鏈接字節碼。

鏈接字節碼(運行時執行)

當 Bun 運行字節碼時,它"鏈接"它——創建運行時包裝器添加:

  • 調用鏈接信息:隨著代碼運行,引擎了解哪些函數調用哪些並優化這些調用點。
  • 分析數據:引擎跟蹤每條指令執行多少次、什麼類型的值流經代碼、數組訪問模式等。
  • JIT 編譯狀態:對熱代碼的基線 JIT 或優化 JIT(DFG/FTL)編譯版本的引用。
  • 運行時對象:指向實際 JavaScript 對象、原型、作用域等的指針。

這種鏈接表示每次運行代碼時都會重新創建。這允許:

  1. 緩存昂貴的工作(解析和編譯為未鏈接字節碼)
  2. 仍然收集運行時分析數據以指導優化
  3. 仍然應用 JIT 優化基於實際執行模式

字節碼緩存將昂貴的工作(解析和編譯為字節碼)從運行時移到構建時。對於頻繁啟動的應用程序,這可以將啟動時間減半,代價是磁盤上的文件更大。

對於生產 CLI 和無服務器部署,--bytecode --minify --sourcemap 的組合為您提供最佳性能,同時保持可調試性。

Bun學習網由www.bunjs.com.cn整理維護