
一句话:Monorepo 的价值是「原子提交 + 共享代码 + 统一工具链」,代价是「构建与 CI 的复杂度」;所以它的核心不是「把所有代码放一起」,而是「依赖声明清楚(pnpm workspace)+ 任务图与缓存(Turbo/Nx)+ 类型可解析(project references)+ 版本可追溯(changesets)」这四件事。
一、先想清楚:为什么要 monorepo(以及不要)
⭐⭐⭐ Monorepo 的四个真实收益:
① 【原子提交(atomic commit)】
一次 PR 里同时改「组件库 + 引用它的 3 个应用」→
⭐ 不可能出现「库改了但应用没跟上」的中间状态(多仓里必然出现)
② 【共享代码零成本】
抽一个 `packages/utils`,任何应用 `import { x } from '@acme/utils'` ——
不需要「发版 → 装包 → 等 registry」
③ 【统一工具链】
一份 ESLint/TS/Prettier 配置、一个 CI 流水线、统一的 Node/pnpm 版本
④ 【重构友好】
想要「全局把 `xx` 改名」→ 一个 PR 搞定(多仓要找 N 个仓库分别改)
⭐⭐ Monorepo 的四个真实代价(不认清就会翻车):
① 【构建复杂度】「谁依赖谁、先构建谁」需要任务图(Turbo/Nx 解决)
② 【CI 变慢】仓库变大后「全量构建+测试」跑不动 → 必须做「只跑受影响的包」
③ 【边界容易失控】任何人可以 import 任何包 → 需要「显式依赖声明 + lint 约束」
④ 【权限/发布粒度】多团队协作时,「谁能改哪个包」不如多仓清晰
⭐⭐⭐ 什么时候「不该」用 monorepo:
❌ 只有「一个应用」,没有共享包 → 用 monorepo 纯属增加复杂度
❌ 团队之间「几乎不共享代码」(完全独立的业务线)→ 多仓更简单
❌ 有「强隔离/合规」要求(不同仓库对应不同权限与发布节奏)
⭐ 判据:「**是否存在『一次改动需要同时触及多个包』的常态**」——
有 → monorepo 收益大;没有 → 多仓更省心。
二、工具全景:两个维度
⭐⭐ 选型看两个维度:「**包管理/链接**」与「**任务编排/缓存**」。它们是正交的。
【维度 1:包管理与 workspace 链接】
· **pnpm**(推荐)——严格依赖(默认不 hoist)+ 内容寻址存储 + workspace 协议
· npm workspaces——内置、简单,但「幽灵依赖」问题更明显
· yarn(berry)——PnP 模式(无 node_modules,但有生态兼容成本)
· bun——快,但生态还在成熟
【维度 2:任务编排与缓存】
· **Turborepo**(推荐,轻量)——声明 `tasks` 与 `dependsOn`,本地/远程缓存
· **Nx**(功能全)——任务图 + 代码生成器 + 依赖图可视化 + 插件生态
· lerna——历史产物(现在多与 Nx 一起用,或直接用 Nx/Turbo 替代)
· rush / bazel——大厂级(学习成本高)
⭐ 常见组合:**pnpm + Turborepo**(中小团队最省心)或 **pnpm + Nx**(需要更强约束与生成器)。
三、pnpm workspace:依赖声明要「说真话」
# ✅ pnpm-workspace.yaml
packages:
# 应用
- 'apps/*'
# 共享包
- 'packages/*'
# 工具链(eslint-config / tsconfig 等)
- 'tooling/*'
# ⚠️ 不要 include '**'(会把 node_modules 里的包也当成 workspace 包)
// ✅ 根 package.json —— 只放「跨包共享」的东西
{
"name": "acme-mono",
"private": true, // ⭐ 根包必须 private(不会被发布)
"packageManager": "pnpm@9.12.0", // ⭐⭐ 锁定包管理器版本(Corepack 会读它)
"engines": { "node": ">=20.10.0" },
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --parallel",
"test": "turbo run test",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "turbo run build --filter=./packages/* && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.27.0",
"turbo": "^2.3.0",
"typescript": "^5.6.0"
}
}
// ✅ packages/ui/package.json —— 共享包的依赖声明
{
"name": "@acme/ui",
"version": "3.2.1",
"private": false,
"type": "module",
"sideEffects": ["*.css"],
"files": ["dist"],
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./styles.css": "./dist/styles.css"
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"dev": "tsup src/index.ts --format esm --dts --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
// ⭐⭐ 用 workspace 协议:workspace 内「永远用本地版本」
"@acme/utils": "workspace:*",
"@acme/tokens": "workspace:^"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
⭐⭐⭐ `workspace:*` 协议的两个关键点(这是 pnpm 最实用的特性之一):
① 【开发时:直接链接到「本地目录」】
`"@acme/utils": "workspace:*"` → pnpm 在 `node_modules` 里建立
「指向 `packages/utils` 的符号链接」→ 改了源码立刻生效(无需发版)。
② 【发布时:自动替换成「真实版本号」】
`pnpm publish` 会把 `workspace:*` 替换成 `3.2.1` 这类具体版本
→ ⭐ 用户从 npm 装到的包「没有 workspace: 字样」(否则用户装不了)。
⚠️ 变体含义:
· `workspace:*` → 精确版本(`3.2.1`)
· `workspace:^` → `^3.2.1`(允许次版本)
· `workspace:~` → `~3.2.1`
⭐ 建议:**共享包之间用 `workspace:*` 或 `workspace:^`**,
让「本地开发」与「发布后」的行为可预期。
⭐⭐ 「幽灵依赖(phantom dependency)」——pnpm 严格模式的核心价值:
【npm/yarn 的 hoist 问题】
npm 会把「所有依赖」尽量提升到根 `node_modules`。
于是 `packages/a` 可以 `import lodash` ——即使它的 package.json 里**没写** lodash
(只是因为「别的包装了 lodash,被提升上来了」)。
⚠️ 后果:某天「那个别的包」删掉 lodash → 你的代码突然构建失败;
而且「发布出去的包」在用户机器上也报错(因为用户没装 lodash)。
【pnpm 的做法】
· 只把「声明了依赖的包」链接到该包的 `node_modules`
· 用「全局内容寻址存储 + 硬链接」节省磁盘
→ ⭐ 于是「没声明的依赖」直接 `import` 不到 → 开发期就暴露问题
⚠️ 但「迁移到 pnpm」时,很多老项目会「因为幽灵依赖而报错」——
这不是 pnpm 的问题,而是「暴露了原本就存在的错误依赖声明」。
✅ 修法:把那几个包补进 package.json。
# ✅ .npmrc(pnpm 的常用设置)
# ⭐ 让「安装可复现且严格」
strict-peer-dependencies=false # 安装时:peer 缺失只警告(true 会直接失败)
auto-install-peers=true # 自动安装 peer(减少「缺 peer」的报错)
# ⭐ 允许某些包「执行安装脚本」(pnpm 10 起默认禁止,这是安全特性)
onlyBuiltDependencies[]=esbuild
onlyBuiltDependencies[]=sharp
# ⭐ 如果某些工具「真的需要扁平化」
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
四、Turborepo:任务图与缓存
// ✅ turbo.json(Turborepo 2.x 用 `tasks`;1.x 是 `pipeline`)
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["tsconfig.base.json", ".env"],
"globalEnv": ["NODE_ENV", "CI"],
"tasks": {
"build": {
// ⭐ 构建前必须先「构建自己的依赖」(拓扑顺序)
"dependsOn": ["^build"],
// ⭐ 产物(用于「缓存命中时还原」+ 「输入指纹」的一部分)
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
// ⭐ 额外的输入(参与哈希:只要它们变了就重新构建)
"inputs": ["src/**", "package.json", "tsconfig.json", "tsup.config.ts"],
"env": ["NEXT_PUBLIC_*"] // ⭐ 环境变量进哈希(避免「改 env 但命中旧缓存」)
},
"typecheck": {
// ⭐ 类型检查不需要「先构建依赖」(用 project references 直接读源码)
"dependsOn": ["^typecheck"],
"outputs": [] // 无产物(只是「通过/失败」)
},
"lint": {
"dependsOn": [], // ⭐ 可与其它任务并行(不依赖拓扑)
"outputs": []
},
"test": {
"dependsOn": ["build"], // ⭐ 测试自己的构建产物
"outputs": ["coverage/**"]
},
"dev": {
"cache": false, // ⭐ dev 不缓存
"persistent": true // ⭐ 长驻任务(turbo 会并行跑它们)
},
"clean": {
"cache": false
}
}
}
⭐⭐⭐ Turborepo 的三个「必须理解」的概念:
① 【`dependsOn` 里的 `^` 前缀】
· `"dependsOn": ["^build"]` → 「**先构建我依赖的包**」(拓扑顺序)
⭐ 这是 monorepo 构建顺序的正确表达方式
· `"dependsOn": ["build"]`(**没有** `^`)→ 「**同一个包内**,build 前先跑 build」
(很少用;更常见的同包内依赖是 `test` 依赖 `build`)
② 【缓存命中 = 零工作】
turbo 计算「输入哈希」(源码 + package.json + env + globalDependencies):
· 哈希没变 → ⭐ **直接还原 `outputs` 里的产物,不执行命令**
· 哈希变了 → 执行命令 + 保存缓存
⭐ 所以「`outputs` 声明漏了」的后果是「缓存命中时产物缺失」
(典型症状:本地好好的,CI 上「找不到 dist」)
③ 【远程缓存(Remote Cache)】
把缓存上传到共享存储 → ⭐ 「同事构建过的包,你直接命中」
· 官方托管(Vercel Remote Cache)
· 或自建(S3/R2 兼容,配 `TURBO_TOKEN`/`TURBO_TEAM`)
⭐ 在 CI 里这是「把 10 分钟变 1 分钟」的最大杠杆。
# ✅ `--filter` 的常用语法(「只跑受影响的包」的核心)
turbo run build --filter=@acme/ui # 只构建这个包
turbo run build --filter=./packages/* # 只构建 packages 下的包
turbo run build --filter=@acme/web... # ⭐ 「@acme/web 及其所有依赖」
turbo run build --filter=...@acme/ui # ⭐ 「@acme/ui 及其所有『依赖它』的包」
turbo run build --filter=@acme/web...[HEAD^1] # ⭐ 只跑「相对上次提交被影响」的包
turbo run test --filter='[origin/main]' # ⭐ 只跑「与 main 相比发生变化的包」
五、TypeScript:三种引用方式与它们的代价
⭐⭐⭐ 「包之间怎么互相引用类型」有三种方式,各有代价:
① 【`paths` 直引源码】
tsconfig.base.json:
"paths": { "@acme/ui": ["./packages/ui/src/index.ts"] }
✅ 开发体验最好(跳转到源码、改源码立刻生效、无需先构建)
⚠️ 风险:应用构建时「把源码也编译进来」→
需要「应用侧的构建器支持编译 workspace 源码」(Vite 要 `optimizeDeps`/`transpilePackages`)
⭐ 适合:内部应用 + 共享包的组合(多数项目的实际选择)
② 【直引构建产物(dist)】
tsconfig 里不加 paths,靠 node_modules 里的 `dist/index.d.ts`
✅ 最贴近「发布后的真实情况」
⚠️ 必须先 `build` 才能有类型 → 开发时要开 watch
③ 【Project References(`composite`)】⭐ TS 官方的「大项目方案」
packages/ui/tsconfig.json:
{ "compilerOptions": { "composite": true }, "include": ["src"] }
apps/web/tsconfig.json:
{ "references": [{ "path": "../../packages/ui" }] }
✅ 增量构建(只有变了的包重新编译)+ 类型正确 + 边界清晰
⚠️ 需要 `tsc --build`(不能只用 `tsc --noEmit`)+ 配置更繁琐
// ✅ tsconfig.base.json(根配置,被所有包 extends)
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true, // ⭐ 索引访问返回 T | undefined
"exactOptionalPropertyTypes": true, // ⭐ 可选属性不允许显式 undefined
"verbatimModuleSyntax": true, // ⭐ type import 必须显式写 type
"isolatedModules": true, // ⭐ 保证「单文件转译」正确(esbuild/swc 需要)
"skipLibCheck": true,
"noEmit": true
}
}
// ✅ tooling/tsconfig/package.json —— 「共享 tsconfig」本身也是一个包
{
"name": "@acme/tsconfig",
"version": "0.0.0",
"private": true,
"files": ["base.json", "react.json", "node.json"]
}
// packages/ui/tsconfig.json
{
"extends": "@acme/tsconfig/react.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
⭐⭐ 一个「必踩」的坑:`tsc --noEmit` 与 `composite` 冲突
`composite: true` **要求**「产出声明文件」→ 与 `noEmit: true` 冲突。
⚠️ 症状:「开了 composite 之后 typecheck 脚本报错」。
✅ 做法:
· 用 project references 时:`tsc --build`(它会产出 `.tsbuildinfo` 与 `.d.ts`)
· 或者「不产出但保留增量信息」:
"composite": true, "noEmit": false, "emitDeclarationOnly": true
⭐ 很多团队的选择是「**不用 project references**,用 `paths` 直引源码 +
`tsc --noEmit` 做类型检查」——简单,且对「用打包器构建」的项目够用。
六、完整实现:一个 monorepo 的配置骨架(220 行)
acme-mono/
├── package.json # 根:private + scripts(走 turbo)+ packageManager
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.base.json
├── .npmrc
├── .changeset/
│ └── config.json
├── apps/
│ ├── web/ # @acme/web(Next.js / Vite)
│ └── admin/ # @acme/admin
├── packages/
│ ├── ui/ # @acme/ui(组件库)
│ ├── tokens/ # @acme/tokens(设计 token)
│ └── utils/ # @acme/utils(纯工具,无 React 依赖)
└── tooling/
├── tsconfig/ # @acme/tsconfig
└── eslint-config/ # @acme/eslint-config
// ✅ tooling/eslint-config/package.json(共享 lint 配置也是一个包)
{
"name": "@acme/eslint-config",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./react": "./react.js",
"./node": "./node.js"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-import-x": "^4.0.0"
},
"peerDependencies": { "eslint": "^9.0.0" }
}
// ✅ tooling/eslint-config/react.js(扁平的 ESLint 9 配置)
import ts from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{
files: ['**/*.{ts,tsx}'],
languageOptions: { parser: tsParser, parserOptions: { project: true } },
plugins: { '@typescript-eslint': ts, 'react-hooks': reactHooks },
rules: {
...ts.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
// ⭐⭐ 关键:强制「react-hooks/exhaustive-deps」为 error
// (这是「记忆化失效」与「闭包陷阱」的最后一道防线,见 5.8、2.13)
'react-hooks/exhaustive-deps': 'error',
// ⭐ 禁止「跨包深引」(必须走 exports 里的公开入口)
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@acme/*/src/*', '@acme/*/dist/*'],
message: '不要深引其它包的内部路径,请使用包的公开入口(见 package.json 的 exports)',
},
],
}],
},
},
// ⭐ 只允许 apps/* 引用「应用级依赖」,packages/* 不许引 app
{
files: ['packages/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{ group: ['@acme/web', '@acme/admin'], message: '共享包不能依赖应用' }],
}],
},
},
];
// ✅ packages/utils/eslint.config.js(每个包一份,内容很短)
import config from '@acme/eslint-config/react';
export default config;
// ✅ .changeset/config.json(版本与发布)
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"access": "public",
"baseBranch": "main",
// ⭐ 内部依赖升级策略:依赖的包升级时,自动给「依赖它的包」打 patch
"updateInternalDependencies": "patch",
// ⭐ 不发布的包(应用/工具链)要 ignore,否则 changeset 会给它们算版本
"ignore": ["@acme/web", "@acme/admin", "@acme/tsconfig", "@acme/eslint-config"]
}
# ✅ CI(GitHub Actions):缓存 + 只跑受影响的包
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # ⭐ 需要完整历史才能算「变更范围」
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm # ⭐ 缓存 pnpm 存储
- run: pnpm install --frozen-lockfile # ⭐ CI 必须用 frozen(保证可复现)
# ⭐⭐ Turborepo 的本地缓存(跨 CI 运行复用)
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: turbo-${{ runner.os }}-
# ⭐⭐ 只跑「相对 base 分支发生变化」的包(这是 CI 提速的核心)
- run: pnpm turbo run lint typecheck build test --filter='[origin/${{ github.base_ref || 'main' }}]'
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} # ⭐ 远程缓存(可选但强烈建议)
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
⭐⭐⭐ 这套骨架解决的五件事:
① 【依赖声明说真话】
`workspace:*` + pnpm 的严格模式 → 幽灵依赖在开发期就暴露
② 【任务顺序正确】
`dependsOn: ["^build"]` → 拓扑顺序构建(不用手写「先构建 utils 再构建 ui」)
③ 【缓存命中就不干活】
`outputs`/`inputs`/`env` 声明正确 → 本地与 CI 都能「秒过」
④ 【只跑受影响的包】
`--filter='[origin/main]'` → 改一个包就只跑相关的(⭐ 大仓库 CI 的关键)
⑤ 【版本可追溯】
changesets → 「哪个 PR 改了哪个包的什么版本」一目了然
七、本篇特有的坑
① pnpm-workspace.yaml 里写了 `**`(把 node_modules 里的包也当成 workspace 包)
✅ 精确写 glob(apps/*、packages/*、tooling/*)
② 根 package.json 没有 `private: true`(根包可能被误发布)
✅ `"private": true`
③ 没锁「包管理器版本」(不同人用 npm/yarn/pnpm → lock 文件打架)
✅ `"packageManager": "pnpm@9.12.0"` + 开启 Corepack
④ 共享包之间用「具体版本号」而不是 `workspace:*`
"dependencies": { "@acme/ui": "3.2.1" } // ⚠️ 改本地源码不生效(装的还是 registry 版本)
✅ "workspace:*"
⑤ 开发时「base tsconfig 的 exclude 没写」,把 dist 也纳入类型检查(报重复定义)
✅ 排除 dist / node_modules
⑥ 用 `paths` 直引源码,但应用构建器没配「转译 workspace 源码」
⚠️ 症状:应用构建报「JSX 语法错误」/「无法解析 .ts」
✅ Vite: `optimizeDeps.exclude` + `build.commonjsOptions`;Next: `transpilePackages: ['@acme/ui']`
⭐ 或者让包「先构建再被引用」(不直引源码)
⑦ `tsc --noEmit` 与 `composite: true` 冲突(typecheck 脚本报错)
✅ 二选一(project references 用 `tsc --build`;否则不用 composite)
⑧ turbo 的 `outputs` 漏声明(缓存命中时产物不存在)
⚠️ 典型症状:本地正常,CI 上「找不到 dist」
⑨ turbo 的 `inputs` 漏了「配置文件」,改了配置却命中旧缓存
✅ `inputs: ["src/**", "package.json", "tsup.config.ts", "tsconfig.json"]`
⑩ `env` 没声明(改了环境变量还命中旧缓存)
✅ 把「参与构建的环境变量」列进 `env`(支持通配如 `NEXT_PUBLIC_*`)
⑪ `dev` 任务没设 `"cache": false` + `"persistent": true`(turbo 卡住/重复启动)
✅ 见上文 turbo.json
⑫ 用 `**` 通配跑 turbo(`turbo run build` 无 filter)→ 每次都全量
✅ 用 `--filter`(尤其 `[origin/main]`)
⑬ CI 用 `pnpm install` 而不是 `--frozen-lockfile`(锁文件被意外改写)
✅ `--frozen-lockfile`(CI 里绝不允许改 lock)
⑭ changesets 的 `ignore` 漏了应用包(给「不该发布的包」算版本)
✅ 把所有 `private: true` 的包列进 ignore
⑮ 「共享包」用了「应用才会有的依赖」(如 next/react-router)
⚠️ 后果:共享包被别处引用时「意外要求装 next」
✅ 共享包只依赖「它真正需要的」(React 用 peer)
⑯ 「循环依赖」(a 依赖 b,b 又依赖 a)
⚠️ turbo 会报「circular dependency」;即使能跑,也会「构建顺序不确定」
✅ 抽「共用部分」到第三个包;或引入依赖图检查
# ✅ 实用的诊断命令
pnpm why react # ⭐ 为什么装了这个包(查重复依赖/幽灵依赖,见 5.14)
pnpm list --depth=0 -r # 每个 workspace 包的直接依赖
pnpm outdated -r # 所有包的过期依赖
pnpm dedupe # 尝试去重
turbo run build --graph # ⭐ 打印依赖图(定位循环依赖/顺序问题)
pnpm exec syncpack list-mismatches # ⭐ 检查「同一个依赖在不同包里版本不一致」
⭐⭐ 「依赖版本不一致」是 monorepo 里最隐蔽的问题之一:
packages/ui 用 `react@18`,apps/web 用 `react@19`:
⚠️ 后果:pnpm 会「安装两份 React」→ 运行时 **Invalid Hook call**
(因为 ui 的组件用的是 18 的 React 实例,而 web 用它自己的)
✅ 解法:
① 共享包统一用 **peerDependencies**(见 10.8)
② 用 **syncpack** 或 **pnpm catalog** 统一版本:
# pnpm-workspace.yaml(pnpm 9.5+ 的 catalog 特性)
catalog:
react: ^19.0.0
react-dom: ^19.0.0
→ 各包写 `"react": "catalog:"`
③ CI 里跑 `syncpack list-mismatches` 作为门禁
八、面试延伸
- 「为什么要用 monorepo?什么时候不该用?」
收益四条:① ⭐ 原子提交——一次 PR 同时改「库 + 引用它的应用」,不可能出现「库改了应用没跟上」的中间态;② 共享代码零成本(不需要「发版 → 装包 → 等 registry」);③ 统一工具链(一份 lint/ts/CI 配置);④ 重构友好(全局改名一个 PR 搞定)。代价四条:构建复杂度、CI 变慢、边界容易失控、权限/发布粒度不如多仓。⭐ 什么时候不该用:只有「一个应用」没有共享包;团队之间几乎不共享代码;有强隔离/合规要求。判据是「是否存在『一次改动需要同时触及多个包』的常态」。 -
「pnpm 相比 npm/yarn 好在哪?」
三点:① ⭐ 严格依赖(默认不 hoist)——只有「声明了依赖」的包才能在代码里import,于是「幽灵依赖(phantom dependency)」在开发期就暴露(npm 会把依赖提升到根node_modules,让「没声明的包」也能被 import 到,最终在「发布后/删掉那个包后」才炸);② 内容寻址存储 + 硬链接——同样的包版本在磁盘上只存一份,node_modules体积显著更小;③ ⭐workspace:协议——workspace:*在开发时链接到本地目录(改源码立刻生效),发布时自动替换成真实版本号。⚠️ 注意:从 npm/yarn 迁到 pnpm 时「突然报一堆找不到模块」,那不是 pnpm 的问题,而是暴露了原本就存在的不正确依赖声明(把那几个包补进package.json即可)。 -
「Turborepo 的缓存是怎么工作的?」
核心是「给任务算一个输入哈希,哈希不变就直接还原产物、不执行命令」。哈希的输入包括:① 源码与声明的inputs(src/**、package.json、配置文件…);② 依赖包的哈希(⭐ 所以「依赖的包改了」会级联触发重建);③env声明的环境变量;④globalDependencies(根级配置)。缓存内容是outputs声明的文件/目录。⭐ 两个最常见的错误:outputs漏声明(缓存命中时产物不存在——症状是「本地正常、CI 上找不到dist」)、inputs/env漏声明(改了配置或环境变量却命中旧缓存)。远程缓存(Vercel Remote Cache 或自建 S3/R2)能让「同事构建过的包,你直接命中」——这是 CI 从 10 分钟变 1 分钟的最大杠杆。 -
「monorepo 的 CI 怎么提速?」
四层递进:① 只跑受影响的包——⭐turbo run build --filter='[origin/main]'(只跑「相对 base 分支发生变化的包及其依赖者」),这是最大的杠杆;② 缓存——Turborepo 的本地缓存(actions/cache缓存.turbo)+ 远程缓存(跨机器/跨 CI 复用);③ 依赖安装——pnpm install --frozen-lockfile+ 缓存 pnpm store(setup-node的cache: pnpm);④ 并行——turbo 默认按任务图并行跑独立的分支,--parallel可让dev类任务一起跑。⭐ 还有一个前提:fetch-depth: 0(要完整 git 历史才能算变更范围)。 -
「monorepo 里包之间怎么互相引用(TypeScript 视角)?」
三种方式,各有代价:① ⭐paths直引源码("@acme/ui": ["./packages/ui/src/index.ts"])——开发体验最好(跳源码、改源码立刻生效、无需先构建),但需要「应用侧构建器支持转译 workspace 源码」(Vite 要optimizeDeps,Next 要transpilePackages);② 直引构建产物(靠node_modules里的dist/*.d.ts)——最贴近发布后的真实情况,但必须先 build(开发时要开 watch);③ ⭐ Project References(composite: true+references)——TS 官方的「大项目方案」,增量构建 + 边界清晰,但需要tsc --build(⚠️ 与noEmit: true冲突)且配置繁琐。多数用打包器构建的项目选 ①(简单够用)。 -
「monorepo 里最容易出的问题是什么?」
六个高频问题:① ⭐ 依赖版本不一致——如packages/ui装react@18而apps/web用react@19→ 两份 React →Invalid Hook call(✅ 共享包用peerDependencies+ 用 syncpack 或 pnpm catalog 统一版本 + CI 门禁);② 幽灵依赖(✅ pnpm 严格模式);③outputs漏声明导致缓存命中时产物缺失;④ 循环依赖(✅ turbo 会报 error,用--graph定位,抽公共部分到第三个包);⑤ 共享包依赖了应用才有的东西(如 next)→ 别处引用时被迫装一堆(✅ 只依赖真正需要的,React 用 peer);⑥ 深引内部路径(@acme/ui/src/internal)→ 破坏了「公开发布入口」的边界(✅ 用exports限制 + ESLint 的no-restricted-imports拦截)。 -
「共享包的构建产物应该怎么配?」
五条:① ⭐react/react-dom是peerDependencies并在构建时external(❌ 打进产物会导致「两份 React」);② 产出 ESM 并保留模块结构(preserveModules/多入口/tsup),否则破坏 tree-shaking;③ ⭐sideEffects写对(["*.css"]——写成false会把 CSS 删掉);④exports里每个入口都有types(且路径与运行时一致);⑤ ⭐files: ["dist"](只发产物,别把src/测试发出去——除非你明确要走「发源码让用户自己编译」的路线,那要在文档里写清楚,并让用户配transpilePackages)。另外:共享包应该极度克制依赖(用户引你的包时也在引你的依赖,见 10.8)。 -
「如果一个仓库有 50 个包,你会怎么组织?」
四件事:① 分层目录——apps/(可部署应用)、packages/(可复用库)、tooling/(配置类)、可选services/(后端);② ⭐ 用「依赖方向」约束而不是目录约定——共享包不能依赖应用(用 ESLintno-restricted-imports强制)、底层包(utils/tokens)不能依赖 UI 包,⭐ 并启用「依赖图检查」(循环依赖直接 CI 失败);③ ⭐ 统一版本策略——用 pnpm catalog 或 syncpack 让「同一个依赖在所有包里版本一致」(避免「两份 React」);④ 按变更频率分层缓存——「很少变的底层包」与「天天改的业务包」分开,配合 turbo 的远程缓存,让「改业务包」时底层包直接命中缓存。⭐ 核心原则:「依赖是一张有向无环图,工具要能『读懂』它」。
一句话速记
Monorepo 的收益是「原子提交 + 共享代码零成本 + 统一工具链 + 重构友好」,代价是「构建复杂度 + CI 变慢 + 边界失控 + 权限粒度」,⭐ 判据是「是否存在『一次改动触及多个包』的常态」;技术栈是「pnpm workspace(严格依赖 +
workspace:*)+ Turborepo(任务图 + 缓存)」——workspace:*开发时链接本地目录、发布时自动替换成真实版本;pnpm 的严格模式让「幽灵依赖」在开发期暴露(迁过去一堆报错是「暴露了原本的错误声明」);Turborepo 的三个概念:dependsOn: ["^build"](拓扑顺序)、outputs/inputs/env(缓存哈希与还原,⭐ 漏声明会导致「CI 上产物缺失」或「改了却命中旧缓存」)、远程缓存(最大杠杆);CI 提速四层:--filter='[origin/main]'(只跑受影响的)→ 远程缓存 →--frozen-lockfile+ pnpm store 缓存 → 并行(⚠️ 要fetch-depth: 0);TS 引用三方式(paths直引源码最常用、直引 dist、project references)⚠️composite与noEmit冲突;最容易出的问题是「依赖版本不一致 → 两份 React → Invalid Hook call」(✅ peer + syncpack/catalog)与「深引内部路径」(✅exports+ ESLint 拦截);共享包构建五条:react 必须 peer/external、产出 ESM 保留模块结构、sideEffects写对、类型跟exports走、files: ["dist"]。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!