pnpm Workspaces Monorepo 生产环境踩坑实录

最近赶了个项目,用 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 就是在每个包里跑。devclean 加了 --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/**/*"]
}

rootDiroutDir 必须放在各个包里,因为是相对路径。其他全放 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。不是会不会的问题,是什么时候的问题。

相关推荐
zhensherlock3 小时前
Protocol Launcher 系列:一键唤起 Windsurf 智能 IDE
javascript·ide·vscode·ai·typescript·github·ai编程
十步杀一人_千里不留行3 小时前
TypeScript 里的 Type Guard 是什么
javascript·ubuntu·typescript
We་ct4 小时前
LeetCode 211. 添加与搜索单词 - 数据结构设计:字典树+DFS解法详解
开发语言·前端·数据结构·算法·leetcode·typescript·深度优先
zhensherlock4 小时前
Protocol Launcher 系列:一键唤起 VSCodium 智能 IDE
javascript·ide·vscode·typescript·开源·编辑器·github
阿懂在掘金5 小时前
Vue Asyncx 库三周年,回顾起源时的三十行代码
前端·typescript·开源
We་ct6 小时前
LeetCode 46. 全排列:深度解析+代码拆解
前端·数据结构·算法·leetcode·typescript·深度优先·回溯
猫头虎-前端技术6 小时前
这个项目需要Node 16,那个项目需要Node 18:如何解决多项目Node.js版本管理问题
前端·javascript·chrome·typescript·node.js·json·firefox
坐吃山猪7 小时前
TypeScript知识速览
前端·javascript·typescript
We་ct7 小时前
LeetCode 39. 组合总和:DFS回溯解法详解
前端·算法·leetcode·typescript·深度优先·个人开发·回溯