编写一个颜色选择器之后,我理解了 React 工程化的三层架构

编写一个颜色选择器之后,我理解了 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 层 类型定义与数据结构,项目的"数据契约" 餐厅菜单:定义菜品标准,服务员和后厨都按此交流

数据流向是单向的ComponentAPIModel,依赖关系也如此。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
职责 "这个组件需要什么参数" "系统中的数据长什么样"

typeinterface 的选择

在 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 + 硬编码数据模拟异步请求,方便开发时独立测试和演示。真实项目中替换为 fetchaxios 调用,并配置统一拦截器处理 token、错误码等。

API 层的三大价值:

  1. 统一管理:所有接口集中在一个目录,修改 baseURL、添加认证头、处理错误都在一处
  2. 类型安全Promise<Member[]> 保证返回数据的类型,组件中不需要写 as Member[] 断言
  3. 可复用:任何组件需要成员数据时,调用同一个函数即可

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.valueevent.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/:封装有副作用的逻辑

useEffectuseStateuseContext 这些 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(容器组件) 包含 useStateuseEffect、数据请求、业务逻辑 小型项目,快速原型
Lv2(分离组件) 拆出了 apimodel,但组件内仍有逻辑 中型项目,开始注重维护
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 调用 apiapi 负责网络细节。

💬 typeinterface 到底怎么选?

Model 定义和 Props 定义推荐 interface,因为语义匹配(组件"满足"接口)且扩展性更好(支持同名声明合并)。type 适合定义联合类型、给基础类型起别名。两者可以混用,但建议在团队中统一风格。

💬 工程化架构在 Vibe Coding 场景中有价值吗?

非常有价值。清晰的目录结构和类型定义就是 AI 的"上下文约束"。当 AI 知道 model/ 里有 Color 接口、api/ 里有 getMenberCollection 函数时,它生成的新代码会更准确、更一致,不会"凭空造"新的数据结构或请求方式。工程化让 AI 生成的代码质量更高,减少了人工修正的工作量。

相关推荐
先吃饱再说1 小时前
别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了
react.js·前端框架
先吃饱再说1 小时前
从“传事件”到“只传值”:React + TypeScript 组件 Props 设计的两次进化
react.js·前端框架·前端工程化
Seven_Ting11 小时前
React-Hooks笔记
前端·笔记·react.js
Revolution611 天前
React 组件重新渲染时,到底重新执行了什么
前端·react.js·面试
林焱_RPAAI1 天前
影刀RPA技术深度:CSS选择器高级实战指南——伪类属性选择器与性能对比完全解析
vue.js·react.js
名字还没想好☜1 天前
React 受控输入框光标跳到末尾:格式化输入时的 selection 丢失 bug 与修复
前端·javascript·react.js·bug·react·next.js
10share1 天前
React 新一代样式隔离方案 —— 编译时、零运行时、原生写法
前端·react.js
光影少年1 天前
RN 的EventEmitter 双向通信
前端·react native·react.js
禅思院1 天前
流式 Markdown 渲染完全指南【引子】
前端·架构·前端框架