目录
- 前言
- [一、什么才叫「真正的 Monorepo」](#一、什么才叫「真正的 Monorepo」)
- [二、前端 Monorepo 的 6 大核心能力](#二、前端 Monorepo 的 6 大核心能力)
-
- 1、Workspace:统一依赖管理(地基)
- [2、Package Boundary:包边界治理(灵魂)](#2、Package Boundary:包边界治理(灵魂))
- 3、构建系统:只构建"受影响的包"
- 4、发布体系:包级别独立发布
- 5、工程规范统一(隐形价值最大)
- [6、应用层组织(Apps vs Packages)](#6、应用层组织(Apps vs Packages))
- [三、一个「企业级前端 Monorepo」方案与实践](#三、一个「企业级前端 Monorepo」方案与实践)
-
- 1、具体方案
- 2、方案实践
-
- (1)、最终工程目录(先给全貌)
- [(2)、Workspace & 根配置(地基)](#(2)、Workspace & 根配置(地基))
-
- [①、pnpm-workspace.yaml 和 根 package.json](#①、pnpm-workspace.yaml 和 根 package.json)
- [②、Turborepo 构建调度(核心)](#②、Turborepo 构建调度(核心))
- [(3)、TypeScript Project References(企业级必备)](#(3)、TypeScript Project References(企业级必备))
- [(4)、一个真实的 Package:@acme/utils](#(4)、一个真实的 Package:@acme/utils)
- [(5)、一个真实的 UI 包:@acme/ui](#(5)、一个真实的 UI 包:@acme/ui)
- [(6)、App 使用 Package(真实依赖链)](#(6)、App 使用 Package(真实依赖链))
- (7)、Changesets:真实发布链路
- [(8)、CI(GitHub Actions)](#(8)、CI(GitHub Actions))
- (9)、这套方案为什么「真·企业级」
- [四、eslint 强制包边界(禁止跨包 src 引用)](#四、eslint 强制包边界(禁止跨包 src 引用))
-
- [1、问题本质:为什么必须禁止跨包 src 引用](#1、问题本质:为什么必须禁止跨包 src 引用)
- [2、企业级解决思路(不是只有 ESLint)](#2、企业级解决思路(不是只有 ESLint))
- [3、方案一(最稳):eslint-plugin-boundaries (⭐⭐⭐⭐⭐)](#3、方案一(最稳):eslint-plugin-boundaries (⭐⭐⭐⭐⭐))
-
- (1)、安装依赖(根目录)
-
- [(2)、定义 Monorepo 的"架构规则"](#(2)、定义 Monorepo 的“架构规则”)
- (3)、实际效果(你会看到的报错)
- [4、方案二(补充):只允许通过 package name 引用](#4、方案二(补充):只允许通过 package name 引用)
- [5、方案三(进阶):配合 TS exports + path 限制](#5、方案三(进阶):配合 TS exports + path 限制)
- 6、真实企业中的"包依赖白名单"模型(进阶)
- [7、为什么我说这一步是 Monorepo 的"生死线"](#7、为什么我说这一步是 Monorepo 的“生死线”)
- [五、Monorepo 拆包方法论(怎么拆不会后悔)](#五、Monorepo 拆包方法论(怎么拆不会后悔))
-
- 1、先给结论:拆包的第一性原则(必须背)
- [2、Monorepo 拆包的"正确顺序"(非常重要)](#2、Monorepo 拆包的“正确顺序”(非常重要))
- [3、真正"值得拆"的 6 类包(企业级清单)](#3、真正“值得拆”的 6 类包(企业级清单))
-
- [(1)、utils(几乎 100% 不会后悔)](#(1)、utils(几乎 100% 不会后悔))
- (2)、ui(高复用、低业务)
- (3)、hooks(抽"行为",不是"页面逻辑")
- [(4)、domain(90% 团队拆错的地方)](#(4)、domain(90% 团队拆错的地方))
- (5)、app(永远不被依赖)
- [4、哪些包"拆了 100% ❌ 会后悔"(重点)](#4、哪些包“拆了 100% ❌ 会后悔”(重点))
- 5、一个「不后悔拆包」的判断清单(实战)
- 6、企业级"演进式拆包流程"(不会后悔的关键)
- 7、一个真实案例(你会非常熟)
- [六、从多仓迁移到 Monorepo 的实战步骤](#六、从多仓迁移到 Monorepo 的实战步骤)
-
- 1、先说结论:迁移的唯一正确姿势
- [2、迁移前准备(90% 成败在这里)](#2、迁移前准备(90% 成败在这里))
- [3、阶段一:创建 Monorepo「影子仓库」](#3、阶段一:创建 Monorepo「影子仓库」)
-
- [(1)、新建 Monorepo 仓库](#(1)、新建 Monorepo 仓库)
- (2)、建立"空壳包位"
- 4、阶段二:迁移"最不容易出事"的包(关键)
-
- (1)、第一批:utils(必选)
- [(2)、保持 package name 不变](#(2)、保持 package name 不变)
- [(3)、仍然发布到 npm(不要停)](#(3)、仍然发布到 npm(不要停))
- [5、阶段三:引入双轨依赖(workspace + npm)](#5、阶段三:引入双轨依赖(workspace + npm))
- [6、阶段四:逐步迁 UI / hooks / domain](#6、阶段四:逐步迁 UI / hooks / domain)
- [7、阶段五:迁 App(最危险,但可控)](#7、阶段五:迁 App(最危险,但可控))
- [8、迁移过程中 5 个"必踩坑"提前告诉你](#8、迁移过程中 5 个“必踩坑”提前告诉你)
-
- (1)、版本号混乱
- (2)、隐式依赖爆雷
- [(3)、CI 时间暴涨](#(3)、CI 时间暴涨)
- (4)、团队抗拒
- (5)、回滚困难
- 9、迁移期间的版本治理(避免版本地狱)
-
- (1)、迁移期的最大误区(先踩刹车)
- (2)、迁移期的唯一正确版本策略
- (3)、强烈推荐:Changesets(迁移期救命工具)
-
- [①、迁移期 Changesets 使用规则(非常关键)](#①、迁移期 Changesets 使用规则(非常关键))
- ②、迁移期版本策略表
- (4)、"双轨依赖"是避免地狱的关键
- [(5)、迁移期版本地狱的 3 个前兆(看到就停)](#(5)、迁移期版本地狱的 3 个前兆(看到就停))
- [10、迁移中的 CI 治理方案(不炸流水线)](#10、迁移中的 CI 治理方案(不炸流水线))
-
- [(1)、CI 的迁移期目标(不是最优)](#(1)、CI 的迁移期目标(不是最优))
- [(2)、正确的 CI 双轨结构(企业实战)](#(2)、正确的 CI 双轨结构(企业实战))
- [(3)、Monorepo CI 最小可用配置(迁移期)](#(3)、Monorepo CI 最小可用配置(迁移期))
- [(4)、CI 中强制的"破窗器"](#(4)、CI 中强制的“破窗器”)
- 11、迁移期间的权限治理(很多团队忽略)
-
- (1)、权限治理的核心目标
- [(2)、推荐的 Git 权限模型(真实可用)](#(2)、推荐的 Git 权限模型(真实可用))
- (3)、CODEOWNERS(强烈推荐)
- (4)、迁移期的"冻结规则"
- 12、迁移期间的发布治理(最容易出事)
- 13、迁移期的"止血型规则清单"(建议直接落文档)
- 14、迁移完成后的"解封顺序"(别反过来)
- 15、完整迁移完成的标志(不是主观感觉)
- [七、Monorepo + 微前端 / 单包独立构建](#七、Monorepo + 微前端 / 单包独立构建)
-
- 1、先给结论:三者的正确关系(非常重要)
- 2、三种组合模式(企业真实使用)
-
- [(1)、模式一:Monorepo + 单包独立构建(最常见 ⭐⭐⭐⭐⭐)](#(1)、模式一:Monorepo + 单包独立构建(最常见 ⭐⭐⭐⭐⭐))
- [(2)、模式二:Monorepo + 微前端(Module Federation / single-spa)](#(2)、模式二:Monorepo + 微前端(Module Federation / single-spa))
-
- [①、Monorepo + MF 的真实结构](#①、Monorepo + MF 的真实结构)
- [②、Vite + Module Federation(真实示例)](#②、Vite + Module Federation(真实示例))
- [(3)、模式三:Monorepo + 微前端 + 独立构建(最复杂)](#(3)、模式三:Monorepo + 微前端 + 独立构建(最复杂))
- 3、单包独立构建的"工程真相"
- [4、Monorepo 为什么是微前端的"最强基座"](#4、Monorepo 为什么是微前端的“最强基座”)
- 5、企业级推荐组合(结论)
- [6、Monorepo + 微前端的本地联调方案(非常难)](#6、Monorepo + 微前端的本地联调方案(非常难))
-
- [(1)、为什么「Monorepo + 微前端」本地联调这么难?](#(1)、为什么「Monorepo + 微前端」本地联调这么难?)
- (2)、企业级正确思路(先给结论)
- [(3)、模式 A:本地直连模式(最重要 ⭐⭐⭐⭐⭐)](#(3)、模式 A:本地直连模式(最重要 ⭐⭐⭐⭐⭐))
-
- ①、结构示意
- [②、Host 里用「源码直连」](#②、Host 里用「源码直连」)
- ③、用环境变量切换(关键)
- [④、优点 / 缺点](#④、优点 / 缺点)
- [(4)、模式 B:本地 remote 模式(验证微前端)](#(4)、模式 B:本地 remote 模式(验证微前端))
-
- [①、同时启动多个 dev server](#①、同时启动多个 dev server)
- [②、Host 指向本地 remote](#②、Host 指向本地 remote)
- [③、关键问题 & 解法](#③、关键问题 & 解法)
- [(5)、模式 C:混合模式(真实多人协作)](#(5)、模式 C:混合模式(真实多人协作))
- [(6)、模式 D:线上 remote 回放(排查问题)](#(6)、模式 D:线上 remote 回放(排查问题))
- [(7)、Monorepo 在这里的"隐藏价值"](#(7)、Monorepo 在这里的“隐藏价值”)
- (8)、工程级关键细节(非常容易踩坑)
-
- [①、Host 必须是"最薄的一层"](#①、Host 必须是“最薄的一层”)
- ②、微应用必须"可独立运行"
- ③、严禁微应用之间通信
前言
前端真正实现 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