Monorepo 工程化实践
大约 13 分钟约 3936 字
Monorepo 工程化实践
简介
Monorepo 是将多个相关项目放在同一个代码仓库中管理的工程模式。随着前端项目复杂度增加,共享组件库、工具函数、配置文件等跨项目复用的需求越来越强烈。Monorepo 通过统一的依赖管理、共享的构建缓存和一致的代码规范,显著提升了多包协作效率。本文深入讲解 pnpm workspace、Turborepo、Nx 等主流方案的实战配置。
特点
Monorepo vs Polyrepo
对比分析
| 维度 | Monorepo | Polyrepo |
|---|---|---|
| 代码管理 | 一个仓库多个包 | 每个包独立仓库 |
| 依赖共享 | 直接引用内部包 | npm 发布后引用 |
| 代码规范 | 统一配置 | 各仓库独立配置 |
| 原子提交 | 跨包修改一次提交 | 需要多次 PR |
| CI/CD | 增量构建、按需部署 | 每个仓库独立流水线 |
| 代码审查 | 跨项目可见性高 | 只能看到单个仓库 |
| 学习成本 | 需要理解 workspace 概念 | 简单直接 |
| 权限控制 | 粗粒度 | 细粒度 |
| 仓库体积 | 可能很大 | 各自独立 |
项目结构设计
推荐目录结构
monorepo/
├── apps/ # 应用层
│ ├── web/ # Web 前端应用
│ │ ├── src/
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── admin/ # 管理后台
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── docs/ # 文档站点
│ ├── src/
│ └── package.json
├── packages/ # 共享包
│ ├── ui/ # UI 组件库
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # 工具函数库
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── hooks/ # React Hooks 库
│ │ ├── src/
│ │ └── package.json
│ ├── config/ # 共享配置
│ │ ├── eslint/
│ │ ├── prettier/
│ │ ├── tsconfig/
│ │ └── package.json
│ └── types/ # 共享类型定义
│ ├── src/
│ └── package.json
├── turbo.json # Turborepo 配置
├── pnpm-workspace.yaml # pnpm workspace 配置
├── package.json # 根 package.json
├── tsconfig.json # 根 tsconfig
└── .github/
└── workflows/
└── ci.yml # CI 配置pnpm Workspace 配置
根目录配置文件
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'// 根 package.json
{
"name": "monorepo",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "turbo run build && changeset publish"
},
"devDependencies": {
"turbo": "^2.0.0",
"typescript": "^5.4.0",
"prettier": "^3.2.0",
"eslint": "^8.56.0"
},
"packageManager": "pnpm@9.0.0",
"engines": {
"node": ">=18.0.0",
"pnpm": ">=9.0.0"
}
}包依赖声明
// apps/web/package.json
{
"name": "@monorepo/web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.22.0",
"@monorepo/ui": "workspace:*",
"@monorepo/hooks": "workspace:*",
"@monorepo/utils": "workspace:*",
"@monorepo/types": "workspace:*"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.2.0",
"vitest": "^1.4.0"
}
}// packages/ui/package.json
{
"name": "@monorepo/ui",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
"lint": "eslint src/",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"clsx": "^2.1.0",
"@monorepo/utils": "workspace:*"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.4.0"
},
"peerDependencies": {
"react": "^18.0.0"
}
}Turborepo 配置
turbo.json 完整配置
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
".env",
".env.local"
],
"globalEnv": [
"NODE_ENV"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "build/**"],
"outputLogs": "new-only"
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"],
"outputs": []
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"inputs": ["src/**", "test/**", "*.test.ts", "*.test.tsx"]
},
"clean": {
"cache": false
},
"type-check": {
"dependsOn": ["^build"],
"outputs": []
}
}
}Turborepo 管道说明
# 构建所有包(自动按依赖拓扑排序)
pnpm build
# 只构建 web 应用及其依赖
turbo run build --filter=@monorepo/web
# 构建自上次提交以来变更的包
turbo run build --filter=...[HEAD^]
# 并行运行 lint 和 type-check
turbo run lint type-check --parallel
# 强制跳过缓存
turbo run build --force
# 查看依赖图
turbo run build --dry-run --graph
# 远程缓存配置
turbo login
turbo linkTurborepo 远程缓存
# .turbo/config.json - 自托管远程缓存
# 设置环境变量 TURBO_TOKEN 和 TURBO_TEAM
# CI 中使用远程缓存
# .github/workflows/ci.yml# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type Check
run: turbo run type-check
- name: Build
run: pnpm build
- name: Test
run: pnpm test
- name: Cache Turbo
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-Nx Workspace
Nx 配置
// nx.json
{
"$schema": "https://nx.dev/schemas/nx.schema.json",
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": ["build", "lint", "test", "type-check"],
"parallel": 3
}
}
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"]
},
"test": {
"inputs": ["default", "^production", "{workspaceRoot}/vitest.config.ts"]
},
"lint": {
"inputs": ["default", "{workspaceRoot}/.eslintrc.json"]
}
},
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/?(*.)test.{ts,tsx,js,jsx}",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/.eslintrc.json"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
}
}Nx 常用命令
# 查看依赖图
nx graph
# 查看受影响的包
nx affected:graph
# 只构建受影响的包
nx affected:build
# 只测试受影响的包
nx affected:test
# 运行特定包的任务
nx build @monorepo/web
# 生成组件
nx g @nx/react:component Button --project=@monorepo/ui
# 查看包详情
nx show project @monorepo/ui共享配置包
TypeScript 配置
// packages/config/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false
},
"exclude": ["node_modules", "dist"]
}// packages/config/tsconfig/react.json
{
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
}
}// apps/web/tsconfig.json
{
"extends": "@monorepo/config/tsconfig/react.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}ESLint 共享配置
// packages/config/eslint/index.js
const { resolve } = require('node:path')
const project = resolve(process.cwd(), 'tsconfig.json')
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'prettier'
],
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
parser: '@typescript-eslint/parser',
parserOptions: {
project,
sourceType: 'module',
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true }
},
settings: {
react: { version: 'detect' },
'import/resolver': {
typescript: { project }
}
},
env: {
browser: true,
node: true,
es2022: true
},
rules: {
// TypeScript 规则
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports' }
],
'@typescript-eslint/no-non-null-assertion': 'warn',
// React 规则
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react/display-name': 'off',
// 通用规则
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'no-var': 'error'
},
overrides: [
{
files: ['**/*.test.ts', '**/*.test.tsx'],
env: { jest: true }
}
],
ignorePatterns: ['dist/', 'node_modules/', '.eslintrc.js']
}// apps/web/.eslintrc.json
{
"root": true,
"extends": ["@monorepo/config/eslint"]
}Prettier 共享配置
// packages/config/prettier/index.js
module.exports = {
semi: false,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
tabWidth: 2,
endOfLine: 'lf',
arrowParens: 'always',
importOrder: [
'^react',
'^@monorepo/(.*)$',
'^@/(.*)$',
'^[./]'
],
importOrderSeparation: true,
importOrderSortSpecifiers: true
}// 根目录 .prettierrc.js
module.exports = require('@monorepo/config/prettier')共享 UI 组件库
组件库结构
// packages/ui/src/index.ts
export { Button } from './components/Button'
export { Input } from './components/Input'
export { Modal } from './components/Modal'
export { Card } from './components/Card'
export { Select } from './components/Select'
export { theme } from './theme'
export type { ThemeConfig } from './theme'// packages/ui/src/components/Button.tsx
import { forwardRef } from 'react'
import { clsx } from 'clsx'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
icon?: ReactNode
fullWidth?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
variant = 'primary',
size = 'md',
loading = false,
icon,
fullWidth = false,
disabled,
children,
className,
...rest
} = props
return (
<button
ref={ref}
disabled={disabled || loading}
className={clsx(
'btn',
`btn-${variant}`,
`btn-${size}`,
fullWidth && 'btn-full',
loading && 'btn-loading',
className
)}
{...rest}
>
{loading && <span className="btn-spinner" />}
{icon && <span className="btn-icon">{icon}</span>}
{children}
</button>
)
}
)
Button.displayName = 'Button'// packages/ui/src/theme/index.ts
export interface ThemeConfig {
colors: {
primary: string
secondary: string
success: string
warning: string
danger: string
info: string
background: string
surface: string
text: string
textSecondary: string
border: string
}
spacing: {
xs: string
sm: string
md: string
lg: string
xl: string
}
borderRadius: {
sm: string
md: string
lg: string
full: string
}
fontSize: {
xs: string
sm: string
md: string
lg: string
xl: string
}
}
export const theme: ThemeConfig = {
colors: {
primary: '#3b82f6',
secondary: '#6366f1',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
info: '#06b6d4',
background: '#ffffff',
surface: '#f8fafc',
text: '#0f172a',
textSecondary: '#64748b',
border: '#e2e8f0'
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px'
},
borderRadius: {
sm: '4px',
md: '8px',
lg: '12px',
full: '9999px'
},
fontSize: {
xs: '12px',
sm: '14px',
md: '16px',
lg: '18px',
xl: '24px'
}
}共享工具函数库
工具包实现
// packages/utils/src/index.ts
export { formatDate, formatRelativeTime } from './date'
export { debounce, throttle } from './function'
export { storage } from './storage'
export { copyToClipboard, downloadFile } from './browser'
export { validateEmail, validatePhone, validateUrl } from './validator'// packages/utils/src/storage.ts
export interface StorageOptions {
prefix?: string
expire?: number // 过期时间(毫秒)
}
class StorageManager {
private prefix: string
constructor(prefix = 'app') {
this.prefix = prefix
}
private getKey(key: string): string {
return `${this.prefix}:${key}`
}
set(key: string, value: any, options?: StorageOptions): void {
const data = {
value,
timestamp: Date.now(),
expire: options?.expire || null
}
localStorage.setItem(this.getKey(key), JSON.stringify(data))
}
get<T = any>(key: string): T | null {
const raw = localStorage.getItem(this.getKey(key))
if (!raw) return null
try {
const data = JSON.parse(raw)
if (data.expire && Date.now() - data.timestamp > data.expire) {
this.remove(key)
return null
}
return data.value as T
} catch {
return null
}
}
remove(key: string): void {
localStorage.removeItem(this.getKey(key))
}
clear(): void {
const keys = Object.keys(localStorage)
keys.forEach(key => {
if (key.startsWith(this.prefix)) {
localStorage.removeItem(key)
}
})
}
}
export const storage = new StorageManager()// packages/utils/src/function.ts
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
return function (this: any, ...args: Parameters<T>) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}
export function throttle<T extends (...args: any[]) => any>(
fn: T,
interval: number
): (...args: Parameters<T>) => void {
let lastTime = 0
return function (this: any, ...args: Parameters<T>) {
const now = Date.now()
if (now - lastTime >= interval) {
fn.apply(this, args)
lastTime = now
}
}
}Changeset 版本发布
Changeset 配置
// .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@monorepo/web", "@monorepo/admin"]
}发布流程
# 1. 创建 changeset(交互式选择变更的包和版本类型)
pnpm changeset
# 这会在 .changeset/ 目录生成一个 md 文件:
# ---
# "@monorepo/ui": minor
# "@monorepo/utils": patch
# ---
# 添加了新的 Card 组件
# 2. 消费 changeset,更新 package.json 和 CHANGELOG
pnpm version-packages
# 3. 构建并发布到 npm
pnpm release
# 或者使用 GitHub Actions 自动发布自动发布 CI
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
publish: pnpm release
version: pnpm version-packages
title: 'chore: version packages'
commit: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}CI/CD 优化
按需构建部署
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
web: ${{ steps.filter.outputs.web }}
admin: ${{ steps.filter.outputs.admin }}
ui: ${{ steps.filter.outputs.ui }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
web:
- 'apps/web/**'
- 'packages/**'
admin:
- 'apps/admin/**'
- 'packages/**'
ui:
- 'packages/ui/**'
deploy-web:
needs: detect-changes
if: needs.detect-changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- run: pnpm install --frozen-lockfile
- run: turbo run build --filter=@monorepo/web
- name: Deploy to CDN
run: |
cd apps/web
npx wrangler pages deploy dist --project-name=web
deploy-admin:
needs: detect-changes
if: needs.detect-changes.outputs.admin == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- run: pnpm install --frozen-lockfile
- run: turbo run build --filter=@monorepo/admin
- name: Deploy
run: echo "Deploy admin"依赖管理
pnpm 常用命令
# 安装所有依赖
pnpm install
# 添加依赖到指定包
pnpm add lodash --filter @monorepo/web
# 添加开发依赖
pnpm add -D vitest --filter @monorepo/ui
# 添加 workspace 依赖
pnpm add @monorepo/utils --filter @monorepo/web
# 更新依赖
pnpm update --latest --recursive
# 检查过期依赖
pnpm outdated --recursive
# 清理 node_modules
pnpm clean
# 查看依赖图
pnpm list --depth 0 --recursive
# 仅安装生产依赖
pnpm deploy --prod distworkspace 协议
// workspace:* — 始终使用 workspace 版本(开发时)
{
"dependencies": {
"@monorepo/ui": "workspace:*"
}
}
// workspace:^ — 发布时替换为 semver 范围
{
"dependencies": {
"@monorepo/ui": "workspace:^"
}
}
// workspace:~ — 发布时替换为 patch 范围
{
"dependencies": {
"@monorepo/utils": "workspace:~"
}
}迁移指南
从独立仓库迁移到 Monorepo
#!/bin/bash
# migrate-to-monorepo.sh
# 1. 初始化 monorepo
mkdir monorepo && cd monorepo
git init
pnpm init
# 2. 创建目录结构
mkdir -p apps packages/config
# 3. 创建 workspace 配置
cat > pnpm-workspace.yaml << 'EOF'
packages:
- 'apps/*'
- 'packages/*'
EOF
# 4. 迁移现有项目
# 方法一:使用 git subtree(保留历史)
git subtree add --prefix=apps/web https://github.com/org/web-app.git main
git subtree add --prefix=packages/ui https://github.com/org/ui-lib.git main
# 方法二:直接复制(不保留历史)
cp -r ../web-app apps/web
cp -r ../ui-lib packages/ui
# 5. 调整 package.json
# - 添加 workspace:* 依赖
# - 统一 scripts 命名
# 6. 安装依赖
pnpm install
# 7. 验证构建
pnpm build
# 8. 提交
git add .
git commit -m "chore: init monorepo"迁移检查清单
## 迁移检查清单
### 迁移前
- [ ] 列出所有仓库和它们的依赖关系
- [ ] 确认 Node/pnpm 版本统一
- [ ] 确认 TypeScript 版本统一
- [ ] 确认 ESLint/Prettier 配置可以统一
- [ ] 评估仓库体积(大文件使用 Git LFS)
### 迁移中
- [ ] 创建 monorepo 目录结构
- [ ] 配置 pnpm-workspace.yaml
- [ ] 迁移各项目到对应目录
- [ ] 修改 package.json 中的内部依赖为 workspace:*
- [ ] 统一 TypeScript 配置(extends 共享配置)
- [ ] 统一 ESLint/Prettier 配置
- [ ] 配置 Turborepo(turbo.json)
- [ ] 调整 CI/CD 流水线
- [ ] 更新部署脚本
### 迁移后
- [ ] 验证所有项目正常构建
- [ ] 验证所有测试通过
- [ ] 验证 CI/CD 流水线正常
- [ ] 更新开发文档
- [ ] 通知团队成员优点
- 代码共享便捷:内部包直接引用,无需发布到 npm
- 原子提交:跨包修改可以一次 PR 完成
- 统一规范:ESLint、TypeScript、Prettier 配置共享
- 增量构建:只构建变更的包和依赖它的包
- 协作效率高:所有代码可见,方便跨团队协作
- 重构安全:IDE 全局搜索替换,重构更彻底
缺点
- 学习成本:团队需要理解 workspace、turbo 等概念
- 仓库体积:所有项目在一个仓库,clone 时间较长
- CI 复杂度:需要按需构建和部署,CI 配置更复杂
- 权限管理:无法按包控制代码权限
- 构建工具链复杂:需要理解 pnpm、turbo、changeset 等工具
- 耦合风险:包之间可能产生不合理的依赖
性能注意事项
- 利用构建缓存:Turborepo 自动缓存构建结果,避免重复构建
- 按需构建:CI 中使用 filter 只构建变更的包
- 并行任务:Turbo/Nx 自动并行执行无依赖的任务
- pnpm 硬链接:pnpm 使用硬链接共享依赖,节省磁盘空间
- 远程缓存:配置 Turborepo 远程缓存,CI 和本地共享缓存
- 轻量依赖:共享包保持轻量,避免不必要的依赖传递
- Tree-shaking:确保共享包支持 ESM 和 tree-shaking
总结
Monorepo 是管理多包前端项目的有效模式。pnpm workspace 提供了高效的依赖管理,Turborepo 带来了增量构建和缓存能力,Changeset 简化了版本发布流程。关键在于合理划分 apps 和 packages 目录,统一代码规范,优化 CI/CD 流程。对于有多个前端项目和共享组件的团队,Monorepo 是值得投入的工程化方案。
关键知识点
- pnpm workspace 通过硬链接节省磁盘空间,workspace 协议管理内部依赖
- Turborepo 的管道配置定义了任务间的依赖关系和缓存策略
- Changeset 实现语义化版本管理和自动化的发布流程
- 共享配置包(ESLint、TypeScript)确保项目间代码规范一致
- CI/CD 需要使用 filter 和 affected 实现按需构建
- workspace:* 在发布时会被替换为实际的 semver 版本
常见误区
- 所有项目都适合 Monorepo — 不相关的小项目没必要合并
- Monorepo 一定比 Polyrepo 好 — 权衡团队规模、项目关联度等因素
- 共享包直接拷贝代码 — 应该抽取为独立的 workspace 包
- 忽略缓存配置 — Turborepo 的 outputs 配置不当会导致缓存失效
- 所有包都发布到 npm — 内部应用包应标记 private: true
- 根目录安装业务依赖 — 根 package.json 只放开发工具依赖
进阶路线
- 入门:pnpm workspace 基础、多包项目结构
- 进阶:Turborepo 管道配置、共享配置包、Changeset 发布
- 高级:Nx 依赖图分析、远程缓存、自定义 plugin
- 专家:Monorepo 治理策略、微前端集成、大规模仓库优化
- 架构:跨团队 Monorepo 规范、自动化代码生成器
适用场景
- 多个前端应用共享 UI 组件库
- 前端 + 组件库 + 工具库联合开发
- 全栈 Monorepo(前端 + Node.js BFF + 共享类型)
- 设计系统维护(设计 tokens + 组件库 + 文档站点)
- 微前端架构的基座和子应用管理
落地建议
- 从小开始:先合并 2-3 个关联最紧密的项目
- 统一工具链:确保 Node、pnpm、TypeScript 版本一致
- 建立规范:制定包命名规范、目录结构规范、发布流程规范
- 完善 CI:按需构建、自动发布、变更检测
- 文档先行:编写贡献指南、开发文档、发布手册
- 持续优化:定期检查依赖关系图,消除循环依赖
排错清单
复盘问题
- pnpm 的硬链接机制为什么比 npm 的扁平化安装更节省空间?
- Turborepo 的缓存 key 是由哪些因素决定的?
- 为什么 workspace:* 在发布时需要替换为具体版本号?
- 如何检测和消除 packages 之间的循环依赖?
- Monorepo 中的 Git 大文件(图片、字体)应该如何管理?
- 如何实现 Monorepo 中的选择性部署(只部署变更的应用)?
