最近赶了个项目,用 pnpm workspaces 搭了个 monorepo。四个包,全 TypeScript,大概 8000 行,一周内从 v0.2 迭代到 v0.3 发到了 npm。Node 22,pnpm 9,构建工具只用 tsc,没有别的。
开搞之前我翻了十来篇 monorepo 的文章,大部分花两千字在比 Turborepo、Nx、Lerna 哪个好,真正讲日常会踩什么坑的内容少得可怜。
这篇就讲那些坑。
workspace 配置就两行
yaml
# pnpm-workspace.yaml
packages:
- "packages/*"
packages/ 下面每个目录就是一个 workspace 包。根目录的 package.json 只管编排:
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 就是在每个包里跑。dev 和 clean 加了 --parallel,因为它们之间没有依赖关系。但 build 不能并行------我有一个包 import 了另一个包,得先编译依赖。pnpm 会自己分析依赖顺序,按拓扑排序跑。
Turborepo 和 Nx 我一个都没用。pnpm -r 编排,tsc 编译,够了。
共享 tsconfig------真正省时间的地方
每篇 monorepo 文章都让你搞一个共享的 base tsconfig,这没错。但没人告诉你哪些配置放 base、哪些放各个包里。
我的 base:
json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": false,
"sourceMap": false
}
}
各个包 extends 它:
json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}
rootDir 和 outDir 必须放在各个包里,因为是相对路径。其他全放 base。
之前没做这个的时候,四个包四份 tsconfig,有一次我调了半天 bug,最后发现是其中一个包没开 strictNullChecks。统一 base 之后改一处全生效。
TypeScript Project References 要不要用?
我没用。composite: true 可以跨包做类型检查,但你得在每个 tsconfig 里维护一个 references 数组,而且要跟依赖图保持同步。tsBuildInfo 文件经常过期,出现莫名其妙的类型错误。
四个包,一个内部依赖,按顺序 build 就行。等包多到十几个、构建时间从秒级变成分钟级的时候再说。
workspace:* 和 workspace:^ 的区别
monorepo 里一个包依赖另一个包:
json
{
"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:
lua
error TS2307: Cannot find module 'ws' or its corresponding type declarations.
修起来不难,但挺丢人。
排查方法:在每个 workspace 下跑 pnpm why,确认每个 import 都有对应的声明:
bash
cd packages/client && pnpm why @types/ws
# 什么都没输出?那就是 bug
pnpm add -D @types/ws
这活很无聊,但能省掉一次尴尬的 npm publish。
Vitest 和 workspaces 配合
Vitest 有 workspace 功能,根目录配一下:
typescript
// vitest.workspace.ts
import { defineWorkspace } from "vitest/config";
export default defineWorkspace([
"packages/server",
"packages/client",
"packages/cli",
"packages/core",
]);
各个包的配置:
typescript
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 钩子是救命的
json
{
"scripts": {
"prepublishOnly": "pnpm build"
}
}
不加这个的话,你迟早会发布过期的 dist/ 文件。我第二次 publish 就干了这事------跑 npm info 看包的文件列表,dist 还是三个 commit 之前的。搞了好一会儿才反应过来是忘了 build。
files 白名单别忘了
json
{
"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。不是会不会的问题,是什么时候的问题。