编写一个颜色选择器之后,我理解了 React 工程化的三层架构
摘要:一个包含颜色选择器和成员列表的小项目,如何从"所有代码堆在一起"演进为 Model、API、Component 三层分离?本文通过实际代码,拆解工程化如何让 React 项目变得清晰、可维护、可扩展。
📑 目录
- 从"能跑就行"到"改了不敢动"------项目变大的痛点
- 工程化的本质:关注点分离
- Model 层:给数据立"规矩"
- API 层:把"怎么拿数据"和"在哪用数据"分开
- Component 层:只关心"长什么样"和"怎么交互"
- Component 层详解:三个组件的设计拆解
- Color 模块:三层协作的完整闭环
- Member 模块:加入 API 请求后的三层协作
- 进阶分离:让组件更"纯粹"
- 工程化目录结构:一眼看懂项目全貌
- 互动讨论
从"能跑就行"到"改了不敢动"------项目变大的痛点
很多 React 项目都是从"能跑就行"开始的。初期,所有代码堆在一个组件里------状态、请求、类型、UI 全部混在一起,确实能快速跑起来。
但项目只要稍微变大,问题就来了:
- 改一个字段,要改 5 个组件:同一个数据类型在多个地方重复定义,改的时候漏掉一个就出 bug
- API 请求散落各处 :
fetch写在各种useEffect里,接口地址变了要到处找 - 一个组件几百行,看半天看不懂:状态管理、请求逻辑、验证规则、UI 渲染全部搅在一起
- 不敢改,怕改坏其他地方:没有清晰的职责边界,改一处不知道会影响哪里
这个颜色选择器项目虽然小,但它包含了两个典型的交互场景------滑块控制颜色(受控组件)和远程数据获取(异步请求)------正好用来演示三层架构如何解决上述痛点。
工程化的本质:关注点分离
React 工程化的核心思想是四个字:关注点分离。把代码按照职责边界分层,每一层只关注一件事。
对于 React 项目,最基础的分层是三层:
| 层级 | 职责 | 比喻 |
|---|---|---|
| Component 层 | UI 渲染和用户交互,不包含业务逻辑 | 餐厅服务员:接待、点单、上菜,不做菜 |
| API 层 | 数据请求与处理,封装所有后端交互 | 餐厅后厨:按菜单做菜,交给服务员 |
| Model 层 | 类型定义与数据结构,项目的"数据契约" | 餐厅菜单:定义菜品标准,服务员和后厨都按此交流 |
数据流向是单向的 :Component → API → Model,依赖关系也如此。Component 依赖 API 和 Model,API 依赖 Model,Model 独立于任何组件或 API。

