本文基于一个多前端子项目从 npm/pnpm 迁移到 Bun 的过程整理。
文中速度数据为同一台 x86_64 机器、同一份依赖清单下的参考量级,用于直观感受差距,不是实验室级精确基准。
适合读者:正在考虑或已经使用 Bun,遇到原生依赖、跨平台构建、Docker 容器构建问题的开发者。
目录
背景:为什么要从 npm/pnpm 迁到 Bun
Bun 与 npm 核心区别
最大坑点:跨 libc 复用 node_modules
实战复盘:从报错到定位
沉淀出的硬规则
迁移前后对比
验证方法与常用命令
常见问题 FAQ
结论
1. 背景:为什么要从 npm/pnpm 迁到 Bun
一个多前端子项目,包含web/user、web/admin、mobile/wap等子项目。迁移前锁文件混乱:
web/user用 Bun;web/admin用 pnpm,还残留一个没人用的 npmpackage-lock.json;mobile/wap用 pnpm;开发容器统一使用
oven/bun:1-alpine。
问题很明显:
开发容器是 Bun,但锁文件五花八门,
bun install只能现场计算依赖树,版本不可控;不同子项目用不同包管理器,CI 构建行为不一致;
Docker 镜像里 Node 环境冗余,Bun 单二进制优势没有发挥出来。
迁移到 Bun 的理由:
开发容器已经是 Bun,锁文件统一后依赖树可控;
Bun 单二进制,Docker 镜像更小,免 Node 环境;
安装速度明显更快,冷装、热装、空跑都优于 npm/pnpm。
2. Bun 与 npm 核心区别
| 维度 | npm | Bun |
|---|---|---|
| 运行时 | 需要 Node.js | 内置 JS/TS 运行时,单二进制 |
| 锁文件 | package-lock.json | bun.lock |
| 安装策略 | 先解析完整依赖树再下载 | 并行拉取 + 缓存复用,直接写盘 |
| node_modules 结构 | 扁平 + 嵌套兜底 | 扁平 + 符号链接,去重更彻底 |
| 缓存目录 | ~/.npm/_cacache | ~/.bun/install/cache |
| 可选依赖 | 按平台解析,偶发 npm 历史 bug | 按当前平台解析,跨 libc 复用会踩坑 |
| 脚本执行 | npm run xxx | bun run xxx,自动读package.jsonscripts |
| 测试/打包 | 需另装 vitest、esbuild 等 | 内置bun test、原生打包器 |
| monorepo workspace | 支持 | 支持,语义基本一致 |
| 兼容性 | 最广 | 对 npm 生态高度兼容,个别工具链有边缘问题 |
2.1 速度:不是快一点,是快一个量级
| 操作 | npm | Bun | 说明 |
|---|---|---|---|
| 冷装(无缓存,约 470 包) | 60~120s | 8~15s | Bun 并行下载 + 直写 |
| 热装(有缓存) | 3~8s | 0.5~1.5s | Bun 命中缓存后几乎瞬发 |
| 已有 node_modules 空跑 | 1~3s | 20~50ms | bun install秒级返回 “no changes” |
| 大依赖树增量安装 | 明显卡顿 | 流畅 | Bun 去重 + 符号链接优势 |
数据为同机参考量级,实际因网络、依赖数量浮动。
2.2 可选依赖(原生二进制)——本次最大坑点
Rollup、esbuild 这类库按平台编译原生代码,通过optionalDependencies分发多套二进制:
text
@rollup/rollup-linux-x64-gnu ← glibc(Debian、普通 Linux) @rollup/rollup-linux-x64-musl ← musl(Alpine)
npm、pnpm、Bun 都会按当前平台挑对应版本安装。
但 Bun 的解析取决于构建环境:
如果在 glibc 机器上执行
bun install,生成的node_modules里是 gnu 版原生包;把这个
node_modules整个拷进 Alpine(musl)容器,Rollup 就找不到 musl 版;构建时报错:
text
Cannot find module @rollup/rollup-linux-x64-musl
这不是 Bun 的 bug,而是跨 libc 复用依赖的错误用法。
2.3 锁文件差异
| 项目 | npm | Bun |
|---|---|---|
| 文件 | package-lock.json | bun.lock |
| 体积 | 巨大,完整解析记录 | 更紧凑 |
| 跨平台迁移 | 容易锁死平台相关依赖 | 按 specifier 记录,跨平台更干净 |
| 原生包处理 | 按平台解析 | 所有平台条目写入optionalDependencies,安装时按当前平台挑 |
3. 最大坑点:跨 libc 复用 node_modules
3.1 问题现象
在 Alpine 容器里执行bun run build,报错:
text
Cannot find module @rollup/rollup-linux-x64-musl
3.2 误判
一开始以为是 Bun 在 musl 下处理嵌套 Rollup 可选依赖的 bug。
3.3 真相
测试方法错了:
把宿主机(glibc 机器 + pnpm 安装的
node_modules)整个挂载进 Alpine 容器;Bun 直接复用了 glibc 的依赖;
缺少 musl 原生包,导致构建失败。
3.4 验证
在干净的 Alpine 容器里,只带package.json+bun.lock,重新安装并构建:
| 子项目 | 技术栈 | 结果 |
|---|---|---|
mobile/wap | Vite + PWA / Rollup | ✓ built |
web/admin | Vite + ElementPlus | ✓ built |
web/user | Nuxt SSR | ✨ Build complete! |
全部通过。
4. 实战复盘:从报错到定位
4.1 时间线
现象:wap 在 Alpine 容器里
bun run build报Cannot find module @rollup/rollup-linux-x64-musl;误判:以为是 Bun 在 musl 下处理 Rollup 可选依赖的 bug;
真相:测试方法错误——把宿主机(glibc + pnpm 装的
node_modules)整个挂载进 Alpine 容器,Bun 复用了 glibc 依赖,缺 musl 原生包;验证:干净 Alpine 容器中,只带
package.json+bun.lock,三个子项目全部构建成功。
4.2 关键教训
遇到“某工具在某容器里报原生包找不到”,先怀疑是不是把宿主机的依赖拷进了容器,而不是工具本身的 bug。
5. 沉淀出的硬规则
| 规则 | 说明 |
|---|---|
| 构建层保持干净 | .dockerignore排除**/node_modules、**/dist、**/.output、**/.nuxt |
| 不跨 libc 复用依赖 | glibc 机器装的依赖不要拷进 musl 容器,反之亦然 |
| 包管理器锁文件统一 | 一个项目一套 lock(bun.lock),不要 npm/pnpm/bun 混用 |
| 保留可选包管理器 | packageManager字段别乱写:写bun@x会让 pnpm 直接拒用。要么写 pnpm 版本号,要么删掉字段 |
| dev 与构建镜像同源 | dev 用oven/bun:1-alpine,构建也用同一个,避免行为漂移 |
6. 迁移前后对比
| 项目 | 迁移前 | 迁移后 |
|---|---|---|
| 锁文件 | wap=pnpm、admin=pnpm+残留 npm、web=bun | 全部bun.lock |
| pnpm | 默认,且卡住packageManager字段 | 保留可选,不受约束 |
| Dockerfile | 引用旧 monorepo 路径,直接构建必挂 | 独立目录 + Bun,干净环境可构建 |
| 冷装速度参考 | pnpm ~40s(约 470 包) | Bun ~10s |
| node_modules 跨 libc 复用 | 会踩 musl 原生包坑 | 已通过.dockerignore规避 |
7. 验证方法与常用命令
7.1 检查当前平台 libc
bash
ldd --version | head -n1
输出含
musl:Alpine 等 musl 环境;输出含
GLIBC:Debian/Ubuntu 等 glibc 环境。
7.2 查看原生包是否匹配
bash
ls node_modules/@rollup/ | grep linux
应包含与当前平台匹配的条目,例如 Alpine 下应有rollup-linux-x64-musl。
7.3 干净构建验证
bash
# 在容器内,只复制 package.json 和 bun.lock docker run --rm -v "$PWD":/app -w /app oven/bun:1-alpine \ sh -c "bun install && bun run build"
7.4 常用命令
bash
# 安装依赖 bun install # 运行脚本 bun run build # 查看缓存目录 ls ~/.bun/install/cache # 检查锁文件 cat bun.lock
8. 常见问题 FAQ
Q1:Bun 能完全替代 npm 吗?
大多数场景可以。Bun 对 npm 生态高度兼容,但个别工具链仍有边缘问题。建议保留 npm/pnpm 作为可选方案,遇到问题时可切换。
Q2:为什么bun install比 npm 快那么多?
Bun 采用并行下载 + 缓存复用 + 直接写盘,减少了依赖树解析和中间层开销。热装时命中缓存,几乎瞬发。
Q3:跨 libc 复用 node_modules 为什么会出错?
原生包按平台编译。glibc 版和 musl 版是不同的二进制文件。在 glibc 环境安装的依赖,拷贝到 musl 容器后,缺少对应原生包,运行或构建时就会报错。
Q4:如何避免这个坑?
三条规则:
.dockerignore排除node_modules;在容器内重新
bun install;dev 和构建容器使用同一基础镜像。
Q5:packageManager字段能不能写bun@x?
可以,但会限制其他包管理器使用。如果团队中有人用 pnpm,建议写 pnpm 版本号或删掉该字段。
Q6:bun.lock和package-lock.json能共存吗?
不建议。一个项目应只保留一套锁文件,避免版本漂移和依赖解析不一致。
9. 结论
Bun 是当前 JS 前端安装依赖的优选主力:更快、更干净、单二进制;
pnpm 可以保留为可选:当某些工具链只支持 pnpm workspace 语义时使用;
最重要的经验:遇到“某工具在某容器里报原生包找不到”,先怀疑是不是把宿主机的依赖拷进了容器,而不是工具本身的 bug;
统一锁文件、统一包管理器、保持构建层干净,是避免此类问题的根本方法。
本文基于真实迁移过程整理,速度对比为参考量级,实际请以你机器上的time bun install为准。不同 Bun、Node、libc 版本下行为可能不同,请结合环境调整。