Skip to content

前端应用架构

采用 "业务模块化 + 数据分层" 的架构模式。

模块化是指按业务领域(如订单、用户)而非技术类型组织代码,实现逻辑高内聚。

数据分层则主要是引入 Controller 层专门处理数据转换与校验,让 View 层只负责纯粹的 UI 渲染。

解决了随着项目复杂度增加带来的组件代码耦合与维护困难等问题。

数据分层

架构图

将前端数据流处理划分为清晰的四层,每层职责单一。

┌─────────────────────────────────────────────────────────────┐
│                         View 层                              │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  用户交互 → 触发 Controller 方法                       │    │
│  │  接收 UI Friendly 数据 → 直接渲染                      │    │
│  └─────────────────────────────────────────────────────┘    │
└────────────────────────┬────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                      Controller 层                           │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  1. 调用 Service/API 获取数据                         │    │
│  │  2. 数据校验(zod)                                   │    │
│  │  3. 数据转换(格式化、计算、映射)                       │    │
│  │  4. 返回 UI Friendly 数据                             │    │
│  └─────────────────────────────────────────────────────┘    │
└────────────────────────┬────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                      Service 层 [可选]                       │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  聚合多个 API 调用                                    │    │
│  │  处理串行/并行请求逻辑                                │    │
│  └─────────────────────────────────────────────────────┘    │
└────────────────────────┬────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                         API 层                               │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  纯粹的 HTTP 请求发送                                 │    │
│  │  与后端接口严格对齐                                   │    │
│  └─────────────────────────────────────────────────────┘    │
└────────────────────────┬────────────────────────────────────┘


                    Backend API

1. 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)

  • 职责:数据适配与业务逻辑处理。
  • 场景
    1. 数据清洗:补全默认值、格式转换(如 YYYY-MM-DD)、字段重命名。
    2. 数据校验:验证数据完整性与安全性(如使用 zod)。
    3. 计算逻辑:前端纯业务计算(如根据数量计算总价)。
  • 输出:向 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/apisrc/services(共享)。

Umi 应用特殊说明

在 Umi(@umijs/max)项目中,存在框架级的数据流约定,需要作为架构规范的补充说明:

  • src/models 为框架级模型目录:所有通过 useModel('xxx') 访问的全局状态模型都应放在 src/models/ 下,由 Umi 自动注册。
  • 不要把 Umi Model 当作普通 HookuseModel 的模型不应放入 shared/hooks,否则会误导为“可直接 import 调用”的普通 Hook。
  • 职责边界
    • 跨页面/跨模块共享的 UI 状态:放 src/models(如全局弹窗、统一消息队列)。
    • 仅模块内使用的状态:放模块内 hooks/ 或组件本地状态。
    • 与基础设施相关的全局能力:放 src/shared/stores + src/shared/hooks(如全局 loading)。

架构设计价值

1. 高内聚低耦合

业务模块内聚合了所有相关逻辑,修改某个功能时只需关注该模块目录,不影响其他部分。

2. 职责单一

  • View 层只管画图
  • Controller 层只管数据
  • API 层只管发请求

代码逻辑清晰,易于测试和维护。

3. 兼顾复用与隔离

  • src/common 存放真正的全局通用能力
  • 路由目录下的 components/ 存放业务私有组件,避免全局组件库膨胀与污染

4. 并行开发友好

不同开发者可以独立负责不同的业务模块,极大减少代码冲突。

最佳实践建议

1. 不要在 View 层处理复杂逻辑

一旦发现 useEffect 或数据计算逻辑过长,立即抽离到 ControllerHooks 中。

❌ 错误示例:

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(不要过早优化)

Released under the MIT License.