Monorepo 单仓库管理

目录

前言

前端真正实现 Monorepo,不是"把代码放一起",而是:

用统一依赖 + 强包边界 + 增量构建 + 独立发布 + 工程规范,构建一个可规模化演进的工程系统。

为什么说 Monorepo 是「高级前端分水岭」

你可以明显区分:

水平 特征
初级 能用 Monorepo
中级 能维护 Monorepo
高级 能设计 Monorepo
架构 能限制 Monorepo 失控

真正难的不是搭,而是:

  • 包怎么拆
  • 边界怎么定
  • 未来怎么演进

一、什么才叫「真正的 Monorepo」

❌ 不是:

  • 把多个项目丢进一个 Git 仓库
  • packages/* + npm install 就完事
  • 只解决了「代码放一起」,没解决「协作 & 规模」

真正的 Monorepo = 一个工程系统

  • 用一个仓库,统一管理:代码、依赖、构建、发布、规范、协作与演进

核心判断标准(是否"真 Monorepo"):

维度 必须具备
依赖管理 统一依赖树、避免版本地狱
包边界 明确 public API、禁止随意互相 import
构建体系 能只构建"受影响的包"
发布体系 包级别独立发布
工程规范 lint / tsconfig / commit 统一
协作效率 改一处,影响可控

少任何一项,都只是"假 Monorepo"

二、前端 Monorepo 的 6 大核心能力

这是从工程本质上拆解 Monorepo:

  • Workspace:统一依赖管理(地基)
  • Package Boundary:包边界治理(灵魂)
  • 构建系统:只构建"受影响的包"
  • 发布体系:包级别独立发布
  • 工程规范统一(隐形价值最大)
  • 应用层组织(Apps vs Packages)

1、Workspace:统一依赖管理(地基)

为什么这是第一步?

  • 避免 react@17、react@18 共存
  • 避免 node_modules 爆炸
  • 为「包间互相引用」提供基础能力

主流方案:

工具 推荐度 说明
pnpm workspaces ⭐⭐⭐⭐⭐ 目前事实标准
yarn workspaces ⭐⭐⭐ 稳定但性能一般
npm workspaces ⭐⭐ 功能够用但弱

结论:企业级前端 = pnpm

typescript 复制代码
# pnpm-workspace.yaml
packages:
  - apps/*
  - packages/*

2、Package Boundary:包边界治理(灵魂)

Monorepo 失败 80% 是因为:包之间可以乱引用

错误示例:

typescript 复制代码
import xxx from '../../other-package/src/xxx'

正确原则:

  • 包只能通过 package name 引用
  • 只能使用 exports 暴露的 API
typescript 复制代码
{
  "name": "@acme/utils",
  "exports": {
    ".": "./dist/index.js"
  }
}

强制手段(非常关键):

  • ESLint:禁止跨包相对路径
  • TypeScript project references
  • API boundary 校验(很多大厂自研)

3、构建系统:只构建"受影响的包"

这是 Monorepo 与多仓的分水岭。

❌ 天真实现

typescript 复制代码
pnpm -r build   # 全量构建

✅ 真·Monorepo 构建

  • 只 build 被改动的包 + 依赖它的包

主流构建调度器:

工具 适用场景
Turborepo 前端首选
Nx 全栈 / Angular / 大系统
Lage 微软系
typescript 复制代码
// turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

这是"规模化"的核心

4、发布体系:包级别独立发布

Monorepo ≠ 一起发版本。

两种模式:

  • 独立版本(推荐)
typescript 复制代码
@acme/utils@1.2.3
@acme/ui@0.9.0
  • 统一版本(适合强耦合)
typescript 复制代码
@acme/*@1.5.0

工具链:

  • changesets(事实标准)
  • semantic-release(自动化)
typescript 复制代码
pnpm changeset
pnpm changeset version
pnpm changeset publish

5、工程规范统一(隐形价值最大)

统一内容包括:

  • ESLint / Prettier
  • TSConfig 基础配置
  • commitlint
  • husky
  • lint-staged

推荐做法:

  • 抽一个 @acme/config 包
typescript 复制代码
packages/
  eslint-config/
  tsconfig/
  prettier-config/

6、应用层组织(Apps vs Packages)

这是真正工程化的目录结构:

typescript 复制代码
repo/
├─ apps/            # 最终交付物
│  ├─ admin
│  └─ web
├─ packages/        # 可复用能力
│  ├─ ui
│  ├─ utils
│  ├─ hooks
│  └─ config
  • apps:不能被别的包依赖
  • packages:可以被 apps / packages 依赖

三、一个「企业级前端 Monorepo」方案与实践

1、具体方案

React + TypeScript + pnpm + Turborepo

技术栈组合(推荐):

领域 选择
Workspace pnpm
构建调度 Turborepo
发布 changesets
CI GitHub Actions
包规范 exports + boundary lint
类型 TS project references

关键链路(非常重要):

2、方案实践

技术栈:React + TypeScript + pnpm + Turborepo + Changesets + GitHub Actions

(1)、最终工程目录(先给全貌)

typescript 复制代码
monorepo/
├─ apps/
│  └─ web/                    # React 应用
│     ├─ src/
│     ├─ package.json
│     ├─ tsconfig.json
│     └─ vite.config.ts
│
├─ packages/
│  ├─ ui/                     # 组件库
│  │  ├─ src/
│  │  ├─ package.json
│  │  ├─ tsconfig.json
│  │  └─ vite.config.ts
│  │
│  ├─ utils/                  # 工具库
│  │  ├─ src/
│  │  ├─ package.json
│  │  └─ tsconfig.json
│  │
│  └─ tsconfig/               # 统一 TS 配置
│     ├─ base.json
│     └─ package.json
│
├─ .changeset/
├─ .github/workflows/ci.yml
├─ turbo.json
├─ pnpm-workspace.yaml
├─ tsconfig.json
├─ package.json
└─ pnpm-lock.yaml

这是"真·企业级 Monorepo"的标准骨架

(2)、Workspace & 根配置(地基)

①、pnpm-workspace.yaml 和 根 package.json

pnpm-workspace.yaml:

typescript 复制代码
packages:
  - apps/*
  - packages/*

根 package.json:

typescript 复制代码
{
  "name": "acme-monorepo",
  "private": true,
  "packageManager": "pnpm@8.15.0",
  "scripts": {
    "dev": "turbo run dev --parallel",
    "build": "turbo run build",
    "lint": "turbo run lint",
    "changeset": "changeset",
    "version": "changeset version",
    "release": "changeset publish"
  },
  "devDependencies": {
    "turbo": "^1.12.0",
    "typescript": "^5.3.3",
    "@changesets/cli": "^2.27.1"
  }
}
②、Turborepo 构建调度(核心)

turbo.json:

typescript 复制代码
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false
    },
    "lint": {},
    "test": {}
  }
}

关键点:

  • ^build:依赖先构建
  • outputs:构建缓存的关键

(3)、TypeScript Project References(企业级必备)

根 tsconfig.json:

typescript 复制代码
{
  "files": [],
  "references": [
    { "path": "./packages/utils" },
    { "path": "./packages/ui" },
    { "path": "./apps/web" }
  ]
}

packages/tsconfig/base.json:

typescript 复制代码
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "jsx": "react-jsx",
    "declaration": true,
    "composite": true,
    "skipLibCheck": true
  }
}

(4)、一个真实的 Package:@acme/utils

packages/utils/package.json:

typescript 复制代码
{
  "name": "@acme/utils",
  "version": "0.1.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsc -b",
    "lint": "echo lint utils"
  }
}

packages/utils/tsconfig.json:

typescript 复制代码
{
  "extends": "../tsconfig/base.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"],
  "references": []
}

packages/utils/src/index.ts:

typescript 复制代码
export function sum(a: number, b: number) {
  return a + b;
}

(5)、一个真实的 UI 包:@acme/ui

packages/ui/package.json:

typescript 复制代码
{
  "name": "@acme/ui",
  "version": "0.1.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": "./dist/index.js"
  },
  "scripts": {
    "build": "vite build",
    "dev": "vite",
    "lint": "echo lint ui"
  },
  "dependencies": {
    "@acme/utils": "workspace:*",
    "react": "^18.2.0"
  }
}

packages/ui/src/index.tsx:

typescript 复制代码
import { sum } from '@acme/utils';

export function Button() {
  return <button>1 + 2 = {sum(1, 2)}</button>;
}

(6)、App 使用 Package(真实依赖链)

apps/web/package.json:

typescript 复制代码
{
  "name": "web",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "@acme/ui": "workspace:*",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

apps/web/src/main.tsx:

typescript 复制代码
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Button } from '@acme/ui';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <Button />
);

这里已经完成了:

  • workspace 本地联调
  • 包级依赖
  • 严格边界(只能用 exports)

(7)、Changesets:真实发布链路

初始化:

typescript 复制代码
pnpm changeset

生成:

typescript 复制代码
---
"@acme/utils": minor
---

Add sum util

版本 & 发布:

typescript 复制代码
pnpm version
pnpm release

这是企业里 90% 团队在用的发布方式

(8)、CI(GitHub Actions)

typescript 复制代码
.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v3
        with:
          version: 8

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: pnpm

      - run: pnpm install
      - run: pnpm build

(9)、这套方案为什么「真·企业级」

你现在这套已经具备:

能力 是否具备
Workspace 依赖
强包边界(exports)
增量构建
构建缓存
独立版本发布
CI 自动校验
TS 工程引用

已经不是"Demo",而是"工程系统"

四、eslint 强制包边界(禁止跨包 src 引用)

  • Monorepo 的本质不是共享代码,而是"约束共享"。
  • ESLint 包边界规则,就是前端架构的法律系统。

1、问题本质:为什么必须禁止跨包 src 引用

没有"包边界强约束"的 Monorepo,一定会在 6~12 个月内失控。

❌ 典型失控写法(非常常见):

typescript 复制代码
// apps/web/src/pages/Home.tsx
import { sum } from '../../packages/utils/src/index';

这会导致什么?

问题 后果
绕过 exports 包 API 形同虚设
强耦合内部实现 utils 重构直接炸 app
构建不可控 Turborepo 缓存失效
发布不可预测 改私有代码却没 bump 版本

Monorepo 一旦允许这种写法,本质退化成"一个巨大项目"

✅ 正确的 Monorepo 包依赖原则:

  • 包与包之间,只能通过「包名 + exports」通信
typescript 复制代码
import { sum } from '@acme/utils'; // ✅ 唯一合法方式

2、企业级解决思路(不是只有 ESLint)

真实世界里我们是 三层防线:

层级 目的
ESLint 开发期立即报错
TS References 类型 & 构建期隔离
构建工具 依赖图 & 缓存正确

你现在问的是 第一层(也是最重要的一层)。

3、方案一(最稳):eslint-plugin-boundaries (⭐⭐⭐⭐⭐)

这是目前前端 Monorepo 最成熟、最工程化的方案

(1)、安装依赖(根目录)

typescript 复制代码
pnpm add -D eslint eslint-plugin-boundaries
(2)、定义 Monorepo 的"架构规则"

.eslintrc.cjs(根目录):

typescript 复制代码
module.exports = {
  root: true,
  plugins: ['boundaries'],
  settings: {
    'boundaries/elements': [
      {
        type: 'app',
        pattern: 'apps/*',
      },
      {
        type: 'package',
        pattern: 'packages/*',
      },
    ],
  },
  rules: {
    /**
     * 核心规则:
     * - app 可以依赖 package
     * - package 只能依赖 package
     * - 禁止跨 src 直连
     */
    'boundaries/element-types': [
      'error',
      {
        default: 'disallow',
        rules: [
          {
            from: 'app',
            allow: ['package'],
          },
          {
            from: 'package',
            allow: ['package'],
          },
        ],
      },
    ],

    /**
     * 禁止相对路径穿越包
     */
    'no-restricted-imports': [
      'error',
      {
        patterns: ['../*', '../../*', '../../../*'],
      },
    ],
  },
};

