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

启用

全局虚拟存储默认关闭。它仅适用于隔离链接器;提升链接器不会使用它。 要为某个项目启用它:
bunfig.toml
或者通过环境变量在每次调用时启用:
terminal
要显式关闭它(默认行为),请设置 globalStore = falseBUN_INSTALL_GLOBAL_STORE=0

为什么它很快

之前的隔离链接器会在每次安装时,对每个包都调用 clonefileat()(macOS)或 link()/copyfile()(其他平台),即使包缓存已经是热的,而缺失的只有 node_modules。对一个 1,400 包的测试 fixture 在 macOS 上进行热安装的分析显示,主线程有 95.4 % 的时间都花在 clonefileat
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 比没有全局存储的同一 linker 快 6.6 倍,比 hoisted 快 6.6 倍

磁盘

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

真实世界冷态→热态

克隆后的真实仓库从冷态到热态之间的耗时(macOS arm64):

目录结构

隔离安装 相比,磁盘上的布局多加了一层间接层:
tree layout
16 位十六进制的 entry_hash 后缀编码的是该条目的已解析依赖闭包:包自身的存储路径和 tarball 完整性,以及它所链接到的每个依赖的哈希。两个项目如果把 [email protected] 解析到同一组传递版本,就共享同一个全局目录;如果某个项目把某个传递依赖解析到不同版本,就会得到一个单独的全局条目,其依赖符号链接会指向正确的同级项。参与依赖循环的包会共享一个基于整个强连通分量计算出的哈希,因此这个键不依赖于某个项目的依赖图最先碰到的是哪个成员。

哪些内容保持项目本地

只有在条目能够安全共享时,它才会存在于全局存储中。以下情况会回退到每个项目各自的 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,并丢弃自己相同的暂存树;在构建过程中崩溃的写入者只会留下一个无人引用的暂存目录,下一次安装会忽略它。因此,已发布的条目总是完整的;不存在单独的完整性哨兵。

相关文档