Model 层:给数据立"规矩"
Model 层的价值在于:数据类型有唯一来源。
在颜色选择器项目中,Color 接口在多个地方被使用------ColorBrowser 展示它,ColorPicker 修改它,App 管理它。
typescript
typescript
// model/color.ts
export interface Color {
red: number;
green: number;
blue: number;
}
如果 Color 的定义散落在三个组件中,改一个字段(比如把 red 改成 r)就要改三处,漏一个就崩。有了 Model 层,改一处,所有使用它的地方都会在编译时报错,TypeScript 精确告诉你哪里需要同步修改。
同样,Member 类型在成员列表模块中也扮演同样的角色:
typescript
typescript
// model/member.ts
export interface Member {
id: number;
login: string;
avatar_url: string;
}
Model 与组件 Props 的区别:
| 维度 | 组件 Props Interface | Model Interface |
|---|---|---|
| 作用范围 | 单个组件 | 整个项目 |
| 定义位置 | 组件文件内 | model/ 目录 |
| 变更影响 | 仅影响该组件 | 影响所有使用它的组件和 API |
| 职责 | "这个组件需要什么参数" | "系统中的数据长什么样" |
type 与 interface 的选择
在 TypeScript 中声明类型有两种方式。Props 声明和 Model 声明都推荐用 interface,因为它的语义匹配(组件"满足"接口)且扩展性更好------interface 支持同名声明自动合并,而 type 不支持。type 更适合给基础类型取别名(如 type UserId = string)或定义联合类型(如 type Status = 'pending' | 'success')。
API 层:把"怎么拿数据"和"在哪用数据"分开
在"堆叠式"写法中,fetch 请求直接写在组件的 useEffect 里。API 层把请求逻辑抽离出来,让组件只关心"拿到数据后做什么",而不是"怎么拿数据"。
typescript
typescript
// api/memberApi.ts
import { type Member } from "../model/member";
export const getMenberCollection = (): Promise<Member[]> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1457912, login: "brauliodiez", avatar_url: "https://..." },
{ id: 4374977, login: "Nasdan", avatar_url: "https://..." }
]);
}, 5000);
});
};
注:这里用
setTimeout+ 硬编码数据模拟异步请求,方便开发时独立测试和演示。真实项目中替换为fetch或axios调用,并配置统一拦截器处理 token、错误码等。
API 层的三大价值:
- 统一管理:所有接口集中在一个目录,修改 baseURL、添加认证头、处理错误都在一处
- 类型安全 :
Promise<Member[]>保证返回数据的类型,组件中不需要写as Member[]断言 - 可复用:任何组件需要成员数据时,调用同一个函数即可
Component 层:只关心"长什么样"和"怎么交互"
Component 层的职责是纯粹的展示和交互。在这个颜色选择器项目中,组件被划分为不同的角色:
App 作为容器组件 :负责组合子组件、管理共享状态,将 color 状态和 setColor 通过 props 分发给子组件。
子组件作为展示组件:接收 props,渲染 UI,通过回调触发父组件更新。
这种划分体现了 React 的核心设计原则------单向数据流:数据从父组件流向子组件,修改通过回调从子组件流回父组件,形成闭环。
Component 层详解:三个组件的设计拆解
ColorBrowser:纯展示组件
typescript
javascript
const ColorBrower: 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} />;
};
它只做一件事:接收 Color 数据,渲染一个色块。不修改数据、不发起请求、不关心数据来源------纯展示组件。
React.CSSProperties 是 React 提供的类型定义,用于约束行内样式对象。它能让编辑器提供自动补全和类型检查,避免样式属性拼写错误------这是 TypeScript 在组件层的价值体现。
ColorPicker:受控交互组件
typescript
ini
const ColorPicker: React.FC<Props> = (props) => {
return (
<div>
<input
type="range"
min="0" max="255"
value={props.color.red}
onChange={event => props.onColorUpdated({
...props.color,
red: +event.target.value // + 号将字符串转为数字
})}
/>
{props.color.red}
{/* green 和 blue 同理 */}
</div>
);
};
ColorPicker 是受控组件 ------它的显示完全由父组件传入的 color props 决定,自身变化通过 onColorUpdated 回调通知父组件。这里的三个滑块独立控制 R、G、B 三个通道,每个滑块变化时只更新对应的颜色分量。
+event.target.value 将 event.target.value(字符串类型)隐式转换为数字,满足 Color 接口中 number 类型的约束。...props.color 展开运算符保留了其他两个颜色通道的值,只更新变化的那一个------这是 React 中"不可变更新"的典型写法。
MemberTable:组合 + 数据获取
typescript
javascript
const MemberTable: React.FC = () => {
const [memberCollection, setMemberCollection] = useState<Member[]>([]);
useEffect(() => {
(async () => {
const members = await getMenberCollection();
setMemberCollection(members);
})();
}, []);
return (
<table>
<thead>
<tr><th>Avatar</th><th>Id</th><th>Name</th></tr>
</thead>
<tbody>
{memberCollection.map((member) => (
<MemberRow key={member.id} member={member} />
))}
</tbody>
</table>
);
};
useState<Member[]> 使用 Model 的 Member 类型约束状态;getMenberCollection() 从 API 层获取数据;map 遍历渲染 MemberRow 子组件。
useEffect 中的 (async () => { ... })() 是标准模式------因为 useEffect 本身不能是 async 函数(它必须返回清理函数或 undefined),所以在内部定义并立即执行异步函数是最干净的写法。
key={member.id} 是列表渲染中必须提供的,帮助 React 识别每个列表项的唯一身份,优化 Diff 算法的效率。
MemberRow:单一职责的展示组件
typescript
javascript
const MemberRow: React.FC<Props> = (props) => {
const { member } = props;
return (
<tr>
<td><img src={member.avatar_url} style={{ maxWidth: '10rem' }} /></td>
<td><span>{member.id}</span></td>
<td><span>{member.login}</span></td>
</tr>
);
};
MemberRow 的职责单一到无法再拆分:接收一个 member,渲染一行表格。style={{ maxWidth: '10rem' }} 约束头像宽度,rem 单位相对根元素字体大小,适合响应式设计。
Color 模块:三层协作的完整闭环
以颜色选择器为例,三层架构的协作过程:
text
css
1. App 组件使用 useState<Color> 创建状态(数据源头)
↓
2. App 将 color 状态通过 props 传给 ColorBrowser 和 ColorPicker
↓
3. ColorPicker 中的滑块变化,触发 onColorUpdated 回调
↓
4. 回调中调用 setColor,更新 App 中的 color 状态
↓
5. 状态更新触发重新渲染,新的 color 通过 props 流向两个子组件
↓
6. ColorBrowser 的色块颜色同步更新
三层各司其职:
- Model 层 :
Color接口定义数据形状 - Component 层 :
ColorBrowser展示、ColorPicker交互,通过 props 接收数据和回调 - App 组件(容器组件):连接各个子组件,管理共享状态
Member 模块:加入 API 请求后的三层协作
成员列表模块展示了 API 层加入后的完整数据流:
text
scss
1. MemberTable 组件挂载
↓
2. useEffect 触发,调用 API 层的 getMenberCollection()
↓
3. API 层返回 Promise<Member[]>(5 秒后模拟返回)
↓
4. 数据返回后,setMemberCollection 更新状态
↓
5. 状态更新触发重新渲染,遍历 memberCollection
↓
6. 每个 Member 数据通过 props 传给 MemberRow
↓
7. 成员列表表格完成渲染
这个闭环展示了 Model-API-Component 三层如何协同工作:Model 定义数据契约,API 负责获取数据,Component 负责展示和交互。每一层都有清晰的边界,互不干扰。
进阶分离:让组件更"纯粹"
三层架构已经大大改善了代码组织,但组件中仍可能包含一些副作用逻辑(如 useEffect 请求数据)或纯函数(如格式化、校验)。进一步分离,可以让组件更纯粹。
hooks/:封装有副作用的逻辑
useEffect、useState、useContext 这些 React Hooks 的使用,本质是"副作用"------它们与 React 的运行机制绑定。把它们抽到自定义 Hook 中,组件就变成了纯 UI。
typescript
typescript
// hooks/useMemberCollection.ts
import { useState, useEffect } from 'react';
import { getMenberCollection } from '@/api/memberApi';
import type { Member } from '@/model/member';
export const useMemberCollection = () => {
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const data = await getMenberCollection();
setMembers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
})();
}, []);
return { members, loading, error, refetch: () => { /* 重新请求逻辑 */ } };
};
组件中只需调用 const { members, loading, error } = useMemberCollection();,然后渲染。组件不再关心"数据怎么来的",只关心"数据来了怎么展示"。
utils/:封装纯函数
纯函数是"同样的输入永远有同样的输出",不依赖外部状态,不产生副作用。比如格式化日期、校验邮箱、计算总价等。
typescript
typescript
// utils/format.ts
export const formatDate = (date: Date): string => {
return date.toLocaleDateString('zh-CN');
};
export const formatBytes = (bytes: number): string => {
const i = bytes === 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${['B', 'KB', 'MB', 'GB', 'TB'][i]}`;
};
hooks/ vs utils/ 的分界线:
| 维度 | hooks/ |
utils/ |
|---|---|---|
| 依赖 | React 状态(useState)、副作用(useEffect) | 只依赖传入的参数 |
| 返回值 | 返回状态 + 操作函数 | 返回计算结果 |
| 是否有副作用 | 有(网络请求、本地存储) | 无(纯计算) |
| 典型例子 | useUser(userId) |
formatCurrency(price) |
组件"纯粹度"的三个层级
| 层级 | 特征 | 适用场景 |
|---|---|---|
| Lv1(容器组件) | 包含 useState、useEffect、数据请求、业务逻辑 |
小型项目,快速原型 |
| Lv2(分离组件) | 拆出了 api 和 model,但组件内仍有逻辑 |
中型项目,开始注重维护 |
| Lv3(纯粹组件) | 组件只做三件事:接收 props → 调用 hooks → 渲染 UI | 大型项目,团队协作 |
工程化目录结构:一眼看懂项目全貌
经过三层分离和进阶分离,一个中等规模 React 项目的目录结构是这样的:
text
python
src/
├── components/ # 纯 UI 组件(傻瓜组件)
│ ├── ColorBrowser/
│ │ └── ColorBrowser.tsx
│ ├── ColorPicker/
│ │ └── ColorPicker.tsx
│ └── MemberTable/
│ ├── MemberTable.tsx
│ └── MemberRow.tsx
│
├── hooks/ # 自定义 Hook(副作用逻辑)
│ └── useMemberCollection.ts
│
├── api/ # 数据请求层
│ ├── client.ts # axios/fetch 实例 + 拦截器
│ └── memberApi.ts # 成员相关请求
│
├── model/ # 类型定义(契约层)
│ ├── color.ts
│ └── member.ts
│
├── utils/ # 纯函数工具
│ └── format.ts
│
└── App.tsx # 根组件(容器)
每个目录的职责清晰:model/ 知道数据长什么样,api/ 知道数据从哪来,components/ 知道界面长什么样,hooks/ 知道副作用逻辑在哪,utils/ 知道纯函数工具在哪。目录结构本身就是项目的文档。
互动讨论
💬 三层架构是不是过度设计?
对于只有一两个页面的 Demo,分层确实增加了文件数量。但对于实际项目------尤其是多人协作、长期维护的项目------分层的收益远大于成本。好架构的价值在项目变大时才会体现。
💬 什么时候应该把类型放在 model/,什么时候放在组件文件内?
判断标准是是否跨组件共享 。Color 在多个组件中使用,放在 model/。如果某个类型只在一个组件内部使用,放在组件文件内即可。Model 层的类型是项目的"全局数据规范"。
💬 hooks/ 和 api/ 的区别是什么?
api/ 负责"怎么发起请求"------URL、headers、参数序列化、响应解析,是纯网络层面的封装。hooks/ 负责"什么时候请求、请求结果怎么存"------用 useState 存数据、useEffect 触发请求。两者结合:hooks 调用 api,api 负责网络细节。
💬 type 和 interface 到底怎么选?
Model 定义和 Props 定义推荐 interface,因为语义匹配(组件"满足"接口)且扩展性更好(支持同名声明合并)。type 适合定义联合类型、给基础类型起别名。两者可以混用,但建议在团队中统一风格。
💬 工程化架构在 Vibe Coding 场景中有价值吗?
非常有价值。清晰的目录结构和类型定义就是 AI 的"上下文约束"。当 AI 知道 model/ 里有 Color 接口、api/ 里有 getMenberCollection 函数时,它生成的新代码会更准确、更一致,不会"凭空造"新的数据结构或请求方式。工程化让 AI 生成的代码质量更高,减少了人工修正的工作量。