執行環境套件管理器打包器測試執行器程式碼檢查格式化指南參考部落格意見回饋

全域性虛擬儲存

只安裝一次。每個專案都連結到同一份副本。

你機器上的每個專案都相依那幾項沉重的公共包 —— typescript、next、webpack、@babel/*、react-dom。如果沒有共享儲存,每個 node_modules 都會各自儲存一份副本,而每次新的 bun install 又會把它們全部重新寫一遍。

全域性虛擬儲存將其變為安裝一次,到處連結。包檔案儲存在一個共享快取中;每個專案的 node_modules 都是一棵指向它的符號連結淺層樹。第二次檢出、新分支工作樹、CI 工作區——它們都指向那份已經在磁碟上的副本。

結果是:熱安裝速度大約快 7 倍(每個包只需一個符號連結,而不是複製每個檔案),而且 node_modules 會從每個專案的數百 MB 縮減到幾 MB 的連結。

啟用

全域性虛擬儲存預設關閉。它僅適用於隔離連結器;hoisted 連結器不會使用它。

要為某個專案啟用它:

bunfig.toml
[install]
linker = "isolated"
globalStore = true

或者通過環境變數在每次呼叫時啟用:

terminal
BUN_INSTALL_GLOBAL_STORE=1 bun install --linker isolated

要顯式關閉它(預設行為),請設定 globalStore = false 或 BUN_INSTALL_GLOBAL_STORE=0。

為什麼它很快

如果沒有全域性儲存,隔離連結器會在每次安裝時為每個包呼叫 clonefileat()(macOS)或 link()/copyfile()(其他平臺),即使包快取是熱的,且唯一缺少的是 node_modules。在 macOS 上對一個包含 1,400 個包的測試樣例進行熱安裝效能分析後發現,主執行緒有 95.4 % 的時間都花在 clonefileat 中:

Code
按棧頂排序(bun install --linker isolated,熱快取):
    clonefileat        891 / 934 samples
    __openat            15
    __read_nocancel     10

APFS 上的 clonefileat 會持有卷級核心鎖,因此增加執行緒幾乎沒有幫助。八個執行緒只將 2,830 個目錄的克隆時間從 959 ms 縮短到 743 ms。解決方法是在熱路徑中完全不呼叫它。

有了全域性儲存,熱路徑對每個包只需要一次 access()(全域性條目是否存在?)再加一次 symlink()(把專案指向它)。

基準測試

在包含 1,400 個包的 React/webpack/Babel/jest 測試樣例上進行的熱 CI 安裝,運行於 Apple Silicon macOS,使用 hyperfine --warmup 3 --runs 10。鎖檔案存在,包快取是熱的,並且每次執行之間都會刪除 node_modules:

牆鍾時間系統時間clonefileat系統呼叫總數
--linker hoisted823.9 ms477 ms1,3877,857
--linker isolated, globalStore=false840.9 ms1,256 ms1,387—
--linker isolated,全域性儲存124.8 ms94 ms04,957

相比未啟用全域性儲存的相同連結器,快 6.7 倍;相比 hoisted,快 6.6 倍。

磁碟

同一測試樣例的 node_modules 大小(在 APFS 上執行 du -sh node_modules;clonefile 複製是寫時複製,所以 hoisted/每專案的數字是 邏輯 大小——在不支援 CoW 的檔案系統上,這也就是物理大小):

每個專案的 node_modules磁碟上的共享佔用
--linker hoisted391 MB—
--linker isolated, globalStore=false391 MB—
--linker isolated,全域性儲存約 5 MB 的符號連結391 MB 僅一份

把同一個專案克隆五份,差別就是大約 2 GB 的重複包檔案 versus 總計約 400 MB。盈虧平衡點是一個專案;從那之後,每增加一個檢出、分支工作樹或 CI 工作區都是免費的。

真實世界冷態→熱態

克隆後的真實儲存庫從冷態到熱態之間的耗時(macOS arm64):

專案包數量冷態熱態
cal.com~3,58037.4 s4.7 s
remix~1,75023.1 s2.0 s
excalidraw~1,3325.9 s1.1 s
hono~7904.5 s1.3 s
next build (create-next-app)~3821.0 s0.35 s

目錄結構

與隔離安裝相比,磁碟上的佈局多加了一層間接層:

tree
~/.bun/install/cache/
├── react@18.3.1@@@1/                     # 包快取(不變)
│   └── ...包檔案...
└── links/                                # 全域性虛擬儲存
    └── react@18.3.1-5664d3cd670b3205/    # <儲存路徑>-<條目雜湊>
        └── node_modules/
            ├── react/                    # 包檔案(只建立一次)
            ├── loose-envify -> ../../loose-envify@1.4.0-ea24…/node_modules/loose-envify
            └── .bin/
                └── ...

project/node_modules/
├── .bun/
│   ├── node_modules/                     # 隱藏的提升層(每個專案)
│   │   └── react -> ../react@18.3.1/node_modules/react
│   └── react@18.3.1 -> ~/.bun/install/cache/links/react@18.3.1-5664d3cd670b3205
└── react -> .bun/react@18.3.1/node_modules/react

16 位十六進位制的 entry_hash 字尾編碼了條目的已解析相依閉包:包自身的儲存路徑和 tarball 完整性,以及它連結到的每個相依的雜湊。兩個將 react@18.3.1 解析到相同傳遞版本集合的專案會共享同一個全域性目錄。若某個專案將傳遞相依解析到不同版本,則會獲得單獨的全域性條目,其相依符號連結會指向正確的同級條目。參與相依迴圈的包會共享一個基於整個強連通分量計算出的雜湊。因此,該鍵不受某個專案相依圖中最先訪問到哪個成員的影響。

哪些內容保持專案本地

只有在條目能夠安全共享時,它才會存在於全域性儲存中。以下情況會回退到每個專案各自的 node_modules/.bun/<storepath>/ 目錄:

  • 包通過 bun patch 應用了 patch —— 補丁後的內容是專案專屬的;
  • 包列在 trustedDependencies 中(或通過 bun add --trust 被信任)—— 它的生命週期指令碼可能會修改安裝目錄,而通過專案符號連結執行指令碼會修改共享副本;
  • 該包,或它連結到的任何相依,是 workspace:、file: 或 link: 相依 —— 這些會解析到專案本地路徑,其他專案看不到。

不符合條件的情況會向上傳播:如果 your-app 相依的 internal-utils 是一個工作區包,那麼 internal-utils 是專案本地的,所有連結到它的條目也都是專案本地的。如果某個條目在兩次安裝之間失去資格(新應用了補丁或新加入信任列表),Bun 會將其從全域性儲存中分離,並在下次安裝時於專案本地重建。共享條目保持不變。

peer 相依

Bun 會將已解析的 peer 相依(必需和可選)作為相依符號連結納入每個全域性條目。這些 peer 相依會參與條目雜湊的計算。對於只在 peerDependenciesMeta 中列出某個名稱、但沒有對應 peerDependencies 條目的包,Bun 會合成一個隱式的 "*" 可選 peer(與 pnpm 和 yarn 一致)。因此,像 webpack 這樣僅在 peerDependenciesMeta 中宣告 webpack-cli 的包,在專案中安裝了 webpack-cli 時,仍會在其全域性條目中獲得一個 webpack-cli 符號連結。

取捨

幽靈相依回退

當包位於專案的 node_modules/.bun/ 下時,Node 的模組解析會沿著 node_modules/.bun/node_modules/ —— 這個隱藏的 hoisted 層 —— 向上查詢,然後才到專案根目錄。有了全域性儲存後,包會 realpath 到 <cache>/links/,因此從包內部看,這一層就不再位於解析路徑上。

實際中這隻會影響真正的幽靈相依:某個包 require('helper'),但它從未在 dependencies、peerDependencies 或 peerDependenciesMeta 中宣告過這個東西。如果遇到這種情況,把 helper 加到消費方包的相依裡(這是正確修復方式),或者設定 globalStore = false。

publicHoistPattern 和 hoistPattern 會將相依提升到專案的 node_modules 中,而全域性儲存中的包無法訪問該目錄。它們仍可用於從你自己的原始碼中解析已提升的包。

node_modules 大多是符號連結

不會跟隨符號連結就掃描 node_modules 的工具,或者通過字串相等比較檔案路徑的工具,可能會表現不同。這與任何 pnpm 風格佈局的注意事項相同。

磁碟使用

每個唯一的 (package, version, resolved-dependency-set) 三元組都會在 <cache>/links/ 中獲得一個目錄。在許多專案之間,這能帶來很大的淨收益:磁碟上只保留一份副本,而不是每個檢出都保留一份。但隨著新版本和新的 peer 相依組合不斷加入,儲存也會逐漸增大。執行 bun pm cache rm 可以清除包括全域性儲存在內的快取;下次安裝時只會重新填充該專案所需的內容。

並行

多個 bun install 行程(並行 CI 任務、並行工作區建置)可能會爭相填充同一個全域性條目。每個行程都會在私有的 <entry>.tmp-<random>/ 暫存目錄中建置整個條目(包檔案、相依符號連結、bin 連結)。最後一步會將該目錄重新命名到目標位置。重新命名失敗的行程會收到 EEXIST,然後丟棄其相同的暫存樹。建置過程中崩潰的寫入行程只會留下一個未被引用的暫存目錄,下次安裝時會忽略它。因此,已釋出的條目始終是完整的;不需要單獨的完整性標記。

相關文件