依赖管理

要点

  • 前面一篇把 workspace的基础机制讲清楚了:本地包可以互相引用,不需要发布到 npm
  • 先从一个最常见的现象说起
  • hoisting 最容易带来的麻烦叫做幽灵依赖(phantom dependency)
  • hoisting 能统一存放相同版本的包,但如果两个 app 声明了同一个包的不同版本,情况就会变复杂
  • packages/ui 是一个内部包,只在这个 monorepo 里使用,不会发布到 npm

内容

1. workspace 装好了,但依赖这件事远没结束

前面一篇把 workspace的基础机制讲清楚了:本地包可以互相引用,不需要发布到 npm。

但你真正动手之后,很快就会发现还有另一层问题还没解决——依赖怎么装、装在哪、怎么保持一致,这些事情在 monorepo 里的行为,和单仓库里是不一样的。

这一篇就专门把这一层讲清楚。

2. monorepo 里的依赖,默认会往根目录提升

先从一个最常见的现象说起。

你在 apps/web 里安装了 react,在 apps/api 里也安装了 react。但实际看文件系统,你会发现两个 app 各自的 node_modules 里并没有 react,它被放到了仓库根目录的 node_modules 里。

这个行为叫做 hoisting(提升)

package manager 在处理 monorepo 时,会把多个 workspace 里共同依赖的包提升到根目录统一存放,而不是在每个 app 里重复安装。好处是减少磁盘占用,加快安装速度。

// code.ts
my-monorepo/
 
├── node_modules/
 
│   └── react/          ← 提升到根目录
 
├── apps/
 
│   ├── web/
 
│   │   └── node_modules/   ← 通常是空的或只有少数特殊包
 
│   └── api/
 
│       └── node_modules/

这个机制在大多数情况下是透明的——你感觉不到它的存在。但它带来了一类不容忽视的问题。

3. hoisting 会让你不小心用到没有声明的依赖

hoisting 最容易带来的麻烦叫做幽灵依赖(phantom dependency)

假设你的 apps/webpackage.json 里只声明了 react-query,但没有声明 react。在单仓库里,这会直接报错。可在 monorepo 里,因为 react 已经被另一个 app 安装并提升到根目录,你的代码照样能跑。

// code.ts
// apps/web/package.json
 
{
 
  "dependencies": {
 
    "react-query": "^5.0.0"
 
    // 没有声明 react,但代码里用了 import React from 'react'
 
  }
 
}

这个代码能运行,是因为 react 恰好在根目录的 node_modules 里。但这是一种意外的依赖——你没有声明它,只是借用了别人装的版本。

这类问题最危险的地方在于:它在本地是隐形的,只有在某些环境下才会暴露,比如:

  • 另一个 app 把 react 版本升级了
  • 有人把那个 app 从 monorepo 里移走了
  • 在 CI 里只安装了部分 workspace 的依赖

幽灵依赖最好的处理方式,是你用到什么就声明什么,不依赖 hoisting 带来的意外可访问性。

4. 跨 workspace 的版本冲突,是另一类常见麻烦

hoisting 能统一存放相同版本的包,但如果两个 app 声明了同一个包的不同版本,情况就会变复杂。

// code.ts
// apps/web/package.json
 
{ "dependencies": { "lodash": "^4.17.21" } }
 
// apps/api/package.json
 
{ "dependencies": { "lodash": "^3.10.1" } }

这时候 package manager 无法把两个版本都提升到同一个位置,它会把其中一个版本留在 apps/api/node_modules/lodash 里,另一个提升到根目录。

对于工具类包,这种情况通常不会出问题。但对于有全局单例要求的包,比如 react,两个版本共存就会出问题——组件树里可能同时跑着两个不同版本的 React,各种行为都会变得不可预测。

实践建议:

在 monorepo 里,对于这类必须保持单例的包,应该把版本约束统一放到根目录的 package.json 里,或者借助 package manager 的 resolutions(yarn)/ overrides(npm/pnpm)强制锁定到同一版本。

// code.ts
// 根目录 package.json(yarn 写法)
 
{
 
  "resolutions": {
 
    "react": "19.1.0",
 
    "react-dom": "19.1.0"
 
  }
 
}

5. 内部包的依赖声明,要和外部包一样认真

这一点很多人会忽略。

packages/ui 是一个内部包,只在这个 monorepo 里使用,不会发布到 npm。但它依然需要认真声明自己的依赖。

如果 packages/ui 用到了 react,它就应该在自己的 package.json 里把 react 声明为 peerDependencies

// code.ts
// packages/ui/package.json
 
{
 
  "name": "@my-app/ui",
 
  "peerDependencies": {
 
    "react": ">=18.0.0"
 
  }
 
}

为什么是 peerDependencies 而不是 dependencies

因为 react 必须是单例——整个应用只能有一个 React 实例。如果 packages/uireact 声明为 dependencies,package manager 有可能为它单独安装一份,这就可能导致两个 React 实例并存。peerDependencies 告诉 package manager:这个包需要 react,但我不自己带,由使用我的应用提供。

6. 根目录 package.json 和子包的分工

一个常见的困惑是:什么东西应该装在根目录,什么应该装在各个 app 里?

装在根目录的通常是:

  • 开发工具:typescripteslintprettierturbohusky
  • 构建工具:只在全仓库层面需要的脚本和工具
  • 共享配置:被多个 app 或 package 引用的配置包

装在各 app/package 里的通常是:

  • 该 app 运行时实际需要的依赖
  • 框架(nextreact 等)

根目录的包通常不应该包含运行时依赖。如果你发现根目录的 package.json 里开始出现很多业务相关的包,往往说明有东西装错了位置。

7. yarn 和 pnpm 的 hoisting 行为不一样

在使用 pnpm 时,hoisting 的行为和 npm/yarn 有一个显著差异。

pnpm 默认采用更严格的链接策略:每个包只能访问它在 package.json 里显式声明的依赖,没有声明的包在理论上不可访问,即使它们存在于 node_modules 里。这个策略从根本上杜绝了幽灵依赖。

yarn 则相对宽松,它的默认行为更接近 npm——会把大多数依赖提升到根目录,幽灵依赖可以存在但不会报错。不过 yarn 也提供了 nmHoistingLimitspnpMode 等选项来收紧这个行为。

如果你使用 yarn,你需要自己保持对幽灵依赖的警惕,不能依赖工具自动拦截。

8. 总结

monorepo 里的依赖管理和单仓库相比,多了两件需要主动留意的事:

第一件是 hoisting 带来的幽灵依赖——你用到的包必须由你自己声明,不能借用别人装的版本,否则仓库会变得脆而不稳。

第二件是版本一致性——对于必须单例的包(react、react-dom 等),要通过 resolutions 或 overrides 主动锁定版本,防止多个版本并存导致运行时异常。

把这两件事做扎实,后面不管是共享包还是任务编排,才会有一个可靠的基础。

下一篇讲共享代码怎么拆——也就是什么时候应该把东西提到 packages 里,什么时候应该先留在 apps 里。