React + TypeScript 项目架构实战:Model层与Api层的分离设计

摘要

以ColorPicker+MemberTable拆解React+TS的Model/Api分层架构:model定义数据接口作为单一真相源,api封装请求标注返回类型,组件专注UI,实现类型安全闭环。


一、项目变大了,代码放哪

单个组件文件写起来很爽,但项目一变大,问题就来了:这个接口的数据结构是什么样的?这个类型在三个组件里都用到了,定义在哪?API 请求散落在各个组件里,切换后端时改哪里?

React + TypeScript 的企业级项目通常采用两层目录架构来解决这个问题:model 层api 层。本文以一个 Color Picker 颜色选择器 + MemberTable 成员列表项目为样本,拆解这种分层设计的核心思想。

bash 复制代码
src/
├── model/
│   ├── color.ts          # 颜色数据模型
│   └── member.ts         # 成员数据模型
├── api/
│   └── memberApi.ts      # 成员接口封装
├── components/
│   ├── ColorBrowser.tsx  # 颜色预览(纯展示)
│   ├── ColorPicker.tsx   # 颜色调节(受控组件)
│   └── MemberTable.tsx   # 成员列表(异步数据)
└── App.tsx               # 状态持有者

二、Model 层:数据接口的单一真相源

Color 接口需要在 ColorBrowser 和 ColorPicker 两个组件中使用。如果每个组件各自定义,一旦字段名变更,需要改两个地方,而且容易出现不一致。

Model 层的解决方案:把数据接口抽取到独立文件中,谁用谁导入。

typescript 复制代码
// model/color.ts
export interface Color {
  red: number;
  green: number;
  blue: number;
}

RGB 三个通道的值范围都是 0-255,number 类型精确表达了这一约束------它比 string 更安全,比 { r: number } 等缩写更可读。

typescript 复制代码
// model/member.ts
export interface MemberEntity {
  id: number;
  login: string;
  avatar_url: string;
}

MemberEntityEntity 后缀命名,这是企业级项目的常见约定------model 中定义的是"实体"(Entity),代表一个业务对象,而不是一个临时的数据结构。avatar_url 使用下划线命名,说明它来自 GitHub API 的原始字段名,保持与后端一致能减少字段映射代码。

组件中通过 import type 导入:

typescript 复制代码
// App.tsx
import type { Color } from './model/color';

import type 是 TypeScript 3.8 引入的语法,它告诉编译器:这个导入只在类型检查时使用,编译后会被完全删除,不会产生任何运行时代码。对于纯类型导入,使用 import type 是 TypeScript 官方推荐的最佳实践------它让打包器(Vite/Rollup)明确知道哪些导入可以安全移除,减少最终 bundle 体积。


三、Api 层:接口方法的模块化封装

API 请求散落在组件中是最常见的反模式。memberApi.ts 展示了正确的做法:

typescript 复制代码
// api/memberApi.ts
import { type MemberEntity } from '../model/member';

export const getMemberCollection = (): Promise<MemberEntity[]> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1457912, login: "brauliodiez", avatar_url: "https://avatars.githubusercontent.com/u/1457912?v=3" },
        { id: 4374977, login: "Nasdan", avatar_url: "https://avatars.githubusercontent.com/u/4374977?v=3" }
      ])
    }, 500)
  })
}

这个函数有三个关键设计:

返回类型显式标注(): Promise<MemberEntity[]> 明确告诉调用方"这个函数返回一个 MemberEntity 数组的 Promise"。组件不需要看实现,只看类型签名就知道怎么用。

与 model 层的类型绑定 :返回类型引用 MemberEntity,这意味着如果 MemberEntity 增加字段,所有调用方都会得到类型提示,编译阶段就能发现遗漏。

与组件解耦getMemberCollection 不依赖 React,不依赖任何组件。它可以在任何地方调用------测试文件、命令行脚本、甚至另一个框架中。这种纯粹性让 api 层可以独立测试和复用。


四、组件层:类型安全的 UI 渲染

4.1 ColorBrowser:纯展示组件

typescript 复制代码
interface Props {
  color: Color
}

const ColorBrowser: React.FC<Props> = (props) => {
  const divStyle: React.CSSProperties = {
    width: "11rem",
    height: "7rem",
    backgroundColor: `rgb(${props.color.red},${props.color.green},${props.color.blue})`
  }
  return <div style={divStyle} />
}

React.CSSProperties 是 React 内置的样式对象类型。它确保了 backgroundColor 的值是 stringwidth 的值是 string | number------如果你写成 width: true,TypeScript 会直接报错。这是 TypeScript 在 UI 层的另一个价值:连样式都能做类型检查

4.2 ColorPicker:受控组件与不可变更新

typescript 复制代码
interface Props {
  color: Color
  onColorUpdate: (color: Color) => void
}

const ColorPicker: React.FC<Props> = (props) => {
  return (
    <div>
      <input type="range" min="0" max="255"
        value={props.color.red}
        onChange={event => props.onColorUpdate({
          ...props.color,
          red: +event.target.value
        })}
      />
      {props.color.red}
      {/* green 和 blue 同理 */}
    </div>
  )
}

三个 range input 分别对应 RGB 三个通道。onChange 中使用了展开运算符 ...props.color 创建新对象,只修改对应通道的值------这是不可变更新的标准模式。+event.target.value 中的 + 将字符串转为数字,因为 Color 接口要求 rednumber 类型,传字符串会触发类型错误。

