npm 配了八年 monorepo,今天才发现 hoisting 埋了三颗雷——今天 pnpm workspaces 把这件事从根上修了

monorepo 跑久了,总有几个 bug 只在 CI 上复现、本地永远正常。最常见的情况是这样的:app-a 直接 import { foo } from "bar",本地跑得丝滑,一上 CI 就报 Cannot find module "bar"。查半天发现 bar 根本没写在 app-a 的 package.json 里,是被 npm 的 hoisting 机制提到根目录去了,运行时才炸。

这类问题有个专门的名字:phantom dependency(幽灵依赖)。npm workspaces 用了八年,这种暗雷埋得到处都是,只是大多数时候没有触发条件它就一直在暗处。本文说清楚三颗雷的根因,以及 pnpm workspaces 怎么从根上拆掉它们。

第一颗雷:hoisting 让子包偷偷用上了没声明的依赖

npm workspaces 的默认行为是把所有依赖都「提升」到 monorepo 根目录。好处是减少重复安装,坏处是每个子包都能读到根目录里提升上来的包,而不管自己有没有在 package.json 里声明。

这会产生两种经典 bug:

第一种:隐性跨包调用。 app-a 用了 lodash,但 package.json 里没写,因为 package-b 装了,lodash 被提升到了根目录,app-a 绕过去直接读到了。这类 bug 本地正常——根目录里确实有 lodash;但如果某个子包单独发布出去,lodash 就丢了。

第二种:幽灵传递依赖。 A 依赖 B,B 依赖 C,C 依赖 D@1.0,D 被提升到根目录。但当 B 升级后突然要 D@2.0,而 A 的另一条依赖链还在用 D@1.0,两个版本打架,npm 会随机选一个,谁也不知道选了哪个。

pnpm 的解法是严格的 node_modules 结构。每个包只能读到自己 package.json 里声明的依赖,symlink 直接指向 .pnpm 全局 store,不存在任何跨越声明边界的读取。

第二颗雷:npm 的 flat node_modules 在 CI 环境行为不一致

本地开发,npm install 在自己机器上跑;CI 上,runner 环境可能是不同的 Linux 发行版、不同的 Node 版本、不同权限配置。npm hoisting 的逻辑在某些边界条件下,不同平台表现不一致。

具体来说,同一个 monorepo,在本地 macOS 能正常跑,在 Linux CI 上可能就在某个包启动时报 MODULE_NOT_FOUND。这不是代码问题,是 npm hoisting 在不同平台对符号链接的处理差异导致的。

Railway 平台的工程师在生产环境里踩了这个坑,记录在案:本地开发完全正常,deploy 阶段开始报 phantom dependency 错误。根因是 Railway 用的是 Debian 基础镜像,npm 在上面做 symlink resolution 的行为和 macOS 有细微差异,提升到根目录的包在某些条件下变成了 broken symlink。

pnpm workspaces 的内容寻址存储(content-addressable store)完全不依赖平台级别的 symlink 行为。每个包版本在全局 store 里只存一份,通过硬链接(hard link)引用,跨平台行为完全一致。

第三颗雷:没有构建顺序约束导致 race condition

npm workspaces 没有内置的构建顺序保证。packages/a 依赖 packages/b,packages/b 依赖 packages/c,但如果跑 npm run build --workspaces,三个包可能同时开始构建,a 编译时 b 还没好,直接报错。

大多数团队的处理方式是在根目录的 package.json 里用手动排序的 prebuild/postbuild 脚本链来凑,或者干脆在 CI pipeline 里写三行 npm run build -w packages/c && npm run build -w packages/b && npm run build -w packages/a。时间长了,没人记得住依赖图,新增一个包就开始提心吊胆。

pnpm workspaces 内置 --filter 语法,能自动解析完整的依赖图,按拓扑顺序执行构建。pnpm --filter="[HEAD^1]" build 只会构建上一次 commit 改过的包,以及依赖它们的上游包,不需要手动指定顺序,也不需要额外的 turbo pipeline 配置。

pnpm workspaces 三步配置

第一步:在根目录建 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"

第二步:在根目录的 package.json 加 engines 约束

{
  "name": "my-monorepo",
  "private": true,
  "engines": {
    "node": ">=20",
    "pnpm": ">=9"
  }
}

第三步:内部包引用用 workspace: 协议

{
  "dependencies": {
    "@workspace/ui": "workspace:*",
    "@workspace/utils": "workspace:*"
  }
}

这样发布时 pnpm 会自动把 workspace:* 替换成真实版本号,开发时则指向本地包路径,完全无缝。

迁移路上的三个坑

坑一:phantom dependency 暴露是好事不是坏事。

刚迁到 pnpm,CI 开始报 ERR_PNPM_NO_MATCHING_VERSION。这不是 pnpm 坏了,是它把你项目中早就存在的幽灵依赖暴露出来了。逐个把报错的包加到对应 package.json 的 dependencies 里,这些是真正的依赖缺失,补上之后项目反而更健壮。

坑二:不要开 shamefully-hoist。

pnpm 默认 strict 模式,有些团队迁移时图省事在 .npmrc 里写 shamefully-hoist=true,这等于把 npm 的问题带进了 pnpm,重新埋雷。如果确实需要 hoisting(比如某些 ESLint 插件依赖全局可见的 node_modules),用 public-hoist-pattern[]=*eslint* 做精确指定,不要全量开启。

坑三:pnpm version 在 packageManager 字段锁定。

根目录 package.json 加一行:

"packageManager": "pnpm@9.0.0"

这样本地、CI、Dockerfile 全链路统一版本,避免 lockfile 格式在不同版本间不兼容。pnpm v9 改了 git 依赖解析逻辑,老锁文件在新版本上行为不同,提前锁定版本比事后排查省心得多。

实测数据

一个 50 个子包的 monorepo 迁移前后对比:

  • 安装时间:45 分钟 → 12 分钟
  • 磁盘占用:32 GB → 8 GB
  • node_modules 构建速度:提升 300%

另一个 20 包团队的反馈:迁移花了大约一周,迁移后几乎消除了所有依赖相关的「本地正常 CI 挂了」类型 bug。

三步下一步

  1. 跑 pnpm import 从现有的 yarn.lock 或 package-lock.json 生成 pnpm-lock.yaml,保留完整历史
  2. 先在 dev 环境跑一遍 pnpm install,看报哪些 phantom dependency——这些就是项目里早就存在的真实 bug
  3. CI 流水线里把 npm ci 替换成 pnpm install --frozen-lockfile,确认 CI 全部绿了再全团队切换

monorepo 依赖管理这件事,npm 的 hoisting 埋了八年雷,不是不能用,是代价一直在暗处。pnpm workspaces 的严格隔离不是什么魔法,只是把本该属于工程的问题提前暴露出来了——早暴露比晚暴露好。

评论区

0 条评论

登录后可评论。

阿柯·前端架构 16 阅读