前端应用架构
采用 "业务模块化 + 数据分层" 的架构模式。
模块化是指按业务领域(如订单、用户)而非技术类型组织代码,实现逻辑高内聚。
数据分层则主要是引入 Controller 层专门处理数据转换与校验,让 View 层只负责纯粹的 UI 渲染。
解决了随着项目复杂度增加带来的组件代码耦合与维护困难等问题。
数据分层
架构图
将前端数据流处理划分为清晰的四层,每层职责单一。
┌─────────────────────────────────────────────────────────────┐
│ View 层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 用户交互 → 触发 Controller 方法 │ │
│ │ 接收 UI Friendly 数据 → 直接渲染 │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Controller 层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 调用 Service/API 获取数据 │ │
│ │ 2. 数据校验(zod) │ │
│ │ 3. 数据转换(格式化、计算、映射) │ │
│ │ 4. 返回 UI Friendly 数据 │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Service 层 [可选] │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 聚合多个 API 调用 │ │
│ │ 处理串行/并行请求逻辑 │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ API 层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 纯粹的 HTTP 请求发送 │ │
│ │ 与后端接口严格对齐 │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│
▼
Backend API1. API 层 (Network Layer)
- 职责:仅负责 HTTP 请求发送。
- 规范:与后端接口严格对齐(参数、返回结构完全一致),不包含任何业务逻辑。
- 输出:
Promise<BackendResponse>
示例:
typescript
// modules/user/api.ts
export const userApi = {
// ✅ 正确:纯粹的请求发送
getUser: (id: string) => request.get(`/users/${id}`),
// ❌ 错误:不要在 API 层做数据处理
getUser: async (id: string) => {
const res = await request.get(`/users/${id}`)
return res.data.result // 这应该在 Controller 或拦截器处理
}
}2. Service 层 (Aggregation Layer) [可选]
- 职责:处理多接口聚合、串行/并行请求逻辑。
- 规范:当一个页面操作需要调用多个 API 时启用;单接口简单场景可省略。
- 输出:向 Controller 提供单一数据源,屏蔽底层多接口细节。
示例:
typescript
// modules/order/service.ts
export const orderService = {
// 聚合订单详情 + 用户信息 + 物流信息
getOrderDetail: async (orderId: string) => {
const [order, user, logistics] = await Promise.all([
orderApi.getOrder(orderId),
userApi.getUser(order.userId),
logisticsApi.getTracking(orderId)
])
return { order, user, logistics }
}
}3. Controller 层 (Adapter Layer)
- 职责:数据适配与业务逻辑处理。
- 场景:
- 数据清洗:补全默认值、格式转换(如
YYYY-MM-DD)、字段重命名。 - 数据校验:验证数据完整性与安全性(如使用 zod)。
- 计算逻辑:前端纯业务计算(如根据数量计算总价)。
- 数据清洗:补全默认值、格式转换(如
- 输出:向 View 层提供 "拿来即用" 的 UI Friendly 数据结构。
示例:
typescript
// modules/order/controller.ts
import { z } from 'zod'
import dayjs from 'dayjs'
const OrderSchema = z.object({
id: z.string(),
amount: z.number().positive(),
createdAt: z.string()
})
export const orderController = {
getOrderDetail: async (orderId: string) => {
// 1. 获取数据
const rawData = await orderService.getOrderDetail(orderId)
// 2. 数据校验
const validated = OrderSchema.parse(rawData.order)
// 3. 数据转换(UI Friendly)
return {
...validated,
// 格式化日期
displayDate: dayjs(validated.createdAt).format('YYYY-MM-DD HH:mm'),
// 计算总价(含税)
totalAmount: validated.amount * 1.13,
// 状态映射
statusText: STATUS_MAP[validated.status] || '未知'
}
}
}4. View 层 (Presentation Layer)
- 职责:纯 UI 渲染与交互响应。
- 规范:不包含复杂的数据处理逻辑,只负责展示数据和触发 Controller 方法。
示例:
tsx
// modules/order/detail/page.tsx
export default function OrderDetailPage({ params }: { params: { id: string } }) {
const [order, setOrder] = useState(null)
useEffect(() => {
// ✅ 正确:直接使用 Controller 返回的数据
orderController.getOrderDetail(params.id).then(setOrder)
}, [params.id])
if (!order) return <Loading />
return (
<div>
{/* 直接使用,无需额外处理 */}
<h1>订单号:{order.id}</h1>
<p>下单时间:{order.displayDate}</p>
<p>总价:¥{order.totalAmount}</p>
<p>状态:{order.statusText}</p>
</div>
)
}业务模块化
按 "业务领域" 而非 "技术类型" 组织代码。每个业务模块内部闭环,包含自己的完整分层。
SPA 项目(Vite / CRA)
text
src/modules/
├─ home/ # [业务模块A]
│ ├─ index.tsx # View
│ ├─ controller.ts # Controller
│ ├─ service.ts # Service
│ ├─ api.ts # API
│ ├─ types.ts # Types
│ └─ components/ # 模块私有组件
│
└─ user/ # [业务模块B]
└─ ...Next.js App Router 项目(路由即模块)
在 Next.js App Router 中,利用其天然的路由即模块特性,将架构分层文件直接置于路由目录下,实现业务的高内聚。
目录结构示例:
text
src/app/
├─ (home)/ # 首页模块
│ ├─ page.tsx # [View 层] 页面入口
│ ├─ controller.ts # [Controller 层] 业务逻辑
│ ├─ types.ts # 类型定义
│ └─ components/ # 模块私有组件
│
├─ users/ # 用户模块
│ ├─ page.tsx # [View 层]
│ ├─ controller.ts # [Controller 层]
│ └─ [id]/ # 用户详情子模块
│ ├─ page.tsx # [View 层]
│ └─ controller.ts # [Controller 层]分层结合方式:
- Page (
page.tsx):作为 View 层,负责接收路由参数(params/searchParams),直接调用 Controller 获取数据并渲染。 - Controller (
controller.ts):作为 Controller 层,负责调用 API/Service,处理数据校验(Zod)与转换,返回给 Page 组件。 - API/Service:可视复用程度放置于当前目录(私有)或
src/api、src/services(共享)。
Umi 应用特殊说明
在 Umi(@umijs/max)项目中,存在框架级的数据流约定,需要作为架构规范的补充说明:
src/models为框架级模型目录:所有通过useModel('xxx')访问的全局状态模型都应放在src/models/下,由 Umi 自动注册。- 不要把 Umi Model 当作普通 Hook:
useModel的模型不应放入shared/hooks,否则会误导为“可直接 import 调用”的普通 Hook。 - 职责边界:
- 跨页面/跨模块共享的 UI 状态:放
src/models(如全局弹窗、统一消息队列)。 - 仅模块内使用的状态:放模块内
hooks/或组件本地状态。 - 与基础设施相关的全局能力:放
src/shared/stores+src/shared/hooks(如全局 loading)。
- 跨页面/跨模块共享的 UI 状态:放
架构设计价值
1. 高内聚低耦合
业务模块内聚合了所有相关逻辑,修改某个功能时只需关注该模块目录,不影响其他部分。
2. 职责单一
- View 层只管画图
- Controller 层只管数据
- API 层只管发请求
代码逻辑清晰,易于测试和维护。
3. 兼顾复用与隔离
src/common存放真正的全局通用能力- 路由目录下的
components/存放业务私有组件,避免全局组件库膨胀与污染
4. 并行开发友好
不同开发者可以独立负责不同的业务模块,极大减少代码冲突。
最佳实践建议
1. 不要在 View 层处理复杂逻辑
一旦发现 useEffect 或数据计算逻辑过长,立即抽离到 Controller 或 Hooks 中。
❌ 错误示例:
tsx
// page.tsx
export default function OrderPage() {
const [orders, setOrders] = useState([])
useEffect(() => {
fetch('/api/orders')
.then((res) => res.json())
.then((data) => {
// ❌ 在 View 层做复杂数据处理
const processed = data.map((order) => ({
...order,
displayDate: dayjs(order.createdAt).format('YYYY-MM-DD'),
totalAmount: order.amount * 1.13
}))
setOrders(processed)
})
}, [])
return <div>{/* ... */}</div>
}✅ 正确示例:
tsx
// controller.ts
export const orderController = {
getOrders: async () => {
const data = await orderApi.getOrders()
return data.map((order) => ({
...order,
displayDate: dayjs(order.createdAt).format('YYYY-MM-DD'),
totalAmount: order.amount * 1.13
}))
}
}
// page.tsx
export default function OrderPage() {
const [orders, setOrders] = useState([])
useEffect(() => {
orderController.getOrders().then(setOrders)
}, [])
return <div>{/* ... */}</div>
}2. 保持 API 层纯净
API 函数应该只做一件事——转发请求。不要在 API 层里做 data.result 这种解包操作(应在 core/request 拦截器统一处理,或 Controller 处理)。
❌ 错误示例:
typescript
// api.ts
export const userApi = {
getUser: async (id: string) => {
const res = await request.get(`/users/${id}`)
return res.data.result // ❌ 不要在 API 层解包
}
}✅ 正确示例:
typescript
// core/request/index.ts
request.interceptors.response.use((response) => {
// 在拦截器统一解包
return response.data.result
})
// api.ts
export const userApi = {
getUser: (id: string) => request.get(`/users/${id}`) // ✅ 纯粹的请求
}3. 按需抽象
不要一开始就过度设计。只有当一个逻辑在两个以上模块使用时,才考虑提取到 src/common。
原则:
- Rule of Three:第三次重复时才抽象
- YAGNI:You Aren't Gonna Need It(不要过早优化)