前端也需要架构:构建可维护的大型应用
很多前端开发者认为"架构"是后端的事。但当你的项目从几千行增长到几万行、十几万行时,你会发现:没有好的架构,代码很快就会变成一团乱麻。
这篇文章分享我在大型前端项目中应用的架构原则和实践。
为什么前端需要架构?
痛点场景
你是否遇到过这些问题:
- 改一个功能,影响其他地方:牵一发而动全身
- 复制粘贴成瘾:相似的逻辑散落在各处
- 测试困难:业务逻辑和 UI 紧耦合
- 新人上手慢:没人知道代码该放在哪里
- 重构恐惧症:不敢动旧代码
这些都是架构问题的表现。
核心原则:分层架构
借鉴 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
- 小型工具类应用
- 短期项目
实施建议
渐进式迁移
不要试图一次性重构整个项目:
- 新功能先用新架构
- 重构时逐步迁移旧代码
- 建立团队共识:确保每个人理解架构
- 文档和示例:让新人快速上手
团队培训
// 创建架构决策记录(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)
// ...
}
结论
好的架构不是为了炫技,而是为了:
- 降低认知负担:每个文件都有明确的位置
- 提高开发效率:不用每次都思考"该怎么写"
- 便于测试:每一层都可以独立测试
- 易于扩展:添加新功能不影响旧代码
- 技术无关:换框架不需要重写业务逻辑
记住:架构的目的是让代码更容易修改,而不是更难修改。
如果你的"架构"让开发变得更慢、更复杂,那就是过度设计了。找到适合你项目规模的架构,才是最好的架构。