Skip to main content

Documentation Index

Fetch the complete documentation index at: https://bun.zhcndoc.com/llms.txt

Use this file to discover all available pages before exploring further.

你机器上的每个项目都依赖那几项沉重的公共包 —— typescriptnextwebpack@babel/*react-dom。如果没有共享存储,每个 node_modules 都会各自保存一份副本,而每次新的 bun install 又会把它们全部重新写一遍。 全局虚拟存储将其变为安装一次,到处链接。包文件保存在一个共享缓存中;每个项目的 node_modules 都是一棵指向它的符号链接浅层树。第二次检出、新分支工作树、CI 工作区——它们都指向那份已经在磁盘上的副本。 结果是:热安装速度大约快 7 倍(每个包只需一个符号链接,而不是复制每个文件),而且 node_modules 会从每个项目的数百 MB 缩减到几 MB 的链接。

启用

全局虚拟存储默认关闭。它只适用于 isolated linker;hoisted linker 不会使用它。 要为某个项目启用它:
bunfig.toml
[install]
linker = "isolated"
globalStore = true
或者通过环境变量按次启用:
terminal
BUN_INSTALL_GLOBAL_STORE=1 bun install --linker isolated
要显式关闭它(默认行为),请设置 globalStore = falseBUN_INSTALL_GLOBAL_STORE=0

Why it’s fast

之前的 isolated linker 会在每次安装时,对每个包都调用 clonefileat()(macOS)或 link()/copyfile()(其他平台),即使包缓存已经是热的,而缺失的只有 node_modules。对一个 1,400 包的 fixture 在 macOS 上进行热安装的分析显示,主线程有 95.4 % 的时间都花在 clonefileat
按栈顶排序(bun install --linker isolated,热缓存):
    clonefileat        891 / 934 samples
    __openat            15
    __read_nocancel     10
APFS 上的 clonefileat 会持有一个全卷范围的内核锁,因此即使增加更多线程,收益也很小——8 个线程只把一个 2,830 目录的克隆从 959 ms 提升到 743 ms。修复方法就是在热路径上根本不调用它。 有了全局存储,热路径对每个包只需要一次 access()(全局条目是否存在?)再加一次 symlink()(把项目指向它)。

基准测试

热态 CI 安装——锁文件存在、包缓存已热、每轮之间删除 node_modules——在一个 1,400 包的 React/webpack/Babel/jest fixture 上,Apple Silicon macOS,hyperfine --warmup 3 --runs 10
wall timesystem timeclonefileattotal syscalls
--linker hoisted823.9 ms477 ms1,3877,857
--linker isolated, globalStore=false840.9 ms1,256 ms1,387
--linker isolated, global store124.8 ms94 ms04,957
比没有全局存储的同一 linker 快 6.6 倍,比 hoisted 快 6.6 倍

磁盘

同一 fixture 的 node_modules 大小(APFS 上运行 du -sh node_modules;clonefile 复制是写时复制,所以 hoisted/每项目的数字是 逻辑 大小——在不支持 CoW 的文件系统上,这也就是物理大小):
每个项目的 node_modules磁盘上的共享占用
--linker hoisted391 MB
--linker isolated, globalStore=false391 MB
--linker isolated, global store约 5 MB 的符号链接391 MB 仅一份
把同一个项目克隆五份,差别就是大约 2 GB 的重复包文件 versus 总计约 400 MB。盈亏平衡点是一个项目;从那之后,每增加一个检出、分支工作树或 CI 工作区都是免费的。

真实世界 cold→warm

克隆后的真实仓库在冷到热之间的耗时(macOS arm64):
projectpackagescoldwarm
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

目录结构

isolated installs 相比,磁盘上的布局多加了一层间接层:
tree layout
~/.bun/install/cache/
├── react@18.3.1@@@1/                     # 包缓存(不变)
   └── ...package files...
└── links/                                # 全局虚拟存储
    └── react@18.3.1-5664d3cd670b3205/    # <storepath>-<entry hash>
        └── node_modules/
            ├── react/                    # 包文件(只创建一次)
            ├── loose-envify -> ../../loose-envify@1.4.0-ea24…/node_modules/loose-envify
            └── .bin/
                └── ...

project/node_modules/
├── .bun/
   ├── node_modules/                     # 隐藏的 hoisted 层(每个项目)
   └── 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 后缀编码的是该条目的已解析依赖闭包:包自身的 store path 和 tarball 完整性,再加上它所链接到的每个依赖的哈希。两个项目如果把 react@18.3.1 解析到同一组传递版本,就共享同一个全局目录;如果某个项目把某个传递依赖解析到不同版本,就会得到一个单独的全局条目,其依赖符号链接会指向正确的同级项。参与依赖循环的包会共享一个基于整个强连通分量计算出的哈希,因此这个 key 不依赖于某个项目的依赖图最先碰到的是哪个成员。

哪些内容保持项目本地

只有在条目能够安全共享时,它才会存在于全局存储中。以下情况会回退到每个项目各自的 node_modules/.bun/<storepath>/ 目录:
  • 包通过 bun patch 应用了 patch —— 补丁后的内容是项目专属的;
  • 包列在 trustedDependencies 中(或通过 bun add --trust 被信任)—— 它的生命周期脚本可能会修改安装目录,而通过项目符号链接运行脚本会修改共享副本;
  • 该包,或它链接到的任何依赖,是 workspace:file:link: 依赖 —— 这些会解析到项目本地路径,其他项目看不到。
不符合条件会向上传播:如果 your-app 依赖于一个工作区包 internal-utils,那么 internal-utils 是项目本地的,所有链接到它的条目也都是项目本地的。在安装之间失去资格(新打补丁、新被信任)的条目会从全局存储中拆离,并在下一次安装时以项目本地方式重建;共享条目保持不变。

peer 依赖

已解析的 peer 依赖——必需和可选的——会作为依赖符号链接并入每个全局条目,并计入其哈希。Bun 会为那些只在 peerDependenciesMeta 中列出名称、却没有对应 peerDependencies 条目的包合成一个隐式的 "*" 可选 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'),但它从未在 dependenciespeerDependenciespeerDependenciesMeta 中声明过这个东西。如果遇到这种情况,把 helper 加到消费方包的依赖里(这是正确修复方式),或者设置 globalStore = false 注意,publicHoistPatternhoistPattern 会提升到项目的 node_modules,而全局存储中的包无法访问那里。不过它们仍然可以用于从你自己的源代码中解析被提升的包。

node_modules 大多是符号链接

不会跟随符号链接就扫描 node_modules 的工具,或者通过字符串相等比较文件路径的工具,可能会表现不同。这与任何 pnpm 风格布局的注意事项相同。

磁盘使用

每个唯一的 (package, version, resolved-dependency-set) 三元组都会在 <cache>/links/ 中得到一个目录。跨多个项目来看,这是巨大的净收益——磁盘上只存一份,而不是每个检出一份——但随着新版本和新的 peer-dependency 组合不断出现,存储也会逐渐增长。运行 bun pm cache rm 可清除包括全局存储在内的缓存;下一次安装只会重新填充该项目所需的内容。

并发

多个 bun install 进程(并行 CI 作业、并发工作区构建)可能会竞争填充同一个全局条目。每个进程都会在私有的 <entry>.tmp-<random>/ 暂存目录下构建整个条目——包文件、依赖符号链接、bin 链接——并在最后一步将其重命名到位。重命名失败的一方会看到 EEXIST,并丢弃自己相同的暂存树;在构建过程中崩溃的写入者只会留下一个无人引用的暂存目录,下一次安装会忽略它。因此,已发布的条目总是完整的;不存在单独的完整性哨兵。

相关文档