陌上人如玉
公子世无双

10.9 Monorepo 工具链

10.9 Monorepo 工具链

一句话: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` 作为门禁

八、面试延伸

  1. 「为什么要用 monorepo?什么时候不该用?」
    收益四条:① ⭐ 原子提交——一次 PR 同时改「库 + 引用它的应用」,不可能出现「库改了应用没跟上」的中间态;② 共享代码零成本(不需要「发版 → 装包 → 等 registry」);③ 统一工具链(一份 lint/ts/CI 配置);④ 重构友好(全局改名一个 PR 搞定)。代价四条:构建复杂度、CI 变慢、边界容易失控、权限/发布粒度不如多仓。⭐ 什么时候不该用:只有「一个应用」没有共享包;团队之间几乎不共享代码;有强隔离/合规要求。判据是「是否存在『一次改动需要同时触及多个包』的常态」。

  2. 「pnpm 相比 npm/yarn 好在哪?」
    三点:① ⭐ 严格依赖(默认不 hoist)——只有「声明了依赖」的包才能在代码里 import,于是「幽灵依赖(phantom dependency)」在开发期就暴露(npm 会把依赖提升到根 node_modules,让「没声明的包」也能被 import 到,最终在「发布后/删掉那个包后」才炸);② 内容寻址存储 + 硬链接——同样的包版本在磁盘上只存一份,node_modules 体积显著更小;③ ⭐ workspace: 协议——workspace:* 在开发时链接到本地目录(改源码立刻生效),发布时自动替换成真实版本号。⚠️ 注意:从 npm/yarn 迁到 pnpm 时「突然报一堆找不到模块」,那不是 pnpm 的问题,而是暴露了原本就存在的不正确依赖声明(把那几个包补进 package.json 即可)。

  3. 「Turborepo 的缓存是怎么工作的?」
    核心是「给任务算一个输入哈希,哈希不变就直接还原产物、不执行命令」。哈希的输入包括:① 源码与声明的 inputs(src/**、package.json、配置文件…);② 依赖包的哈希(⭐ 所以「依赖的包改了」会级联触发重建);③ env 声明的环境变量;④ globalDependencies(根级配置)。缓存内容是 outputs 声明的文件/目录。⭐ 两个最常见的错误:outputs 漏声明(缓存命中时产物不存在——症状是「本地正常、CI 上找不到 dist」)、inputs/env 漏声明(改了配置或环境变量却命中旧缓存)。远程缓存(Vercel Remote Cache 或自建 S3/R2)能让「同事构建过的包,你直接命中」——这是 CI 从 10 分钟变 1 分钟的最大杠杆。

  4. 「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 历史才能算变更范围)。

  5. 「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 冲突)且配置繁琐。多数用打包器构建的项目选 ①(简单够用)。

  6. 「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 拦截)。

  7. 「共享包的构建产物应该怎么配?」
    五条:① ⭐ react/react-dom 是 peerDependencies 并在构建时 external(❌ 打进产物会导致「两份 React」);② 产出 ESM 并保留模块结构(preserveModules/多入口/tsup),否则破坏 tree-shaking;③ ⭐ sideEffects 写对(["*.css"]——写成 false 会把 CSS 删掉);④ exports 里每个入口都有 types(且路径与运行时一致);⑤ ⭐ files: ["dist"](只发产物,别把 src/测试发出去——除非你明确要走「发源码让用户自己编译」的路线,那要在文档里写清楚,并让用户配 transpilePackages)。另外:共享包应该极度克制依赖(用户引你的包时也在引你的依赖,见 10.8)。

  8. 「如果一个仓库有 50 个包,你会怎么组织?」
    四件事:① 分层目录——apps/(可部署应用)、packages/(可复用库)、tooling/(配置类)、可选 services/(后端);② ⭐ 用「依赖方向」约束而不是目录约定——共享包不能依赖应用(用 ESLint no-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"]。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.9 Monorepo 工具链

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