Monorepo 实战指南:原理、选型与落地

November, 2nd 2025 9 min read
Monorepo 实战指南:pnpm/Turbo/Nx 与示例

写在前面(学习笔记)

  • 这是我边搭 monorepo 边做的记录:从最轻的 pnpm workspaces 开始,够用再加 Turbo/Nx。
  • 我更在意“能跑通 + 好维护”,所以例子以最小可用为主,后面再逐步增强(缓存、影响分析、发布)。
  • 如果只想抄作业:先按“仅 pnpm”跑通,再引入 Turbo 的 turbo.json,最后视复杂度考虑 Nx。

一句话版本:先简单起库,跑通 apps 依赖 packages;再加任务编排与缓存;最后把版本与发布(Changesets)纳入流程。

为什么选择 Monorepo?

  • 共享代码与类型:UI 组件、工具库、API SDK 一处维护,多处复用。
  • 一致的工程标准:统一 ESLint/TS/格式化/提交规范,降低认知负担。
  • 原子化改动:跨包重构单 PR 完成,避免多仓库同步困难。
  • 增量构建与缓存:只构建受影响的包,提速 CI/CD。

典型适用:多前端应用 + 共享包、多服务应用共享 schema/types、设计系统落地等。


目录结构基线

plaintext
12345678
      apps/
  web/           # Next.js 应用
  admin/         # 另一个前端或 BFF
packages/
  ui/            # 共享 UI 组件库(无 Next 依赖)
  api-sdk/       # 前后端共享 types/zod schema/客户端
  config/        # eslint/ts/biome 等集中配置
  tsconfig/      # ts 基线配置(可选)
    

原则:包边界清晰、依赖方向单向(apps 依赖 packages;packages 间尽量无环)。


方案对比与选型建议

  • 仅 pnpm workspaces:零上手成本,适合小型到中型;无内建任务编排/缓存。
  • pnpm + Turborepo:主流组合;声明任务依赖、远程缓存、影响分析,性价比高。
  • pnpm + Nx:特性最全(影响分析、图谱、生成器、插件生态);配置略重,适合复杂项目。

建议:

  • 小团队/中等规模:pnpm + Turbo。
  • 复杂依赖图/多语言/更强脚手架:Nx。

最小可用示例(一):仅 pnpm workspaces

根目录 package.json

json
1234567891011
      {
  "name": "acme-monorepo",
  "private": true,
  "packageManager": "pnpm@9",
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "build": "pnpm -r --filter ./packages/* run build && pnpm -r --filter ./apps/* run build",
    "dev": "pnpm -r --parallel --filter ./apps/* run dev",
    "test": "pnpm -r run test"
  }
}
    

pnpm-workspace.yaml

yaml
123
      packages:
  - 'apps/*'
  - 'packages/*'
    

共享 UI 包 packages/ui/package.json

json
12345678910
      {
  "name": "@acme/ui",
  "version": "0.1.0",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
  "scripts": { "build": "tsc -p tsconfig.build.json" },
  "peerDependencies": { "react": ">=18", "react-dom": ">=18" }
}
    

packages/ui/src/Button.tsx

tsx
12345
      import type { ButtonHTMLAttributes } from 'react';

export function Button(props: ButtonHTMLAttributes<HTMLButtonElement>) {
  return <button {...props} />;
}
    

packages/ui/src/index.ts

ts
1
      export * from './Button';
    

Next.js 应用依赖:apps/web/package.json

json
123456
      {
  "name": "web",
  "private": true,
  "dependencies": { "next": "^14", "react": "^18", "react-dom": "^18", "@acme/ui": "workspace:*" },
  "scripts": { "dev": "next dev", "build": "next build", "start": "next start" }
}
    

页面使用:apps/web/app/page.tsx

tsx
12345
      import { Button } from '@acme/ui';

export default function Page() {
  return <Button onClick={() => alert('Hi')}>Click</Button>;
}
    

示例(二):pnpm + Turborepo

安装 turbo 后新增 turbo.json

json
12345678
      {
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] },
    "test": { "dependsOn": ["^build"], "outputs": [] },
    "dev": { "cache": false }
  }
}
    

根脚本:

json
1234567
      {
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "dev": "turbo run dev --parallel"
  }
}
    

优势:声明式任务图、内置缓存与远程缓存(可接入 Vercel Remote Cache)。


示例(三):pnpm + Nx(可选)

nx.jsonproject.json 以包为单位描述任务;开启影响分析:

