前言
最近在带团队做前端项目时,发现很多初级前端开发者有一个通病:拿到需求就写组件,写完组件就堆状态,堆完状态就调接口 。项目跑到后期,代码里到处散落着 any 类型、重复的接口定义、以及谁也看不懂的数据流。
这篇文章以一个看似简单的 RGB 拾色器(Color Picker) 项目为例,聊聊我在实际项目中沉淀下来的一套轻量级前端架构思路------model/api 分层 + TypeScript 类型约束。
项目源码:GitHub | 技术栈:React 19 + TypeScript 6 + Vite 8
一、先看成品
我们的 Color Picker 长这样:
三个滑块分别控制 RGB 三个通道(0-255),上方色块实时预览当前颜色,下方还有一个模拟异步请求的成员列表。
功能虽简单,但代码结构一点都不"简单"------它是一套可复用到大型项目的工程模板。
二、为什么选 TypeScript?
如果你写过 10 个以上 React 组件的中型项目,大概率遇到过这种场景:
jsx
// 😱 没有类型约束的噩梦
function ColorBrowser({ color }) {
// color 是什么结构?red 是 number 还是 string?
// 传错了怎么办?
return <div style={{ backgroundColor: `rgb(${color.red}, ...)` }} />
}
TypeScript 解决的核心问题不是"语法检查",而是"团队协作中的认知对齐"。
当项目有 5 个以上的开发者、100 个以上的组件时,类型系统就是活文档------你不需要去翻 README、不需要问同事,IDE 会直接告诉你这个 props 长什么样。
三、model 层:前端数据模型的"单一真理来源"
3.1 什么是 model?
在传统前端架构中,我们习惯把"数据模型"放在后端。但现代前端应用越来越复杂,前端也需要自己的领域模型。
在我的架构中,model/ 目录专门放 TypeScript 接口定义------它就是前端所有数据结构的"宪法"。
bash
src/
├── model/
│ ├── color.ts # 颜色数据模型
│ └── member.ts # 成员数据模型
├── api/
│ └── memberApi.ts # 接口层
├── components/
│ ├── ColorPicker.tsx
│ ├── ColorBrowser.tsx
│ └── MemberTable.tsx
└── App.tsx
3.2 定义 Color 模型
typescript
// src/model/color.ts
// 数据接口------项目中被多处引用
// model 是项目架构的核心目录之一
export interface Color {
red: number;
green: number;
blue: number;
}
就这么简单。 但它的价值在于:
- 一处定义,全局引用 ------5 个组件、10 个组件都在用同一个
Color类型 - 改类型即改全局 ------如果将来 RGB 扩展为 RGBA,只需在这一个地方加
alpha?: number - IDE 自动补全和类型检查------拼错字段名?编译不过
3.3 定义 Member 模型
typescript
// src/model/member.ts
export interface MemberEntity {
id: number;
login: string;
avatar_url: string;
}
同样简洁,同样重要。这个接口既约束了 API 返回的数据格式,也约束了组件渲染需要的数据格式。
核心认知 :model 不是"后端接口返回什么我就定义什么",而是"前端需要什么数据,我就定义什么模型"。后端接口的字段名和前端模型可以不同------那是 api 层要做的事情(本文暂不展开)。
四、api 层:统一接口管理,告别散落的 fetch
4.1 为什么需要 api 层?
有多少项目是这么写接口的:
tsx
// ❌ 散落在组件里的接口调用
function MemberTable() {
useEffect(() => {
fetch('/api/members')
.then(res => res.json())
.then(data => setMembers(data)) // data 是什么类型?不知道
}, [])
}
问题很明显:
- 接口地址散落在各个组件里,改一个 URL 要全局搜索
- 返回值没有类型约束,
data是any - 没法统一做错误处理、请求拦截、缓存策略
4.2 api 层的实践
typescript
// src/api/memberApi.ts
// 接口文件------前端需要的接口都在这里模块化定义
// 统一接口声明,好管理(application interface)
import { type MemberEntity } from "../model/member";
export const getMembersCollection = (): 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[]>------调用方不用猜 - 模块化:一个 api 文件对应一个业务域
- 可以随时切换实现 :现在是 mock 数据,将来换成
fetch()调用,组件一行不改
五、组件层:类型安全的 Props 设计
5.1 ColorPicker------受控组件的正确打开方式
tsx
// src/components/ColorPicker.tsx
import { type Color } from '../model/color';
interface Props {
color: Color;
onColorUpdated: (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.onColorUpdated({
...props.color, // 展开旧状态
red: +event.target.value, // 只更新 red 通道
})}
/>
{props.color.red}
{/* green 和 blue 同理 */}
</div>
)
}
设计要点:
- 受控组件模式------状态在父组件,ColorPicker 只负责展示和通知
- 不可变更新 ------用展开运算符
...props.color拷贝旧值,再覆盖修改的字段 +event.target.value------+号将字符串转数字,简洁且类型安全
5.2 ColorBrowser------纯展示组件
tsx
// src/components/ColorBrowser.tsx
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} />
}
这里有个容易被忽略的细节 :divStyle 的类型是 React.CSSProperties。这不是多此一举------当你拼错 backgroundColor 为 backgroudColor 时,TypeScript 会直接报错。
5.3 MemberTable------异步数据加载的最佳实践
tsx
const MemberTable: React.FC = () => {
const [memberCollection, setMemberCollection] = React.useState<MemberEntity[]>([])
React.useEffect(() => {
// 挂载后请求接口,不会阻塞首屏渲染
(async () => {
const members = await getMembersCollection();
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 中的 IIFE ------
(async () => { ... })()是处理异步 Effect 的经典模式(因为 useEffect 的回调不能直接是 async 函数) - 空依赖数组
[]------只在组件挂载时执行一次 - 初始值是空数组
[]------渲染前不会报map of undefined
六、App.tsx------状态的"中央厨房"
tsx
function App() {
// TypeScript 适合大型项目:代码量大、团队成员多
const [color, setColor] = useState<Color>({
red: 20,
green: 240,
blue: 180
});
return (
<>
<ColorBrowser color={color} />
<ColorPicker color={color} onColorUpdated={setColor} />
<MemberTable />
</>
)
}
架构意图:
- 状态提升 ------
color状态在 App 层,ColorBrowser 和 ColorPicker 通过 props 共享 - 单向数据流 ------ColorPicker 不直接修改 color,而是通过
onColorUpdated回调通知父组件 setColor直接作为回调传递 ------因为签名完全匹配(color: Color) => void
到这里,整个应用的数据流就非常清晰了:
scss
App (状态持有者)
├── ColorBrowser ← color (只读)
├── ColorPicker ← color + onColorUpdated (读写)
└── MemberTable ← 自己管理自己的状态
七、这套架构的核心价值
回顾一下,我们这个"小"项目做到了哪些"大"项目才需要的东西:
| 能力 | 实现方式 | 收益 |
|---|---|---|
| 类型安全 | TypeScript + model 层 | 编译时发现 90% 的字段拼写和类型错误 |
| 接口管理 | api 层统一封装 | 改接口不影响组件,方便 mock/真实切换 |
| 数据流清晰 | 受控组件 + 状态提升 | 数据流向一目了然,debug 不头疼 |
| 可维护性 | model/api/components 三层分离 | 新人接手只看目录结构就知道代码在哪 |
八、总结:好的架构不是"大项目的专利"
很多开发者觉得:
"我就写个小 demo,不需要 TypeScript 吧?" "才 3 个组件,分层干嘛?"
但我的观点是:好的架构习惯,恰恰要在小项目中刻意练习。
就像这个 Color Picker------功能可能你 30 分钟就能写完,但用上 model/api 分层 + TypeScript 后,它变成了一个可直接复用的项目模板。下次你接到一个新需求,直接在这个骨架上填代码就行:
- 在
model/里定义新的数据模型 - 在
api/里封装接口方法 - 在
components/里写组件 - 在
App.tsx里组装
架构不是约束,是脚手架------它让你不用每次都从零思考"代码放哪"。
参考
如果这篇文章对你有帮助,欢迎点赞、收藏、评论 🎉 关于前端架构设计的更多实践,我会持续更新。