pnpm Workspaces 在生产环境:真正重要的是什么
Source: Dev.to
四个包,全部使用 TypeScript,总计约 8 k 行代码,已发布到 npm。
Node 22,pnpm 9,除 tsc 外没有其他构建工具。
我阅读了十几篇“monorepo 设置”文章——大多数用了 2000 字比较 Turborepo、Nx、Lerna,只有几段文字提到了在随机的星期二下午真正会出问题的东西。
下面是 星期二下午的那些问题。
两行配置
工作区配置 (pnpm-workspace.yaml)
# pnpm-workspace.yaml
packages:
- "packages/*"就是这样——packages/ 下的每个目录都是工作区包。
根 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= 在每个包中运行脚本。--parallel用于dev和clean,因为它们之间没有依赖关系。build顺序执行——一个包导入另一个包,所以必须先编译被依赖的包。pnpm 会自动确定正确的依赖顺序,确保依赖包始终在其依赖项之后构建。
我根本没有花时间在 Turborepo 和 Nx 之间做选择。pnpm -r 负责编排,tsc 负责编译。就是这么简单。
共享 tsconfig(真正节省时间的部分)
每篇关于 monorepo 的文章都会告诉你要创建一个共享的基础 tsconfig。它们是对的,但很少有人解释 哪些应该放在基础配置中,哪些应该放在每个包的配置中。
Base (tsconfig.base.json)
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": false,
"sourceMap": false
}
}Per‑package (packages/*/tsconfig.json)
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}rootDir和outDir属于每个包的配置,因为它们是相对路径。- 其他所有设置都是共享的。
在此之前,我有四个略有不同的 tsconfig,而且记不清哪个包含了 strictNullChecks。现在我只需修改一次设置,它就会在所有地方生效。
关于 TypeScript 项目引用?
我不使用它们。composite: true 在构建时提供跨包类型检查,但你必须在每个 tsconfig 中维护一个 references 数组,使其与依赖图保持同步,并处理会变陈旧并产生幻影错误的 tsBuildInfo 文件。
四个包,一个内部依赖——我只按顺序构建它们。如果我有十个包或构建时间从秒变成分钟,我可能会重新考虑。
workspace:* vs. workspace:^
当一个包在 monorepo 中依赖另一个包时:
{
"dependencies": {
"my-daemon": "workspace:*"
}
}在开发期间 这会创建一个 符号链接 —— 依赖的实时、始终最新的版本。无需重新构建。
在发布时 pnpm 会将 workspace:* 替换为实际的版本号,例如在发布的 package.json 中变为 "my-daemon": "0.2.7"。
注意点
我最初使用了 workspace:^(带插入符号)。发布后,依赖会变成 "^0.2.7"。这样消费者可能会安装到不匹配的 次要 版本。对于紧耦合的内部依赖,使用 workspace:* 进行精确的版本固定。
幽灵依赖会找上你
这件事让我花了整整一个下午。pnpm 默认使用 严格的 node_modules 结构:包只能访问它们明确声明的依赖。听起来不错——直到你发现一半的代码依赖于你根本不知道的幽灵依赖。
示例:
包 A 依赖 fastify。包 B 并未声明它,但 B 能导入 fastify,因为在 npm/yarn 中它被提升(hoisted)了。使用 pnpm 时,B 无法 导入它,从而暴露出缺失的声明。
我曾遇到一个 bug:某个包从 @types/ws 导入类型,却没有把它声明为 devDependency。在本地能够工作是因为另一个包已经安装了它,VS Code 通过工作区解析到了它。发布后,用户会看到:
error TS2307: Cannot find module 'ws' or its corresponding type declarations.解决方案
在每个工作区运行 pnpm why,确保每个 import 都有对应的声明:
$ cd packages/client && pnpm why @types/ws
# 什么都没有?这就是你的 bug
$ pnpm add -D @types/ws枯燥的工作,但如果提前处理好,就能避免一次尴尬的 npm 发布。
Source: …
Vitest + 工作区
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 则只会运行 server 包的测试。
注意:如果你的测试代码从兄弟包中导入内容,请确保先构建好该依赖。
Vitest 不会触发构建
我最终使用了一个在测试套件之前运行 pnpm -r build 的 pretest 脚本。
这很浪费——即使没有任何更改,它也会重新构建所有内容。
另一种情况是忘记构建,然后花 20 分钟调试类型不匹配的原因。
发布到 npm
没有 Changesets,也没有 Lerna。我手动提升版本,然后在每个包目录下运行 pnpm publish。
prepublishOnly 钩子
{
"scripts": {
"prepublishOnly": "pnpm build"
}
}如果没有这个钩子,你最终会发布过时的 dist/ 文件——甚至根本没有 dist/。我在第二次发布时发现了这个问题:npm info 显示 dist/ 文件夹是三次提交前的。我后来才意识到自己忘记在发布前构建。
显式 files 字段
{
"files": ["dist", "README.md"],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}我曾经发布过一个包,意外地把 src/ 目录、测试夹具以及一个 4 MB 的调试日志一起包含进去了。files 字段是一个白名单:只有列出的内容会被发布。
npm pack --dry-run 是你的好帮手——在每次发布前运行它,并仔细阅读输出。
我尝试过但不值得的东西
- Turborepo – 我的完整构建耗时不到 30 秒。远程缓存和智能任务调度解决了我根本没有的问题。
pnpm -r build就是整个构建系统。 - 单独的 ESLint‑config 包 – 对于四个包来说这是一堆繁文缛节。我把配置放在根目录,并通过相对路径引用它。
- “共享 utils” 包 – 里面只有三个函数。那不是一个包,而是一个文件。我把它删掉,并复制了两个真正共享的函数。抽象更少,符号链接的麻烦也更少。
- 同步版本号 – 我的客户端库和服务器以不同的节奏发布。强制两者都使用
v0.3.1意味着只能发布无实际内容的版本,仅仅是为了保持编号一致。
保持简洁
说了这么多,我一直在强调的一点是:保持简洁。
pnpm-workspace.yaml只需要两行。- 你的根目录
package.json只需要大约五个脚本。 - 如果你的 monorepo 需要一个 README 来解释它是怎么工作的,那就说明你走得太远了。
当出现故障时,别急着在 .npmrc 中使用 shamefully-hoist=true。先弄清楚到底是因为什么出错。十有八九是因为缺少依赖声明,而你的用户在安装你的包时也会遇到同样的问题。
在每个可发布的包里都加入 prepublishOnly 钩子。 我再三强调——将来的你很可能会忘记在发布前构建。这不是“是否会发生”的问题,而是一定会发生。
四个包,纯 pnpm,没有 Turborepo,也没有 Nx。如果构建时间成为问题,我会再添加相应的方案;目前还没有出现这种情况。