(3)、实际效果(你会看到的报错)

❌ 非法写法:

typescript 复制代码
import { sum } from '../../utils/src';

ESLint 直接红:

typescript 复制代码
❌ boundaries/element-types
Importing elements of type "package" is not allowed from "app"

开发期直接阻断,而不是等到 CI

4、方案二(补充):只允许通过 package name 引用

这是很多大厂都会再加的一道"硬规则"。

只允许 @acme/* 形式:

typescript 复制代码
'no-restricted-imports': [
  'error',
  {
    paths: [
      {
        name: '../utils',
        message: '❌ 禁止相对路径引用包,请使用 @acme/utils',
      },
    ],
    patterns: [
      {
        group: ['../../packages/*'],
        message: '❌ 禁止跨包 src 引用',
      },
    ],
  },
];

5、方案三(进阶):配合 TS exports + path 限制

packages/utils/package.json:

typescript 复制代码
{
  "name": "@acme/utils",
  "exports": {
    ".": "./dist/index.js"
  }
}

结果:

typescript 复制代码
import '@acme/utils/src/foo'; // ❌ TS + Node 直接报错

ESLint + TS + Node 三重封锁

6、真实企业中的"包依赖白名单"模型(进阶)

在大型团队中,常见规则是:

包类型 可依赖
app ui / utils / hooks
ui utils
utils ❌ 不依赖任何内部包

boundaries 高级写法:

typescript 复制代码
'boundaries/element-types': [
  'error',
  {
    default: 'disallow',
    rules: [
      { from: 'app', allow: ['ui', 'utils'] },
      { from: 'ui', allow: ['utils'] },
      { from: 'utils', allow: [] },
    ],
  },
];

这是"架构级 ESLint"

7、为什么我说这一步是 Monorepo 的"生死线"

如果没有这套规则,Monorepo 一定会:

  • 包越来越难拆
  • 改动影响面不可控
  • 发布开始靠"拍脑袋"

如果有了这套规则:

  • 包 = 微型库
  • API = 合同
  • 重构 = 可预测行为

五、Monorepo 拆包方法论(怎么拆不会后悔)

Monorepo 失败,90% 不是工具问题,而是"拆包决策错误"。

  • Monorepo 拆包不是"拆得细",而是"拆得稳"。
  • 变化率越低、边界越清晰、越不依赖应用层的能力,越值得拆成包。

1、先给结论:拆包的第一性原则(必须背)

拆包的单位不是"技术",而是"变化率 + 责任边界"。

两个关键问题(拆前必问):

  • 这个能力变化频率高吗?
  • 谁对它负责?
情况 是否该拆
高频变化 + 多人修改 ❌ 不要拆
低频变化 + 被多人依赖 ✅ 必拆
高频变化 + 单一负责人 ⚠️ 谨慎
低频变化 + 清晰边界 ⭐ 最优

2、Monorepo 拆包的"正确顺序"(非常重要)

顺序错了,后面一定后悔

✅ 推荐顺序(从最稳到最不稳):

typescript 复制代码
️① 工具类(utils)
②️ 基础 UI(ui)
️③ 通用 Hooks
️④ 领域 SDK(domain)
️⑤ 应用壳(apps)

❌ 最容易犯的错误顺序:

typescript 复制代码
先拆业务 → 再拆基础 → 最后发现全是循环依赖

3、真正"值得拆"的 6 类包(企业级清单)

(1)、utils(几乎 100% 不会后悔)

特征:

  • 无状态
  • 无 React 依赖
  • 可纯函数
typescript 复制代码
export function formatMoney() {}
export function debounce() {}

规则:

  • utils 不能依赖任何内部包

(2)、ui(高复用、低业务)

✅ 正确 ui 包长这样:

typescript 复制代码
Button
Modal
Table

❌ 错误 ui:

typescript 复制代码
UserTable
OrderModal

判断标准:

  • 看名字是否带业务词
  • 是否需要请求接口

(3)、hooks(抽"行为",不是"页面逻辑")

✅ 正确:

typescript 复制代码
useDebounce
useEventListener
useLocalStorage

❌ 错误:

typescript 复制代码
useUserList
useOrderDetail

凡是 hook 里出现"业务名",你就该警惕

(4)、domain(90% 团队拆错的地方)

domain 包 = 前端的"业务 SDK"

正确 domain 包包含:

  • API 封装
  • DTO / VO 类型
  • 业务校验
  • 状态机
typescript 复制代码
packages/user-domain
  ├─ api.ts
  ├─ types.ts
  ├─ rules.ts

domain 的铁律:

  • ❌ 不依赖 React
  • ❌ 不依赖 UI
  • ✅ 可被多个 app 复用

(5)、app(永远不被依赖)

app 是"最终组装层"

✅ app 可以:

  • 组合 domain + ui + hooks

❌ app 不可以:

  • 被其他任何包 import

4、哪些包"拆了 100% ❌ 会后悔"(重点)

(1)、按页面拆包

typescript 复制代码
packages/
  home-page
  order-page

为什么后悔?

  • 页面变化率极高
  • 强依赖 app 路由
  • 无法复用

(2)、按技术名词拆包

typescript 复制代码
packages/
  services
  store
  helpers

这是"语义最差"的拆法

(3)、按当前组织架构拆

组织变了,包名就成历史包袱

5、一个「不后悔拆包」的判断清单(实战)

在拆之前,对每个候选包打分:

维度 问题 分值
复用性 是否 ≥2 个 app 用 +2
稳定性 最近 3 个月改动 ❤️ 次 +2
边界 API 能一句话说明 +2
独立性 不依赖 UI / App +2
所有权 有明确 owner +2

≥7 分才允许拆包

6、企业级"演进式拆包流程"(不会后悔的关键)

不要一次性拆完

正确流程:

typescript 复制代码
️① 在原项目内部"逻辑隔离"
️② 稳定 2~4 周
️③ 抽成 package
️④ ESLint + boundary 锁死
️⑤ 再允许被复用

7、一个真实案例(你会非常熟)

某中大型前端团队(≈40 人)

初期:

  • 直接拆 order, user, payment

半年后:

  • 互相引用

  • 发布互相影响

最终重构:

  • 合并为 domain-*
  • UI 下沉
  • App 只做组装

损失 3 个月人力

六、从多仓迁移到 Monorepo 的实战步骤

在不影响现有交付、不打断 CI、不逼团队改习惯的前提下,把多仓平滑迁移到 Monorepo。

1、先说结论:迁移的唯一正确姿势

多仓 → Monorepo,一定是"并行演进",而不是"替换上线"。

❌ 错误做法(会翻车):

  • 冻结所有仓库
  • 新建 Monorepo
  • 一次性迁移
  • 要求所有人同步切换

大型团队 100% 失败

✅ 正确做法(企业级):

typescript 复制代码
多仓继续跑 + Monorepo 同步建
↓
先迁"最稳定的包"
↓
双轨依赖(workspace / npm)
↓
逐步切换
↓
最终收敛

2、迁移前准备(90% 成败在这里)

(1)、先给现有仓库"分层画像"

你现在的多仓,一定能分成这几类:

仓库类型 举例
应用仓 web-admin / web-h5
基础库 utils / ui / hooks
业务库 user-sdk / order-sdk
工程配置 eslint-config

先标出来,不要急着迁

(2)、标记"稳定度"(极其关键)

给每个仓库打标签:

仓库 变化频率 是否优先迁
utils ✅ 第一批
ui ✅ 第二批
业务 SDK ⚠️ 观察
app ❌ 最后

永远不要先迁 App

3、阶段一:创建 Monorepo「影子仓库」

影子 = 不影响任何现有项目

(1)、新建 Monorepo 仓库

typescript 复制代码
mkdir acme-monorepo
pnpm init
typescript 复制代码
# pnpm-workspace.yaml
packages:
  - apps/*
  - packages/*

(2)、建立"空壳包位"

typescript 复制代码
packages/
  utils/
  ui/
apps/

此时不接入任何业务

4、阶段二:迁移"最不容易出事"的包(关键)

(1)、第一批:utils(必选)

操作步骤:

typescript 复制代码
旧 utils 仓库
  ↓(git history 保留)
packages/utils
typescript 复制代码
git subtree add \
  --prefix=packages/utils \
  git@github.com:acme/utils.git \
  main

保留完整提交历史(非常重要)

(2)、保持 package name 不变

typescript 复制代码
{
  "name": "@acme/utils"
}

⚠️ 这是 兼容旧项目的关键

(3)、仍然发布到 npm(不要停)

typescript 复制代码
Monorepo
  ↓ publish
npm
  ↓ install
旧 app 继续用

此时所有旧项目无感知

5、阶段三:引入双轨依赖(workspace + npm)

这是迁移的核心技巧

在 Monorepo 内部:

typescript 复制代码
"@acme/utils": "workspace:*"

在旧仓库:

typescript 复制代码
"@acme/utils": "^1.3.0"

结果是:

  • Monorepo:本地联调
  • 旧项目:npm 安装
  • 发布链路:只有一条

6、阶段四:逐步迁 UI / hooks / domain

推荐顺序(再次强调):

typescript 复制代码
utils
→ ui
→ hooks
→ domain
→ apps(最后)

每迁一个包,都要做这 3 件事:

  • 加 ESLint 包边界
    • 禁止跨 src
    • 禁止反向依赖
  • 加 changeset
    • 明确版本变化
  • 发布不断
    • npm 是"稳定器"

7、阶段五:迁 App(最危险,但可控)

只有当 80% 的依赖已经在 Monorepo,才迁 App

App 迁移方式(重点):

  • ❌ 不要
    • apps/web ← 直接迁
  • ✅ 正确
    • 保持构建命令一致
    • 保持部署方式一致
    • 只改变代码位置

正确示例:

typescript 复制代码
apps/web
  ↓
只迁代码
CI / 域名 / 发布不动

8、迁移过程中 5 个"必踩坑"提前告诉你

(1)、版本号混乱

解决:统一用 changesets

(2)、隐式依赖爆雷

typescript 复制代码
import '../utils/src'

解决:迁第一天就上 ESLint boundary

(3)、CI 时间暴涨

解决:Turborepo + cache + 只 build 受影响包

(4)、团队抗拒

解决:

  • 不强制
  • 不冻结
  • 不一次性切

(5)、回滚困难

解决:

  • npm 包始终可用
  • 老仓库不删,直到稳定

9、迁移期间的版本治理(避免版本地狱)

(1)、迁移期的最大误区(先踩刹车)

❌ 误区:

  • 迁移时顺便统一版本
  • 顺便升级大版本
  • 顺便清理历史包

这三件事 迁移期全部禁止

(2)、迁移期的唯一正确版本策略

迁移期 = "版本连续性优先"

核心原则(必须遵守):

原则 说明
包名不变 @acme/utils 永远不改
版本连续 1.2.3 → 1.2.4
语义不变 不偷偷 breaking
发布不中断 npm 仍是唯一来源

(3)、强烈推荐:Changesets(迁移期救命工具)

为什么不用 lerna?

  • lerna 偏"统一版本"
  • 不适合迁移期并行演进
①、迁移期 Changesets 使用规则(非常关键)

✅ 只对 packages/ 生效:

typescript 复制代码
packages/*

❌ apps 不参与版本管理:

typescript 复制代码
apps/*
②、迁移期版本策略表
改动类型 版本
迁移代码(无 API 变更) patch
新增能力 minor
破坏兼容 ❌ 禁止

(4)、"双轨依赖"是避免地狱的关键

Monorepo 内部:

typescript 复制代码
"@acme/utils": "workspace:*"

旧仓 / 线上:

typescript 复制代码
"@acme/utils": "^1.2.3"
  • 永远只有一条发布链路
  • 永远不要 fork 包

(5)、迁移期版本地狱的 3 个前兆(看到就停)

  • 同一个包在 npm 出现两个 major
  • 同一个包被 copy 到多个地方
  • 开始出现 utils-v2 / utils-new

出现任何一个,说明治理失败

10、迁移中的 CI 治理方案(不炸流水线)

(1)、CI 的迁移期目标(不是最优)

目标不是"最快 CI",而是"不影响旧系统"

(2)、正确的 CI 双轨结构(企业实战)

typescript 复制代码
旧仓 CI        Monorepo CI
  |               |
  |---- npm ----->|

关键点:

  • 旧仓 CI 不改
  • Monorepo CI 新增
  • npm 作为"稳定中介"

(3)、Monorepo CI 最小可用配置(迁移期)

必须做的 3 件事:

  • install
  • build(增量)
  • lint(包边界)
typescript 复制代码
- pnpm install
- pnpm turbo run build
- pnpm turbo run lint

❌ 迁移期先不上:

  • 全量 e2e
  • 重度性能测试

(4)、CI 中强制的"破窗器"

一条非常关键的规则:

  • 迁移期 CI 发现跨包 src 引用,直接 fail

这是防止"历史坏习惯"被带进 Monorepo 的唯一手段。

11、迁移期间的权限治理(很多团队忽略)

(1)、权限治理的核心目标

允许迁移,不允许破坏

(2)、推荐的 Git 权限模型(真实可用)

Monorepo 分层授权:

区域 权限
packages/utils 核心小组
packages/ui UI 小组
packages/domain-* 对应业务组
apps/* 对应应用组

不是所有人都能改所有包

(3)、CODEOWNERS(强烈推荐)

typescript 复制代码
packages/utils/   @core-team
packages/ui/      @ui-team
apps/order/       @order-team

效果:

  • PR 自动 request owner
  • 非 owner 必须 review

(4)、迁移期的"冻结规则"

行为 是否允许
新建 package ❌ 审批
改包类型(utils → domain)
改 public API

迁移期只允许"等价迁移"

12、迁移期间的发布治理(最容易出事)

(1)、发布的核心原则(背下来)

迁移期发布 ≠ 架构升级发布

(2)、推荐发布流程(迁移期)

typescript 复制代码
改代码
↓
changeset(强制)
↓
CI 校验
↓
发布到 npm
↓
旧项目继续消费

(3)、发布权限强约束

角色 权限
CI Bot publish
开发者
架构负责人 管理

人不直接发包

(4)、灰度发布(迁移期非常重要)

  • patch 版本先发
  • 旧项目先吃
  • Monorepo app 后吃

13、迁移期的"止血型规则清单"(建议直接落文档)

版本:

❌ 禁止大版本

❌ 禁止重命名包

✅ 只 patch / minor

CI:

❌ 禁止全量 build

✅ 增量 + boundary

权限:

❌ 禁止无 owner 包

✅ 强制 CODEOWNERS

发布:

❌ 人工发包

✅ CI 发包

14、迁移完成后的"解封顺序"(别反过来)

typescript 复制代码
1. 完成 App 迁移
2. 停止旧仓写入
3. archive 旧仓
4. 才考虑版本统一 / 架构升级

15、完整迁移完成的标志(不是主观感觉)

你可以用这个 checklist 判断是否"真的迁完了":

  • 新代码不再进旧仓
  • 新包只建在 Monorepo
  • App 依赖 100% workspace
  • 旧仓只读(archive)
  • CI 时间 < 迁移前

七、Monorepo + 微前端 / 单包独立构建

Monorepo 负责"工程内聚",微前端负责"运行时解耦",单包独立构建负责"交付自治"。

  • Monorepo 管"怎么一起开发",
  • 单包独立构建管"怎么单独交付",
  • 微前端管"怎么在运行时分开活"。

1、先给结论:三者的正确关系(非常重要)

Monorepo ≠ 微前端

微前端 ≠ 独立仓库

单包独立构建 ≠ 单独部署

正确分工一句话版:

能力 解决什么问题
Monorepo 开发期协作、复用、规范
微前端 运行期解耦、独立加载
单包独立构建 交付期独立发布

三者解决的是"三个不同阶段的问题"

2、三种组合模式(企业真实使用)

  • Monorepo + 单包独立构建(最常见 )
  • Monorepo + 微前端(Module Federation / single-spa)
  • Monorepo + 微前端 + 独立构建(最复杂)

(1)、模式一:Monorepo + 单包独立构建(最常见 ⭐⭐⭐⭐⭐)

80% 企业真正需要的,其实是这个

适用场景:

  • 一个系统
  • 多个子应用
  • 不需要 iframe / runtime sandbox
①、结构示例
typescript 复制代码
monorepo/
├─ apps/
│  ├─ shell            # 主应用
│  ├─ order            # 子应用
│  └─ user
├─ packages/
│  ├─ ui
│  ├─ utils
│  └─ domain
②、核心能力:apps 独立构建、独立部署

Turborepo 配置:

typescript 复制代码
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

构建某一个子应用:

typescript 复制代码
pnpm turbo run build --filter=apps/order
  • 只构建 order
  • 依赖自动构建
  • 缓存可复用

这是"单包独立构建"的本质

③、典型企业用法
  • Admin:一个仓
  • 多个业务团队:各自维护 app
  • 共享 ui / domain

(2)、模式二:Monorepo + 微前端(Module Federation / single-spa)

当你"真的需要运行时解耦"时才用

✅ 需要微前端的场景:

  • 多团队 完全独立发布
  • 子应用 生命周期不一致
  • 技术栈可能不同

❌ 不需要微前端的场景:

  • 只是想"模块化"
  • 只是想"代码拆分"
①、Monorepo + MF 的真实结构
typescript 复制代码
monorepo/
├─ apps/
│  ├─ host            # 基座
│  ├─ order-mf        # 微应用
│  └─ user-mf
├─ packages/
│  ├─ ui
│  └─ domain
②、Vite + Module Federation(真实示例)
typescript 复制代码
apps/order-mf/vite.config.ts
import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';

export default defineConfig({
  plugins: [
    federation({
      name: 'order',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App.tsx',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
  build: {
    target: 'esnext',
  },
});

apps/host/vite.config.ts
federation({
  remotes: {
    order: 'http://localhost:3001/assets/remoteEntry.js',
  },
});

Monorepo 提供的是:

  • 共享依赖
  • 统一规范
  • 本地联调能力

微前端解决的是:

  • 运行时加载
  • 独立上线
  • 灰度 / 回滚

(3)、模式三:Monorepo + 微前端 + 独立构建(最复杂)

只适合超大规模团队(50+ 前端)

特点:

  • apps 独立 build
  • apps 独立 deploy
  • runtime 独立加载
  • 代码仍然共仓

成本:

项目 成本
构建
CI
心智负担 非常高

没有架构负责人,不要碰

3、单包独立构建的"工程真相"

独立构建 ≠ 独立仓库

核心条件(缺一不可):

  • app 不能依赖 app
  • app 只能依赖 packages
  • packages 不依赖 app
  • 通过 workspace + exports 强制边界

构建过滤(你一定会用到):

typescript 复制代码
pnpm turbo run build --filter=apps/user...

含义:

  • 构建 user
  • 构建 user 的所有依赖

4、Monorepo 为什么是微前端的"最强基座"

很多人反过来想,这是错的。

没有 Monorepo 的微前端:

  • 共享代码靠 copy
  • 规范靠文档
  • 升级靠吼

有 Monorepo 的微前端:

  • 共享包天然存在
  • 升级一次,影响可控
  • 本地可完整联调

Monorepo 是"开发期微前端"

5、企业级推荐组合(结论)

如果你问我选哪个:

团队规模 推荐方案
≤10 人 Monorepo + 单包独立构建
10~30 人 上面 + 可选 MF
30+ Monorepo + MF + 独立部署

6、Monorepo + 微前端的本地联调方案(非常难)

90% 的 Monorepo + 微前端方案,死在"本地联调体验极差"。

**Monorepo + 微前端的本地联调,不是"选一种模式",

而是:

  • 日常开发:本地直连
  • 架构验证:本地 remote
  • 多人协作:混合模式
  • 问题排查:线上回放**

(1)、为什么「Monorepo + 微前端」本地联调这么难?

本质冲突只有一句话:

  • Monorepo 要"本地直连",微前端要"运行时远程加载"。

冲突点拆解:

Monorepo 微前端
workspace 本地包 remoteEntry 远程 URL
编译期确定依赖 运行期动态加载
单进程 dev 多应用 dev server

所以你不能"只用一种方式"

(2)、企业级正确思路(先给结论)

本地联调不是一个模式,而是「多模式并存」

真实团队里,我们会同时支持这 4 种模式:

模式 用途
A. 本地直连模式 日常高频开发(80% 时间)
B. 本地 remote 模式 验证微前端边界
C. 混合模式 多人并行开发
D. 线上 remote 回放 定位线上问题

(3)、模式 A:本地直连模式(最重要 ⭐⭐⭐⭐⭐)

核心思想:本地开发时,"先当它不是微前端"

适用场景:

  • 单人 / 小组开发
  • 改子应用 UI / 逻辑
  • 高频调试
①、结构示意
typescript 复制代码
apps/
├─ host
├─ order-mf
└─ user-mf
②、Host 里用「源码直连」
typescript 复制代码
// ❌ 线上模式(remote)
const OrderApp = React.lazy(() => import('order/App'));

// ✅ 本地直连
import OrderApp from '../../order-mf/src/App';
③、用环境变量切换(关键)
typescript 复制代码
const isLocal = import.meta.env.VITE_MICRO_LOCAL === 'true';

const OrderApp = isLocal
  ? require('../../order-mf/src/App').default
  : React.lazy(() => import('order/App'));

启动方式:

typescript 复制代码
VITE_MICRO_LOCAL=true pnpm dev
④、优点 / 缺点

优点:

  • 极速 HMR
  • 无 remoteEntry 心智负担

缺点:

  • 不验证微前端加载机制

80% 日常开发靠它

(4)、模式 B:本地 remote 模式(验证微前端)

真的把它当微前端跑

①、同时启动多个 dev server
typescript 复制代码
pnpm turbo run dev --parallel

端口示例:

应用 端口
host 3000
order-mf 3001
user-mf 3002
②、Host 指向本地 remote
typescript 复制代码
federation({
  remotes: {
    order: 'http://localhost:3001/assets/remoteEntry.js',
    user: 'http://localhost:3002/assets/remoteEntry.js',
  },
});
③、关键问题 & 解法

问题 1:共享依赖重复加载

typescript 复制代码
shared: {
  react: { singleton: true },
  'react-dom': { singleton: true },
}

问题 2:CORS

typescript 复制代码
server: {
  cors: true,
}

问题 3:dev server 太多

用 turbo 并行 + filter

typescript 复制代码
pnpm turbo run dev --filter=order-mf

(5)、模式 C:混合模式(真实多人协作)

本地直连 + 远程 remote 混合

场景:

  • 我在改 order
  • 你在改 user
  • host 需要同时调两个

实现方式:

typescript 复制代码
const remotes = {
  order: isLocalOrder
    ? 'local'
    : 'http://localhost:3001/assets/remoteEntry.js',

  user: 'http://localhost:3002/assets/remoteEntry.js',
};

if (remotes.order === 'local') {
  OrderApp = require('../../order-mf/src/App').default;
}

这是大型团队的常态

(6)、模式 D:线上 remote 回放(排查问题)

用线上微应用,本地跑 host:

typescript 复制代码
remotes: {
  order: 'https://cdn.xxx.com/order/remoteEntry.js',
}

用途:

  • 本地复现线上 bug
  • 不需要启动子应用

(7)、Monorepo 在这里的"隐藏价值"

如果你不用 Monorepo:

  • 共享代码版本不一致
  • 本地联调需要 npm link
  • HMR 几乎不可用

Monorepo + workspace 解决了 90% 痛点:

  • 共享包天然一致
  • 本地直连无成本
  • 可随时切换模式

(8)、工程级关键细节(非常容易踩坑)

①、Host 必须是"最薄的一层"

❌ 不写:

  • 业务逻辑
  • domain

✅ 只负责:

  • 路由
  • 权限
  • 微应用装载
②、微应用必须"可独立运行"
typescript 复制代码
if (window.__MICRO_APP__) {
  render();
}

否则本地直连会崩。

③、严禁微应用之间通信

通信只能:

  • 通过 host
  • 通过 shared domain
相关推荐
lkbhua莱克瓦241 个月前
项目知识——Monorepo(单体仓库)架构详解
架构·github·项目·monorepo
奋飛2 个月前
Monorepo系列:Pnpm Workspace 搭建 Monorepo
pnpm·monorepo·pnpm workspace·catalogs·pnpm filter
oscar9994 个月前
Monorepo 全面解析:优势、挑战与适用场景
git·monorepo
流氓也是种气质 _Cookie6 个月前
从依赖地狱到依赖天堂PNPM
pnpm·monorepo
Wang's Blog7 个月前
Monorepo架构: 项目管理模式对比与考量
架构·monorepo
爱宇阳9 个月前
pnpm 依赖升级终极指南:从语义化版本控制到 Monorepo 全局更新的企业级实践
pnpm·版本控制·monorepo·依赖升级
utmhikari10 个月前
【架构艺术】Go语言微服务monorepo的代码架构设计
后端·微服务·架构·golang·monorepo
智在碧得1 年前
前端Monorepo实践分享
前端·代码仓库·monorepo·代码·业务组件
JinSoooo1 年前
pnpm monorepo 联调方案
前端·pnpm·monorepo