摘要
以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;
}
MemberEntity 用 Entity 后缀命名,这是企业级项目的常见约定------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 的值是 string,width 的值是 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 接口要求 red 是 number 类型,传字符串会触发类型错误。
父组件 App 持有颜色状态:
typescript
const [color, setColor] = useState<Color>({
red: 20, green: 240, blue: 180
})
useState<Color> 的泛型参数约束了状态类型。setColor 传给 ColorPicker 的 onColorUpdate,形成完整的单向数据流:滑块拖动 → onColorUpdate → setColor → 状态更新 → ColorBrowser 和 ColorPicker 重新渲染。
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 的类型系统,就是最好的文档。