前端也需要架构:构建可维护的大型应用

April 8, 2025

前端也需要架构:构建可维护的大型应用

很多前端开发者认为"架构"是后端的事。但当你的项目从几千行增长到几万行、十几万行时,你会发现:没有好的架构,代码很快就会变成一团乱麻。

这篇文章分享我在大型前端项目中应用的架构原则和实践。

为什么前端需要架构?

痛点场景

你是否遇到过这些问题:

  1. 改一个功能,影响其他地方:牵一发而动全身
  2. 复制粘贴成瘾:相似的逻辑散落在各处
  3. 测试困难:业务逻辑和 UI 紧耦合
  4. 新人上手慢:没人知道代码该放在哪里
  5. 重构恐惧症:不敢动旧代码

这些都是架构问题的表现。

核心原则:分层架构

借鉴 Clean Architecture 的思想,前端应用可以分为这几层:

┌──────────────────────────────────┐
      Presentation Layer            <- React Components
├──────────────────────────────────┤
      Application Layer             <- Hooks, State Management
├──────────────────────────────────┤
      Domain Layer                  <- Business Logic, Entities
├──────────────────────────────────┤
      Infrastructure Layer          <- API Clients, Storage
└──────────────────────────────────┘

依赖规则:内层不依赖外层。业务逻辑(Domain)不依赖于 UI 框架(React)。

第一层:Domain Layer(领域层)

这是你的业务逻辑,与框架无关。

定义实体(Entities)

// domain/entities/User.ts
export type UserId = string & { readonly brand: unique symbol }

export type User = {
  id: UserId
  email: string
  name: string
  role: UserRole
  createdAt: Date
}

export type UserRole = 'admin' | 'editor' | 'viewer'

// 业务规则
export class UserEntity {
  constructor(private user: User) {}

  canEditPost(post: Post): boolean {
    return (
      this.user.role === 'admin' ||
      (this.user.role === 'editor' && post.authorId === this.user.id)
    )
  }

  canDeleteUser(targetUser: User): boolean {
    if (this.user.role !== 'admin') return false
    if (targetUser.id === this.user.id) return false // 不能删除自己
    return true
  }

  isActive(): boolean {
    const accountAge = Date.now() - this.user.createdAt.getTime()
    const thirtyDays = 30 * 24 * 60 * 60 * 1000
    return accountAge < thirtyDays
  }
}

定义值对象(Value Objects)

// domain/value-objects/Email.ts
export class Email {
  private constructor(private readonly value: string) {}

  static create(email: string): Email | Error {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(email)) {
      return new Error('Invalid email format')
    }
    return new Email(email.toLowerCase())
  }

  toString(): string {
    return this.value
  }

  getDomain(): string {
    return this.value.split('@')[1]
  }

  equals(other: Email): boolean {
    return this.value === other.value
  }
}

// 使用
const emailResult = Email.create('USER@EXAMPLE.COM')
if (emailResult instanceof Error) {
  console.error(emailResult.message)
} else {
  console.log(emailResult.toString()) // 'user@example.com'
  console.log(emailResult.getDomain()) // 'example.com'
}

第二层:Application Layer(应用层)

这一层协调业务逻辑,但不包含业务规则。

Use Cases(用例)

// application/use-cases/CreatePost.ts
import { PostRepository } from '../ports/PostRepository'
import { UserRepository } from '../ports/UserRepository'
import { Post } from '@/domain/entities/Post'
import { UserEntity } from '@/domain/entities/User'

type CreatePostInput = {
  title: string
  content: string
  authorId: string
}

type CreatePostOutput = {
  success: boolean
  post?: Post
  error?: string
}

export class CreatePostUseCase {
  constructor(
    private postRepo: PostRepository,
    private userRepo: UserRepository
  ) {}