json
12345
      // nx.json
{
  "extends": "nx/presets/npm.json",
  "tasksRunnerOptions": { "default": { "runner": "nx/tasks-runners/default" } }
}
    

命令示例:

plaintext
12
      npx nx graph            # 交互式依赖图
npx nx affected -t build # 仅构建受影响的包
    

TypeScript 与边界治理

  • 基线配置:根放 tsconfig.base.json,packages 继承;开启 strictcomposite(便于增量、引用)。
  • 路径别名:仅在公共入口导出,禁止跨包内部路径导入。
  • ESLint 边界:使用 import/no-restricted-pathseslint-plugin-boundaries 限制导入层级。

示例 packages/ui/tsconfig.build.json

json
12345
      {
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "outDir": "dist", "declaration": true, "composite": true },
  "include": ["src/**/*"]
}
    

版本与发布(Changesets)

选择策略:

  • 固定版本(fixed):所有包同版本;对应用型 Monorepo 友好。
  • 独立版本(independent):库型更常见;改动最小化发布。

快速上手:

plaintext
12
      pnpm add -D @changesets/cli
npx changeset init
    

生成变更:

plaintext
123
      npx changeset        # 选择受影响的包与语义版本
npx changeset version # 生成新版本并更新依赖range
npx changeset publish # 发布到 npm(或内部 registry)
    

CI 提示:在 main 合并后由机器人 PR 触发 version/publish,保证可追溯。


CI/CD 与增量构建

GitHub Actions(Turbo 示例):

yaml
1234567891011121314
      name: ci
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build    # turbo run build(增量 + 缓存)
      - run: pnpm test
    

要点:

  • 任务图声明清晰,避免“脚本地狱”。
  • 使用远程缓存可显著提速 PR 校验。

独立部署与内部包策略(重要)

Monorepo 中的每个 app 都可以“独立部署”。packages 目录下的 typeszod schema、UI 组件等作为依赖参与构建/打包即可。关键在于“如何把内部包带进部署产物”。

两种常见模式:

  • 构建期内联(推荐)

    • 在 CI/平台以整个 monorepo 为上下文安装依赖并构建。
    • app 构建时将 packages/* 依赖编译并打包进产物(或按需 external)。
    • Vercel/Netlify/Cloudflare Pages 等都支持 monorepo 项目根安装 + 子目录构建。
  • 私有仓库发布

    • 用 Changesets 将内部包发布到私有 npm/内部 registry。
    • app 以普通依赖的形式引入已发布的包,可在完全隔离环境中独立构建/部署。

Next.js app 的关键配置(源码依赖需转译):

js
1234567
      // next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ['@acme/ui', '@acme/api-sdk'],
  experimental: { externalDir: true },
};
module.exports = nextConfig;
    

如果内部包输出构建物(dist),请在包内声明 exports 指向 dist,并在根流水线中先构建 packages:

json
1234567
      // packages/ui/package.json(片段)
{
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
  "scripts": { "build": "tsc -p tsconfig.build.json" }
}
    

Vercel 部署(示例):

  • Project Root: apps/web
  • Install Command: pnpm install(在仓库根执行)
  • Build Command: pnpm -C apps/web build(或保持默认)

按 app 限定依赖安装/构建(CI 加速):

plaintext
123
      pnpm install --filter ./apps/web...
pnpm -r --filter ./packages/* build
pnpm -C apps/web build
    

注意与避坑:

  • 源码未转译 → 使用 transpilePackages,或让包产出 dist 并通过 exports 暴露入口。
  • 跨包导入内部文件 → 严禁使用相对路径跨越包边界,只通过公共入口导出。
  • Edge 运行时约束 → 内部包避免 Node 专用 API;必要时通过条件导出区分 browser/node。
  • peerDependencies → 包若声明了 peer(如 react),app 需显式安装对应版本。

实战建议与避坑

  • 包边界先定律:packages/ui 不依赖 Next;api-sdk 不依赖具体运行时。
  • 第二次复用再抽象:避免过早把实现上提到 shared。
  • 保持“受影响分析”有效:适度拆包,避免大而全的巨型包。
  • 落地 ADR:记录架构决策(工具、版本策略、边界规范)。

小结

  • 选型从简单开始:pnpm → Turbo → Nx,逐步引入复杂度。
  • 用 Changesets 管版本与发布,用 Turbo/Nx 管任务与增量。
  • 清晰的包边界 + 统一工程化,让多人协作与长期演进更稳。