Chenyj Space
返回博客
·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 优势

  1. 代码共享:组件、工具函数、类型定义共享
  2. 原子提交:跨项目修改在一个提交中完成
  3. 依赖管理:统一依赖版本,避免重复安装
  4. 代码复用:减少重复代码,提高开发效率
  5. 团队协作:更容易进行代码审查和知识共享

工具选型

包管理器: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 实践的关键要点:

  1. 工具选型:pnpm + Turborepo 是目前最佳组合
  2. 项目结构:apps(应用)+ packages(共享包)清晰分层
  3. 代码共享:组件库、工具函数、类型定义统一管理
  4. 配置共享:ESLint、TypeScript、Prettier 配置复用
  5. 构建优化:增量构建、远程缓存提升效率
  6. 测试策略:单元测试 + 集成测试保证质量
  7. 版本管理:Changesets 管理版本和发布

Monorepo 特别适合中大型团队和复杂项目,可以显著提升开发效率和代码质量。