  async execute(input: CreatePostInput): Promise<CreatePostOutput> {
    // 1. 验证用户权限
    const user = await this.userRepo.findById(input.authorId)
    if (!user) {
      return { success: false, error: 'User not found' }
    }

    const userEntity = new UserEntity(user)
    if (!userEntity.canCreatePost()) {
      return { success: false, error: 'User cannot create posts' }
    }

    // 2. 验证输入
    if (input.title.length < 5) {
      return { success: false, error: 'Title too short' }
    }

    // 3. 创建文章
    const post= await this.postRepo.create({
      title: input.title,
      content: input.content,
      authorId: input.authorId,
      status: 'draft',
      createdAt: new Date(),
    })

    return { success: true, post }
  }
}

Ports(端口/接口)

定义抽象接口,不依赖具体实现:

// application/ports/PostRepository.ts
import { Post, PostId } from '@/domain/entities/Post'

export interface PostRepository {
  findById(id: PostId): Promise<Post | null>
  findAll(): Promise<Post[]>
  create(post: Omit<Post, 'id'>): Promise<Post>
  update(id: PostId, post: Partial<Post>): Promise<Post>
  delete(id: PostId): Promise<void>
}

// application/ports/UserRepository.ts
export interface UserRepository {
  findById(id: UserId): Promise<User | null>
  findByEmail(email: Email): Promise<User | null>
  create(user: Omit<User, 'id'>): Promise<User>
}

第三层:Infrastructure Layer(基础设施层)

实现具体的技术细节。

API 客户端(Adapters)

// infrastructure/api/ApiPostRepository.ts
import { PostRepository } from '@/application/ports/PostRepository'
import { Post, PostId } from '@/domain/entities/Post'

export class ApiPostRepository implements PostRepository {
  constructor(private apiClient: ApiClient) {}

  async findById(id: PostId): Promise<Post | null> {
    try {
      const response = await this.apiClient.get(`/posts/${id}`)
      return this.mapToPost(response.data)
    } catch (error) {
      if (error.status === 404) return null
      throw error
    }
  }

  async findAll(): Promise<Post[]> {
    const response = await this.apiClient.get('/posts')
    return response.data.map(this.mapToPost)
  }

  async create(post: Omit<Post, 'id'>): Promise<Post> {
    const response = await this.apiClient.post('/posts', post)
    return this.mapToPost(response.data)
  }

  private mapToPost(data: any): Post {
    return {
      id: data.id,
      title: data.title,
      content: data.content,
      authorId: data.author_id, // 处理命名差异
      status: data.status,
      createdAt: new Date(data.created_at),
    }
  }
}

Local Storage 实现

// infrastructure/storage/LocalStoragePostRepository.ts
export class LocalStoragePostRepository implements PostRepository {
  private readonly key = 'posts'

  async findAll(): Promise<Post[]> {
    const data = localStorage.getItem(this.key)
    return data ? JSON.parse(data) : []
  }

  async findById(id: PostId): Promise<Post | null> {
    const posts = await this.findAll()
    return posts.find(p => p.id === id) || null
  }

  async create(post: Omit<Post, 'id'>): Promise<Post> {
    const posts = await this.findAll()
    const newPost = { ...post, id: generateId() }
    posts.push(newPost)
    localStorage.setItem(this.key, JSON.stringify(posts))
    return newPost
  }

  // ... 其他方法
}

第四层:Presentation Layer(表现层)

React 组件和 UI 逻辑。

自定义 Hooks 连接应用层

// presentation/hooks/usePosts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { CreatePostUseCase } from '@/application/use-cases/CreatePost'
import { container } from '@/di/container' // 依赖注入容器

export function usePosts() {
  const queryClient = useQueryClient()
  const postRepo = container.get('PostRepository')

  const { data: posts, isLoading } = useQuery({
    queryKey: ['posts'],
    queryFn: () => postRepo.findAll(),
  })

  const createPostMutation = useMutation({
    mutationFn: async (input: CreatePostInput) => {
      const useCase = new CreatePostUseCase(
        container.get('PostRepository'),
        container.get('UserRepository')
      )
      return useCase.execute(input)
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    },
  })

  return {
    posts,
    isLoading,
    createPost: createPostMutation.mutate,
    isCreating: createPostMutation.isPending,
  }
}

展示组件(纯 UI)

// presentation/components/PostCard.tsx
type PostCardProps = {
  post: Post
  onEdit?: (post: Post) => void
  onDelete?: (postId: PostId) => void
}

