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

启用

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

为什么它很快

如果没有全局存储,隔离链接器会在每次安装时为每个包调用 clonefileat()(macOS)或 link()/copyfile()(其他平台),即使包缓存是热的,且唯一缺少的是 node_modules。在 macOS 上对一个包含 1,400 个包的测试样例进行热安装性能分析后发现,主线程有 95.4 % 的时间都花在 clonefileat 中:
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: 相比未启用全局存储的相同链接器,快 6.7 倍;相比 hoisted,快 6.6 倍。

磁盘

同一测试样例的 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 是项目本地的,所有链接到它的条目也都是项目本地的。如果某个条目在两次安装之间失去资格(新应用了补丁或新加入信任列表),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,然后丢弃其相同的暂存树。构建过程中崩溃的写入进程只会留下一个未被引用的暂存目录,下次安装时会忽略它。因此,已发布的条目始终是完整的;不需要单独的完整性标记。

相关文档