父组件 App 持有颜色状态:

typescript 复制代码
const [color, setColor] = useState<Color>({
  red: 20, green: 240, blue: 180
})

useState<Color> 的泛型参数约束了状态类型。setColor 传给 ColorPickeronColorUpdate,形成完整的单向数据流:滑块拖动 → onColorUpdatesetColor → 状态更新 → ColorBrowserColorPicker 重新渲染。

4.3 MemberTable:异步数据与 useEffect

typescript 复制代码
const MemberTable: React.FC = () => {
  const [memberCollection, setMemberCollection] = React.useState<MemberEntity[]>([])

  React.useEffect(() => {
    (async () => {
      const members = await getMemberCollection();
      setMemberCollection(members);
    })();
  }, [])

  return (
    <table>
      <thead>
        <tr><th>Avatar</th><th>ID</th><th>Name</th></tr>
      </thead>
      <tbody>
        {memberCollection.map((member: MemberEntity) => (
          <MemberRow key={member.id} member={member} />
        ))}
      </tbody>
    </table>
  )
}

useEffect[] 依赖数组确保 API 只在组件挂载后调用一次。组件先渲染空表格(memberCollection 初始为空数组),500ms 后数据返回,setMemberCollection 触发重新渲染,表格填入数据。这就是"先渲染再加载"的体验优化模式。

useEffect 回调中不能直接 await,所以用 IIFE(立即调用函数表达式)包裹异步逻辑。这是 React 中使用 async/await 的标准写法。

MemberRow 是一个无状态子组件,接收 member 作为 props 渲染单行。将它从 MemberTable 中拆分出来,让 map 回调更简洁,也让 MemberRow 可以独立复用。


五、Model + Api + Component:三层协作的全景图

整个项目的数据流是一条清晰的链路:

scss 复制代码
Model 层 (color.ts / member.ts)
  ↓ 定义数据结构
Api 层 (memberApi.ts)
  ↓ 封装请求,标注返回类型
Component 层 (App.tsx / ColorPicker / MemberTable)
  ↓ 调用 API,渲染 UI

一个具体的例子:如果后端把 login 字段改成了 username,只需要改 member.ts 中的 MemberEntity 接口定义。TypeScript 会立即在所有引用处报错------MemberTable 中的 member.login 会标红,memberApi.ts 的返回值也会标红。不需要靠 grep 搜索,不需要靠运行时 bug 发现,编译阶段就能定位到所有需要修改的位置。

这就是 Model 层作为"单一真相源"的核心价值:改一处,TypeScript 帮你找到所有需要同步修改的地方


六、Component 与 Api/Model 的边界

有一种常见的困惑:组件里能不能直接定义接口类型?组件里能不能直接写 fetch?

答案是可以,但要有清晰的边界意识:

职责 放在哪 原因
数据形状定义 model/ 多个组件共享,改一处全生效
接口请求封装 api/ 与组件解耦,可独立测试
UI 渲染逻辑 components/ 组件专注于"怎么展示"
组件内部状态 components/ 只属于该组件的临时状态

Color 接口被两个组件使用,放在 model 层是合理的。如果某个类型只在一个组件内使用,定义在组件文件内也没问题。判断标准是:如果一个类型被两个以上的文件引用,就抽到 model 层

Api 层的封装也有同样的好处:当从模拟数据切换到真实后端时,只需修改 memberApi.ts 中的实现,把 setTimeout 替换成 fetch,组件代码完全不需要改动。


七、总结

React + TypeScript 的项目架构,核心不是"怎么写组件",而是"怎么组织代码"。Model 层定义数据形状,Api 层封装接口请求,Component 层专注于 UI 渲染------三层各司其职,通过 TypeScript 的类型系统紧密连接。

这个架构的设计哲学可以概括为:让类型成为代码的活文档。当你三个月后回头看这个项目,你不需要翻文档,不需要看注释,只需要看 model 层的接口定义,就能知道整个系统的数据结构。看 api 层的函数签名,就能知道每个接口的请求和响应。TypeScript 的类型系统,就是最好的文档。

相关推荐
用户938515635076 小时前
Type vs Interface:读完这篇就没有面试官能难倒你了
前端·面试·typescript
烬羽9 小时前
navigator.gpu 存在就够了?WebGPU 检测的两层陷阱
typescript·浏览器·全栈
触底反弹9 小时前
🚀 浏览器里跑 1.5B 参数大模型?我用 WebGPU + DeepSeek 做到了
人工智能·面试·typescript
退休倒计时13 小时前
【每日一题】LeetCode 20. 有效的括号 TypeScript
算法·leetcode·职场和发展·typescript
晓说前端14 小时前
TypeScript 核心语法应用 —— Vue 3 中的使用(上)
前端·typescript
渣波15 小时前
React Hooks 核心基石:深度解析 `useState` 的类型推断、泛型约束与空值安全
前端·typescript
YIAN15 小时前
吃透这 5 个核心点,你的 React+TS 代码直接上一个台阶
前端·react.js·typescript
meilindehuzi_a16 小时前
WebGPU DeepSeek项目实战(二):用单例模式加载Tokenizer并转发下载进度
单例模式·typescript·react
用户938515635071 天前
从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战
前端·设计模式·typescript