Bun 的一个默认选择,让我的 Vite 开发服务器当场去世
你可能很难想象,一个 bun install 零报错的 monorepo,在启动 Vite 开发服务器后,浏览器里看到的是一个白屏。没有编译报错,没有终端警告,甚至 Vite 的启动日志看起来一切正常——直到你打开浏览器控制台,才发现一堆 SyntaxError 和 Failed to resolve dependency。
事情是这样的。
那天下午,我把一个单体项目拆成 monorepo 结构。bun install 跑完,输出干净利落,没有一行红色。我满怀信心地敲下 bun run dev,Vite 照常启动,终端打出了熟悉的 Local: http://localhost:5173/。我点开浏览器,准备看看新架构的效果。
白屏。
控制台里躺着一行刺眼的错误:
Uncaught SyntaxError: The requested module '/@fs/.../node_modules/.bun/bind-event-listener@3.0.0/node_modules/bind-event-listener/dist/index.js' does not provide an export named 'bind'我盯着 /@fs/ 和 .bun 这两个路径片段,脑子里闪过一个念头:这个依赖根本不在我的 package.json 里。
破案:不是代码问题,是 Bun 的安装策略
排查方向很快就锁定了——Vite 的依赖预打包机制。
在单体项目中,Bun 默认使用 hoisted(提升)安装模式,所有依赖(直接的和间接的)都被扁平化到根 node_modules,Vite 可以像逛超市一样随意扫到任何包,预打包、解析、一切顺畅。
但在 Bun monorepo 中,默认切换为 isolated installs(隔离安装)模式。这个模式的设计初衷很好——类似 pnpm 的严格策略,避免"幽灵依赖",只给你声明过的包创建 symlink。
问题在于,Vite 的依赖优化器完全不知道这件事。
当一个子包(比如 apps/web)的间接依赖(transitive dependency)没有被显式声明时,isolated 模式不会在子包的 node_modules 中创建 symlink。这些包被下载到了 node_modules/.bun/<pkg>@<version>/ 的中央存储里,但子包访问不到。
于是 Vite 开始了一系列连环崩溃:
- 扫描阶段:Vite 扫描
node_modules/<pkg>来发现需要优化的依赖。间接依赖不在那里,扫描直接跳过。 - 手动配置阶段:我试图在
vite.config.ts里加上optimizeDeps.include: ['bind-event-listener'],结果 Vite 报错Failed to resolve dependency: bind-event-listener——因为它根本找不到这个包的入口。 - 应急兜底阶段:Vite 放弃预打包,尝试通过
/@fs/协议直接提供原始文件。但如果该文件是 CommonJS 格式,浏览器收到未经转换的 CJS 代码,直接抛出SyntaxError。
整条链路就这样从"找不到"到"找到了但用不了",一步步走向崩溃。
而这一切的起点,只是 Bun 在 monorepo 中默认选择了 isolated installs。
依赖链的真实面貌
我的项目结构看起来很标准:
my-monorepo/
├── package.json # workspaces: ["apps/*", "packages/*"]
├── apps/
│ └── web/
│ ├── package.json # 依赖 @scope/ui
│ └── vite.config.ts
└── packages/
└── ui/
├── package.json # 依赖 react, bind-event-listener (CJS)
└── src/index.ts依赖链是这样的:
apps/web → @scope/ui (workspace) → bind-event-listener@3.0.0 (CJS, 间接依赖)
→ raf-schd@4.0.3 (CJS, 间接依赖)apps/web 的代码里 import 了 @scope/ui,@scope/ui 的代码里 import 了 bind-event-listener。但 apps/web/package.json 里只声明了 @scope/ui,没有声明 bind-event-listener——因为在 hoisted 模式下,你从来不需要这么做。
isolated 模式说:你没声明的,我就不给你链接。这逻辑本身没错。但 Vite 说:我不管你链没链接,我需要能找到这些包来做预打包。两个工具各讲各的道理,结果就是开发者被夹在中间吃白屏。
解决方案
方案一:显式声明所有间接依赖(推荐,最规范)
在子包的 package.json 中,把代码里实际 import 的包都显式声明:
// apps/web/package.json
{
"dependencies": {
"@scope/ui": "workspace:*",
"react": "^19.0.0",
"bind-event-listener": "^3.0.0",
"raf-schd": "^4.0.3"
}
}这确实是 isolated 模式的设计意图——消除幽灵依赖,让依赖树严格可追溯。但迁移成本不小,你需要逐个子包梳理每一个间接依赖,手动补上声明。
方案二:切回 Hoisted 模式(最快,零代码改动)
在根目录创建 bunfig.toml:
[install]
linker = "hoisted"然后重新安装:
rm -rf node_modules bun.lock
bun installVite 立刻恢复正常。零代码改动,零配置修改。代价是放弃了 isolated 的严格性,幽灵依赖重新回来。
方案三:Vite 嵌套语法(部分 workaround)
// apps/web/vite.config.ts
export default defineConfig({
optimizeDeps: {
include: [
'@scope/ui > bind-event-listener',
'@scope/ui > raf-schd',
],
},
});不需要改 package.json,但你需要手动映射每一条 CJS 间接依赖链,维护成本高,深层嵌套或动态引入的依赖很难穷举。
| 方案 | 改动范围 | 维护成本 | 严格性 | 适用场景 |
|---|---|---|---|---|
| 显式声明依赖 | 各子包 package.json | 中 | 高 | 长期维护的新项目 |
| Hoisted 模式 | 根 bunfig.toml | 低 | 低 | 快速迁移、遗留项目 |
| optimizeDeps 嵌套 | vite.config.ts | 高 | 中 | 临时 workaround |
暴露的问题
- 工具链之间的隐性契约断裂
Bun 的 isolated installs 策略是正确的——它消除了幽灵依赖,让依赖树更严格。但 Vite 的依赖优化器从未被告知这个变化。两个工具各自按照自己的假设运作,开发者成了唯一的受害者。这不是某个工具的 Bug,而是工具链之间缺乏协调的系统性问题。
- "默认值"的隐性杀伤力
Bun 在单体项目里默认 hoisted,在 monorepo 里默认 isolated。开发者从单体迁移到 monorepo 时,不会收到任何警告或提示,只是安静地换了一个安装策略。而这个"安静的切换",会让 Vite 的整个依赖优化机制失效。默认值不应该有这种级别的破坏力。
- 错误提示的缺失让排查极其困难
Vite 启动日志一切正常,bun install 零报错。唯一的线索是浏览器控制台里的 SyntaxError,而且错误路径指向 .bun/ 中央存储——这个路径对大多数前端开发者来说完全陌生。如果 Vite 在扫描阶段发现 node_modules 结构异常时能给出一个警告,排查时间可以从一个小时缩短到五分钟。
后话
这次排查之后,我在团队的 monorepo 规范里加了两条规则:
- 新建 Bun monorepo 时,立即在
bunfig.toml里显式声明linker——不要依赖默认值 - 子包的
package.json中,所有代码里实际 import 的包都必须显式声明——不管它是不是间接依赖
工具在帮你变严格的同时,也可能在你看不见的地方悄悄拆掉另一个工具的跑道。作为工程师,理解每一个"默认选择"背后的真实含义,远比写好业务代码更重要。
毕竟,让你的 Vite 白屏的,不是某个 Bug,而是一个没人告诉你的默认值。