pnpm Workspaces Monorepo 生产环境踩坑实录

25 阅读5分钟

最近赶了个项目,用 pnpm workspaces 搭了个 monorepo。四个包,全 TypeScript,大概 8000 行,一周内从 v0.2 迭代到 v0.3 发到了 npm。Node 22,pnpm 9,构建工具只用 tsc,没有别的。

开搞之前我翻了十来篇 monorepo 的文章,大部分花两千字在比 Turborepo、Nx、Lerna 哪个好,真正讲日常会踩什么坑的内容少得可怜。

这篇就讲那些坑。

workspace 配置就两行

# pnpm-workspace.yaml
packages:
  - "packages/*"

packages/ 下面每个目录就是一个 workspace 包。根目录的 package.json 只管编排:

{
  "private": true,
  "scripts": {
    "build": "pnpm -r build",
    "dev": "pnpm -r --parallel dev",
    "test": "pnpm -r test",
    "clean": "pnpm -r --parallel exec rm -rf dist"
  },
  "engines": {
    "node": ">=22",
    "pnpm": ">=9"
  }
}

-r 就是在每个包里跑。devclean 加了 --parallel,因为它们之间没有依赖关系。但 build 不能并行——我有一个包 import 了另一个包,得先编译依赖。pnpm 会自己分析依赖顺序,按拓扑排序跑。

Turborepo 和 Nx 我一个都没用。pnpm -r 编排,tsc 编译,够了。

共享 tsconfig——真正省时间的地方

每篇 monorepo 文章都让你搞一个共享的 base tsconfig,这没错。但没人告诉你哪些配置放 base、哪些放各个包里。

我的 base:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": false,
    "sourceMap": false
  }
}

各个包 extends 它:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}

rootDiroutDir 必须放在各个包里,因为是相对路径。其他全放 base。

之前没做这个的时候,四个包四份 tsconfig,有一次我调了半天 bug,最后发现是其中一个包没开 strictNullChecks。统一 base 之后改一处全生效。

TypeScript Project References 要不要用?

我没用。composite: true 可以跨包做类型检查,但你得在每个 tsconfig 里维护一个 references 数组,而且要跟依赖图保持同步。tsBuildInfo 文件经常过期,出现莫名其妙的类型错误。

四个包,一个内部依赖,按顺序 build 就行。等包多到十几个、构建时间从秒级变成分钟级的时候再说。

workspace:* 和 workspace:^ 的区别

monorepo 里一个包依赖另一个包:

{
  "dependencies": {
    "my-daemon": "workspace:*"
  }
}

开发时 pnpm 会创建一个 symlink,始终指向本地最新版本,不用重新构建。

发布到 npm 时,pnpm 自动把 workspace:* 替换成实际版本号。比如 "my-daemon": "workspace:*" 变成 "my-daemon": "0.2.7"

坑在这里:我一开始用的是 workspace:^(带 caret)。发布之后变成了 "^0.2.7",用户可能装到不同的 minor 版本。两个紧耦合的内部包版本不一致,各种诡异问题。

结论:内部紧耦合的依赖用 workspace:*,锁定精确版本。

幽灵依赖迟早找上你

这个坑花了我整整一个下午。

pnpm 默认用严格的 node_modules 结构——每个包只能访问自己 package.json 里声明的依赖。这个设计很好,但问题是你可能一直在不知不觉地依赖幽灵依赖。

什么意思?包 A 声明了 fastify,包 B 没声明。在 npm 或 yarn 下面,hoisting 会把 fastify 提升到根目录,包 B 也能 import。你不会发现任何问题。直到你 publish 到 npm,用户装你的包时报错。

我遇到的真实 case:有一个包 import 了 @types/ws 的类型,但没在 devDependencies 里声明。本地跑没问题——另一个包装了这个类型定义,VS Code 通过 workspace 解析到了。发到 npm 两天后收到 issue:

error TS2307: Cannot find module 'ws' or its corresponding type declarations.

修起来不难,但挺丢人。

排查方法:在每个 workspace 下跑 pnpm why,确认每个 import 都有对应的声明:

cd packages/client && pnpm why @types/ws
# 什么都没输出?那就是 bug
pnpm add -D @types/ws

这活很无聊,但能省掉一次尴尬的 npm publish。

Vitest 和 workspaces 配合

Vitest 有 workspace 功能,根目录配一下:

// vitest.workspace.ts
import { defineWorkspace } from "vitest/config";

export default defineWorkspace([
  "packages/server",
  "packages/client",
  "packages/cli",
  "packages/core",
]);

各个包的配置:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    testTimeout: 10_000,
    restoreMocks: true,
  },
});

pnpm test 从根目录跑全部测试,pnpm --filter server test 只跑某一个包。

注意一个问题:如果测试 import 了兄弟包的代码,Vitest 不会自动触发构建。我最后在 pretest 里加了 pnpm -r build,每次跑测试前先全量构建一遍。浪费是浪费了,但总比忘记构建然后花二十分钟排查类型不匹配要好。

发布到 npm 的几个教训

我没用 Changesets,没用 Lerna。手动 bump 版本号,从各个包目录 pnpm publish

prepublishOnly 钩子是救命的

{
  "scripts": {
    "prepublishOnly": "pnpm build"
  }
}

不加这个的话,你迟早会发布过期的 dist/ 文件。我第二次 publish 就干了这事——跑 npm info 看包的文件列表,dist 还是三个 commit 之前的。搞了好一会儿才反应过来是忘了 build。

files 白名单别忘了

{
  "files": ["dist", "README.md"],
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}

我有一次 publish 把 src/ 目录、测试 fixture、还有一个 4MB 的 debug log 全打包进去了。files 字段是白名单机制,只有列出来的才会被发布。

每次 publish 之前跑一遍 npm pack --dry-run,认真看一下输出。

试了但没必要的东西

Turborepo:试了一天,删了。我整个 monorepo 构建不到 30 秒。Remote caching 和 smart task scheduling 解决的是我没有的问题。

内部 eslint-config 包:四个包搞一个 packages/eslint-config,太重了。eslint 配置放根目录,各个包用相对路径引用就行。

shared-utils 包:一开始提取了一个"公共工具"包,里面三个函数。这不叫包,这叫一个文件。后来删了,直接在需要的地方复制那两个真正共用的函数。少一层抽象,少一堆 symlink 问题。

统一版本号:client 库和 server 发布节奏不一样,强行统一成 v0.3.1 意味着要发空版本凑号。放弃了。

最后

跑下来就一个感受:别搞复杂。pnpm-workspace.yaml 两行就够了。根目录 package.json 五个 script。如果你的 monorepo 配置需要写一篇 README 来解释怎么用,那就过度了。

遇到问题别急着加 shamefully-hoist=true。搞清楚为什么报错。十次里有九次是某个包少声明了一个依赖,你的用户迟早也会踩到同样的问题。

还有,每个要发布的包都加上 prepublishOnly 钩子。未来的你一定会忘记 build 就 publish。不是会不会的问题,是什么时候的问题。