·31 分钟
前端工程化:Monorepo 实践指南
Monorepo前端工程化Turborepopnpm前端
什么是 Monorepo
Monorepo vs Multirepo
Monorepo(单仓库):将多个项目放在同一个代码仓库中。
Multirepo(多仓库):每个项目独立的代码仓库。
code
# Monorepo 结构
my-project/
├── apps/
│ ├── web/ # 前端应用
│ ├── admin/ # 后台管理
│ ├── api/ # 后端服务
│ └── mobile/ # 移动端
├── packages/
│ ├── ui/ # UI 组件库
│ ├── utils/ # 工具函数
│ ├── config/ # 配置文件
│ └── types/ # TypeScript 类型
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
Monorepo 优势
- 代码共享:组件、工具函数、类型定义共享
- 原子提交:跨项目修改在一个提交中完成
- 依赖管理:统一依赖版本,避免重复安装
- 代码复用:减少重复代码,提高开发效率
- 团队协作:更容易进行代码审查和知识共享
工具选型
包管理器:pnpm
bash
# 安装 pnpm
npm install -g pnpm
# 初始化项目
pnpm init
# 安装依赖
pnpm add <package>
# 安装到根目录
pnpm add -w <package>
# 安装到指定包
pnpm --filter @myproject/ui add <package>
yaml
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
构建工具:Turborepo
json
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"type-check": {
"dependsOn": ["^build"]
}
}
}
项目结构设计
应用层(apps)
json
// apps/web/package.json
{
"name": "@myproject/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@myproject/ui": "workspace:*",
"@myproject/utils": "workspace:*",
"@myproject/types": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@myproject/config": "workspace:*",
"typescript": "^5.0.0"
}
}
包层(packages)
json
// packages/ui/package.json
{
"name": "@myproject/ui",
"version": "1.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^19.0.0",
"clsx": "^2.0.0",
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@myproject/config": "workspace:*",
"typescript": "^5.0.0",
"tsup": "^8.0.0"
}
}
代码共享
组件库
typescript
// packages/ui/src/components/Button.tsx
import { forwardRef } from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, ...props }, ref) => {
return (
<button
ref={ref}
className={twMerge(
clsx(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
{
'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
'bg-gray-200 text-gray-900 hover:bg-gray-300': variant === 'secondary',
'border border-gray-300 bg-transparent hover:bg-gray-100': variant === 'outline',
'h-8 px-3 text-sm': size === 'sm',
'h-10 px-4 text-base': size === 'md',
'h-12 px-6 text-lg': size === 'lg',
},
className
)
)}
disabled={isLoading}
{...props}
>
{isLoading && (
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = 'Button';
typescript
// packages/ui/src/index.ts
export { Button } from './components/Button';
export type { ButtonProps } from './components/Button';
工具函数
typescript
// packages/utils/src/format.ts
export function formatDate(date: Date | string, format: string = 'YYYY-MM-DD'): string {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds);
}
export function formatNumber(num: number, decimals: number = 0): string {
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(num);
}
export function formatFileSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
typescript
// packages/utils/src/index.ts
export * from './format';
export * from './validation';
export * from './storage';
TypeScript 类型
typescript
// packages/types/src/user.ts
export interface User {
id: string;
email: string;
name: string;
avatar?: string;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
export type UserRole = 'admin' | 'editor' | 'viewer';
export interface CreateUserDTO {
email: string;
name: string;
password: string;
role?: UserRole;
}
export interface UpdateUserDTO {
name?: string;
avatar?: string;
role?: UserRole;
}
typescript
// packages/types/src/index.ts
export * from './user';
export * from './post';
export * from './api';
配置共享
ESLint 配置
json
// packages/config/eslint/package.json
{
"name": "@myproject/eslint-config",
"version": "1.0.0",
"main": "index.js"
}
javascript
// packages/config/eslint/index.js
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
rules: {
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
settings: {
react: {
version: 'detect',
},
},
};
TypeScript 配置
json
// packages/config/typescript/package.json
{
"name": "@myproject/typescript-config",
"version": "1.0.0",
"main": "base.json"
}
json
// packages/config/typescript/base.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist", ".next", "coverage"]
}
构建优化
增量构建
json
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"inputs": [
"src/**",
"tsconfig.json",
"package.json",
"!**/*.test.*",
"!**/*.spec.*"
]
}
}
}
远程缓存
bash
# 登录 Turborepo
npx turbo login
# 链接远程缓存
npx turbo link
# 使用远程缓存构建
npx turbo build --remote-only
测试策略
单元测试
typescript
// packages/utils/src/__tests__/format.test.ts
import { formatDate, formatNumber, formatFileSize } from '../format';
describe('formatDate', () => {
it('should format date correctly', () => {
const date = new Date('2024-01-15 14:30:00');
expect(formatDate(date)).toBe('2024-01-15');
expect(formatDate(date, 'YYYY-MM-DD HH:mm:ss')).toBe('2024-01-15 14:30:00');
});
it('should handle string date', () => {
expect(formatDate('2024-01-15')).toBe('2024-01-15');
});
});
describe('formatNumber', () => {
it('should format number correctly', () => {
expect(formatNumber(1234567)).toBe('1,234,567');
expect(formatNumber(1234.567, 2)).toBe('1,234.57');
});
});
describe('formatFileSize', () => {
it('should format file size correctly', () => {
expect(formatFileSize(1024)).toBe('1.00 KB');
expect(formatFileSize(1048576)).toBe('1.00 MB');
expect(formatFileSize(1073741824)).toBe('1.00 GB');
});
});
集成测试
typescript
// apps/web/src/__tests__/integration/api.test.ts
import { createMocks } from 'node-mocks-http';
import handler from '../../pages/api/users';
describe('/api/users', () => {
it('should return users list', async () => {
const { req, res } = createMocks({
method: 'GET',
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
expect(Array.isArray(data)).toBe(true);
});
it('should create user', async () => {
const { req, res } = createMocks({
method: 'POST',
body: {
email: 'test@example.com',
name: 'Test User',
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(201);
const data = JSON.parse(res._getData());
expect(data.email).toBe('test@example.com');
});
});
发布管理
版本管理
bash
# 安装 changeset
pnpm add -Dw @changesets/cli
# 初始化
pnpm changeset init
# 创建 changeset
pnpm changeset
# 更新版本
pnpm changeset version
# 发布
pnpm changeset publish
json
// .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
总结
Monorepo 实践的关键要点:
- 工具选型:pnpm + Turborepo 是目前最佳组合
- 项目结构:apps(应用)+ packages(共享包)清晰分层
- 代码共享:组件库、工具函数、类型定义统一管理
- 配置共享:ESLint、TypeScript、Prettier 配置复用
- 构建优化:增量构建、远程缓存提升效率
- 测试策略:单元测试 + 集成测试保证质量
- 版本管理:Changesets 管理版本和发布
Monorepo 特别适合中大型团队和复杂项目,可以显著提升开发效率和代码质量。