export function PostCard({ post, onEdit, onDelete }: PostCardProps) {
  return (
    <article className="post-card">
      <h2>{post.title}</h2>
      <p>{post.content}</p>
      <footer>
        {onEdit && <button onClick={()=> onEdit(post)}>Edit</button>}
        {onDelete && <button onClick={()=> onDelete(post.id)}>Delete</button>}
      </footer>
    </article>
  )
}

容器组件(连接逻辑)

// presentation/containers/PostList.tsx
import { usePosts } from '../hooks/usePosts'
import { PostCard } from '../components/PostCard'

export function PostList() {
  const { posts, isLoading, deletePost } = usePosts()
  const { user } = useAuth()

  if (isLoading) return <Spinner />

  return (
    <div>
      {posts?.map(post => {
        const userEntity = new UserEntity(user)
        const canEdit = userEntity.canEditPost(post)
        const canDelete = userEntity.canDeletePost(post)

        return (
          <PostCard
            key={post.id}
            post={post}
            onEdit={canEdit ? handleEdit : undefined}
            onDelete={canDelete ? deletePost : undefined}
          />
        )
      })}
    </div>
  )
}

依赖注入

使用简单的依赖注入容器:

// di/container.ts
type Container = {
  [key: string]: any
}

class DIContainer {
  private services: Container = {}

  register<T>(name: string, instance: T): void {
    this.services[name] = instance
  }

  get<T>(name: string): T {
    if (!this.services[name]) {
      throw new Error(`Service ${name} not found`)
    }
    return this.services[name]
  }
}

export const container = new DIContainer()

// di/setup.ts
import { ApiClient } from '@/infrastructure/api/ApiClient'
import { ApiPostRepository } from '@/infrastructure/api/ApiPostRepository'

export function setupDI() {
  const apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL)

  container.register('ApiClient', apiClient)
  container.register('PostRepository', new ApiPostRepository(apiClient))
  container.register('UserRepository', new ApiUserRepository(apiClient))
}

// app/layout.tsx
setupDI()

测试策略

分层架构的最大优势:每一层都容易测试

测试领域逻辑

// domain/entities/__tests__/User.test.ts
import { UserEntity } from '../User'

describe('UserEntity', () => {
  describe('canEditPost', () => {
    it('admin can edit any post', () => {
      const admin = new UserEntity({
        id: '1',
        role: 'admin',
        // ...
      })

      const post = { authorId: '999', /* ... */ }

      expect(admin.canEditPost(post)).toBe(true)
    })

    it('editor can only edit own posts', () => {
      const editor = new UserEntity({
        id: '2',
        role: 'editor',
        // ...
      })

      const ownPost = { authorId: '2', /* ... */ }
      const othersPost = { authorId: '3', /* ... */ }

      expect(editor.canEditPost(ownPost)).toBe(true)
      expect(editor.canEditPost(othersPost)).toBe(false)
    })
  })
})

测试 Use Cases

// application/use-cases/__tests__/CreatePost.test.ts
import { CreatePostUseCase } from '../CreatePost'
import { MockPostRepository } from '@/test/mocks/MockPostRepository'

describe('CreatePostUseCase', () => {
  it('should create post for authorized user', async () => {
    const mockPostRepo = new MockPostRepository()
    const mockUserRepo = new MockUserRepository([
      { id: '1', role: 'editor', /* ... */ }
    ])

    const useCase = new CreatePostUseCase(mockPostRepo, mockUserRepo)

    const result = await useCase.execute({
      title: 'Test Post',
      content: 'Content',
      authorId: '1',
    })

    expect(result.success).toBe(true)
    expect(result.post?.title).toBe('Test Post')
  })

  it('should reject post with short title', async () => {
    const useCase = new CreatePostUseCase(mockPostRepo, mockUserRepo)

    const result = await useCase.execute({
      title: 'Hi',  // Too short
      content: 'Content',
      authorId: '1',
    })

    expect(result.success).toBe(false)
    expect(result.error).toBe('Title too short')
  })
})

测试组件

// presentation/components/__tests__/PostCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { PostCard } from '../PostCard'

