Bun 的一个默认选择,让我的 Vite 开发服务器当场去世

你可能很难想象,一个 bun install 零报错的 monorepo,在启动 Vite 开发服务器后,浏览器里看到的是一个白屏。没有编译报错,没有终端警告,甚至 Vite 的启动日志看起来一切正常——直到你打开浏览器控制台,才发现一堆 SyntaxErrorFailed 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 开始了一系列连环崩溃:

  1. 扫描阶段:Vite 扫描 node_modules/<pkg> 来发现需要优化的依赖。间接依赖不在那里,扫描直接跳过。
  2. 手动配置阶段:我试图在 vite.config.ts 里加上 optimizeDeps.include: ['bind-event-listener'],结果 Vite 报错 Failed to resolve dependency: bind-event-listener——因为它根本找不到这个包的入口。
  3. 应急兜底阶段: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 install

Vite 立刻恢复正常。零代码改动,零配置修改。代价是放弃了 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

暴露的问题

  1. 工具链之间的隐性契约断裂

Bun 的 isolated installs 策略是正确的——它消除了幽灵依赖,让依赖树更严格。但 Vite 的依赖优化器从未被告知这个变化。两个工具各自按照自己的假设运作,开发者成了唯一的受害者。这不是某个工具的 Bug,而是工具链之间缺乏协调的系统性问题。

  1. "默认值"的隐性杀伤力

Bun 在单体项目里默认 hoisted,在 monorepo 里默认 isolated。开发者从单体迁移到 monorepo 时,不会收到任何警告或提示,只是安静地换了一个安装策略。而这个"安静的切换",会让 Vite 的整个依赖优化机制失效。默认值不应该有这种级别的破坏力。

  1. 错误提示的缺失让排查极其困难

Vite 启动日志一切正常,bun install 零报错。唯一的线索是浏览器控制台里的 SyntaxError,而且错误路径指向 .bun/ 中央存储——这个路径对大多数前端开发者来说完全陌生。如果 Vite 在扫描阶段发现 node_modules 结构异常时能给出一个警告,排查时间可以从一个小时缩短到五分钟。

后话

这次排查之后,我在团队的 monorepo 规范里加了两条规则:

  1. 新建 Bun monorepo 时,立即在 bunfig.toml 里显式声明 linker——不要依赖默认值
  2. 子包的 package.json 中,所有代码里实际 import 的包都必须显式声明——不管它是不是间接依赖

工具在帮你变严格的同时,也可能在你看不见的地方悄悄拆掉另一个工具的跑道。作为工程师,理解每一个"默认选择"背后的真实含义,远比写好业务代码更重要。

毕竟,让你的 Vite 白屏的,不是某个 Bug,而是一个没人告诉你的默认值。

最后修改:2026 年 07 月 05 日
如果觉得我的文章对你有用,请随意赞赏