describe('PostCard', () => {
  const mockPost = {
    id: '1',
    title: 'Test Post',
    content: 'Test content',
    authorId: '1',
  }

  it('should render post title and content', () => {
    render(<PostCard post={mockPost} />)

    expect(screen.getByText('Test Post')).toBeInTheDocument()
    expect(screen.getByText('Test content')).toBeInTheDocument()
  })

  it('should call onEdit when edit button clicked', () => {
    const onEdit = jest.fn()
    render(<PostCard post={mockPost} onEdit={onEdit} />)

    fireEvent.click(screen.getByText('Edit'))

    expect(onEdit).toHaveBeenCalledWith(mockPost)
  })
})

目录结构

src/
├── domain/              # 业务逻辑(与框架无关)
   ├── entities/
      ├── User.ts
      └── Post.ts
   └── value-objects/
       └── Email.ts

├── application/         # 用例和接口定义
   ├── use-cases/
      ├── CreatePost.ts
      └── DeletePost.ts
   └── ports/
       ├── PostRepository.ts
       └── UserRepository.ts

├── infrastructure/      # 技术实现
   ├── api/
      ├── ApiClient.ts
      └── ApiPostRepository.ts
   └── storage/
       └── LocalStorageRepository.ts

├── presentation/        # UI 
   ├── components/      # 纯展示组件
   ├── containers/      # 容器组件
   ├── hooks/           # 自定义 hooks
   └── pages/           # 页面组件

└── di/                  # 依赖注入配置
    ├── container.ts
    └── setup.ts

何时使用这种架构?

适合场景

  • 中大型应用(10000+ 行代码)
  • 业务逻辑复杂
  • 需要频繁更换技术栈
  • 团队规模较大(5+ 开发者)
  • 长期维护的项目

不适合场景

  • 简单的展示型网站
  • 快速原型/MVP
  • 小型工具类应用
  • 短期项目

实施建议

渐进式迁移

不要试图一次性重构整个项目:

  1. 新功能先用新架构
  2. 重构时逐步迁移旧代码
  3. 建立团队共识:确保每个人理解架构
  4. 文档和示例:让新人快速上手

团队培训

// 创建架构决策记录(ADR)
/**
 * ADR-001: 采用分层架构
 *
 * 背景:
 * 项目规模增长到 50k 行,代码难以维护
 *
 * 决策:
 * 采用四层架构:Domain, Application, Infrastructure, Presentation
 *
 * 后果:
 * - 优点:清晰的职责分离,易于测试
 * - 缺点:增加了代码量,学习曲线较陡
 *
 * 替代方案:
 * - Feature-based 架构
 * - 继续现有结构
 */

常见陷阱

1. 过度工程化

// ❌ 过度抽象
class AbstractFactoryProviderManagerBuilder {
  // 三层 wrapper 只为创建一个对象
}

// ✅ 简单直接
const user = new User({ id: '1', name: 'Alice' })

2. 层级混乱

// ❌ UI 组件直接访问 API
function PostList() {
  const posts = await fetch('/api/posts')
  // ...
}

// ✅ 通过 hooks 访问应用层
function PostList() {
  const { posts } = usePosts()
  // ...
}

3. 业务逻辑泄漏

// ❌ 业务规则在组件里
function PostEditor() {
  const canEdit = user.role === 'admin' || user.id === post.authorId
  // ...
}

// ✅ 业务规则在领域层
function PostEditor() {
  const userEntity = new UserEntity(user)
  const canEdit = userEntity.canEditPost(post)
  // ...
}

结论

好的架构不是为了炫技,而是为了:

  1. 降低认知负担:每个文件都有明确的位置
  2. 提高开发效率:不用每次都思考"该怎么写"
  3. 便于测试:每一层都可以独立测试
  4. 易于扩展:添加新功能不影响旧代码
  5. 技术无关:换框架不需要重写业务逻辑

记住:架构的目的是让代码更容易修改,而不是更难修改。

如果你的"架构"让开发变得更慢、更复杂,那就是过度设计了。找到适合你项目规模的架构,才是最好的架构。

参考资源