上周摸鱼写个小 demo,要做个 RGB 颜色选择器,再加个成员信息列表。上来啥也没想,组件咔咔一顿写,写着写着就不对劲了 ------ 同一个 Color 类型,我在 App 里写了一遍,ColorPicker 里又写了一遍,改个字段名改了三处还漏了一处,运行半天颜色显示不对。折腾了一下午,从堆代码到慢慢拆分层,才算把这事捋明白。
先从那个堆出来的颜色选择器说起
最开始想法很简单:三个滑动条,分别控制红、绿、蓝三个通道,取值 0 到 255,左边放个方块实时显示颜色。这不就是最基础的受控组件嘛,我闭着眼都能写。
第一版:所有东西都塞在一个文件里
第一版我直接把所有逻辑都堆在 App.tsx 里,类型也当场定义,组件也当场写。大概长这样:
tsx
import { useState } from "react";
// 颜色类型,随手就写在这了
interface Color {
red: number;
green: number;
blue: number;
}
function App() {
const [color, setColor] = useState<Color>({
red: 20,
green: 40,
blue: 180,
});
return (
<div>
{/* 色块直接写在这 */}
<div style={{
width: '11rem',
height: '7rem',
backgroundColor: `rgb(${color.red}, ${color.green}, ${color.blue})`
}}></div>
{/* 三个滑块也写在这 */}
<input type="range" min="0" max="255" value={color.red}
onChange={e => setColor({...color, red: +e.target.value})}
/>
{/* 剩下两个滑块同理,就不重复贴了 */}
</div>
)
}
写完跑起来一看,没问题,滑块拖一拖,颜色跟着变。我当时还想,这有啥难的,还分层?纯纯脱裤子放屁。
第一个麻烦:类型到处复制,改一个漏三个
后来我想把代码拆得像样点,把色块拆成 ColorBrowser 组件,滑块拆成 ColorPicker 组件。拆完我就傻了:两个组件都要用到 Color 类型,怎么办?
我当时的第一反应是,复制粘贴呗。ColorBrowser 里写一份 interface Color,ColorPicker 里再写一份,App 里再留一份。反正也就三行代码的事。
结果当天晚上我想加个透明度通道,把 RGB 改成 RGBA。改完 App 里的类型,去改 ColorBrowser,改完 ColorBrowser 转头就忘了 ColorPicker 里还有一份。运行起来直接报错,我对着控制台看了十分钟,才反应过来是类型没改全。
说实话那一刻我突然就懂了,为什么大项目里没人把类型散着写。你写的时候是省事,改的时候就是灾难。
把类型收进 model 层,世界终于清净了
想起之前看别人的 React+TS 项目,根目录下总有个 model 文件夹,以前不知道是干嘛的,那天突然就通了。我照着样子建了个 model 目录,把所有数据类型都扔了进去。
model 层到底是干嘛的?我给你打个比方
很多教程会说 "model 是数据模型层",听着挺玄乎。其实说白了特别好理解:model 就是你项目里的 "数据字典"。所有你会用到的数据长什么样、有哪些字段、是什么类型,全在这统一登记。
就像奶茶店的配料表,珍珠多大颗、糖度分几档、茶底有哪几种,全写在一张表里贴在后厨。每个做奶茶的员工都看这张表,不用自己瞎定义,就不会出现你做的三分糖比别人五分糖还甜的情况。
放到我们这个小项目里,颜色对象有 red、green、blue 三个数字字段,成员对象有 id、login、avatar_url 三个字段,这些都是 "数据规格",都应该放进 model 里统一管理。
抽完之后的代码长啥样
我建了两个文件,分别放颜色和成员的类型定义。
先是 model/color.ts:
typescript
// 颜色数据结构,全项目共用
// 以后要加alpha通道,改这一处就行
export interface Color {
red: number;
green: number;
blue: number;
}
然后是 model/member.ts:
typescript
// 成员信息数据结构
// 接口返回、组件props里用的member,全遵循这个定义
export interface Member {
id: number;
login: string;
avatar_url: string;
}
就这么简单?就这么简单。但效果立竿见影。
拆完之后,ColorBrowser 组件里不用自己写类型了,直接从 model 引入:
tsx
import * as React from 'react';
import type { Color } from '../model/color';
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}></div>
}
export default ColorBrowser;
ColorPicker 也是一样,props 里的 color 和回调函数的参数,全用统一的 Color 类型。
tsx
import * as React from 'react';
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, // 注意这行,坑了我半小时
})}
></input>
{props.color.red}
<br />
{/* 绿、蓝通道同理 */}
</div>
)
}
export default ColorPicker;
小提醒如果项目大、业务模块多,可以在 model 里再按模块拆分子文件夹,比如 model/user、model/order,避免一个文件堆几十上百个接口,找起来费劲。
这时候再改需求就舒服了:想加 alpha 通道?打开 model/color.ts 加一行 alpha: number,剩下的地方 TS 会自动给你提示哪里缺了字段,根本不用担心漏改。
跑起来的效果就是这样,左边色块,右边三个滑块,数值分别对应 RGB 三个通道:

成员列表又给我上了一课:接口别散在组件里
颜色选择器搞定了,接着写成员列表。本来以为更简单,不就是发个请求拿数据,渲染成表格嘛。结果写着写着,又觉得别扭了。
一开始我把请求直接写在 useEffect 里
最开始我图省事,直接在 MemberTable 组件的 useEffect 里写请求,连模拟数据都塞在组件里面:
tsx
// 反面教材:接口逻辑直接写在组件里
const MemberTable: React.FC = () => {
const [memberCollection, setMemberCollection] = React.useState<Member[]>([]);
React.useEffect(() => {
// 直接在组件里模拟接口请求
setTimeout(() => {
setMemberCollection([
{
id: 1457912,
login: "brauliodiez",
avatar_url: "https://xxx/avatar.webp"
},
// 第二条数据...
])
}, 500)
}, [])
return <table>{/* 渲染表格 */}</table>
}
写完我就皱眉头了。这写法问题太大了:
- 要是别的组件也要用这份成员数据,我就得把这段 setTimeout 再复制一遍;
- 以后要接真实后端接口,我得钻进组件里去改逻辑;
- 要是接口要加统一的错误处理、loading 状态,每个组件都得写一遍。
这不就是和之前散着写类型一样的毛病吗?类型可以抽成 model,那接口请求是不是也能抽一层?
抽成 api 层之后,改接口再也不用翻组件了
答案当然是可以。这就是很多项目里都会有的 api 层 ------所有和后端打交道的方法,都统一放在 api 目录下管理。组件只负责调用方法拿数据,不用关心数据是模拟的、还是从真实接口来的,也不用管请求地址、超时时间这些细节。
我建了个 api/memberApi.ts,把成员相关的接口都放进去:
typescript
import { type Member } from '../model/member';
// 获取成员列表的接口方法
// 组件只需要调用这个方法,不用管内部实现
export const getMemberCollection = (): Promise<Member[]> => {
return new Promise((resolve, reject) => {
// 现在是用setTimeout模拟数据
// 以后接真实接口,直接换成axios.get就行
setTimeout(() => {
resolve([
{
id: 1457912,
login: "brauliodiez",
avatar_url: "https://p6-passport.byteacctimg.com/img/user-avatar/1980675dbdbf6a122febac01a4c5ab4f~70x70.awebp"
},
{
id: 4374977,
login: "Nasdan",
avatar_url: "https://p6-passport.byteacctimg.com/img/user-avatar/1980675dbdbf6a122febac01a4c5ab4f~70x70.awebp"
}
])
}, 500)
})
}
你看,方法返回的是 Promise<Member \[\]>,直接复用了 model 里定义的 Member 类型,连返回值类型都不用自己再写一遍。这就是分层配合的好处。
然后组件里就干净了,只管调用:
tsx
import * as React from 'react';
import { type Member } from '../model/member';
import { getMemberCollection } from '../api/memberApi';
const MemberTable: React.FC = () => {
const [memberCollection, setMemberCollection] = React.useState<Member[]>([]);
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) => (
<MemberRow key={member.id} member={member}></MemberRow>
))
}
</tbody>
</table>
)
}
组件里再也没有乱七八糟的请求逻辑了,只负责 "拿到数据→渲染页面" 这一件事。以后接口地址变了、返回格式改了、要加请求拦截,都去 api 层处理,组件代码纹丝不动。
踩过的几个坑,你们别再踩了
整个过程踩了好几个不大不小的坑,说出来给大家避避雷,都是我实打实踩过的。
坑 1:滑块的 value 是字符串,直接塞给 number 类型会炸
就是 ColorPicker 里那行 red: + event.target.value,前面的加号我一开始忘了写。
当时我想当然觉得,input 的 min 和 max 都是数字,value 肯定也是数字啊。结果一写,TS 直接飘红,说 string 不能赋值给 number。我还愣了一下,去打印了一下 typeof event.target.value,果然是 string。
注意所有表单元素的 value,哪怕你设置的是数字范围,取出来的也都是字符串类型。用在 number 类型的字段上,一定要记得转类型,要么用 + 号,要么用 Number ()。
别问我为什么印象这么深,一开始我还吐槽 TS 事儿多,后来想想要是没 TS,运行的时候颜色显示不对,我指不定得对着 DOM 找多久 bug。
坑 2:子组件 props 不加类型,TS 等于白用
写 MemberRow 子组件的时候,我图省事直接写成了:
tsx
// 反面教材:props不写类型
const MemberRow = (props) => {
const { member } = props;
return (
<tr>
<td><img src={member.avatar_url} style={{maxWidth:'70px'}}></img></td>
<td><span>{member.id}</span></td>
<td><span>{member.name}</span></td> {/* 这里写错了! */}
</tr>
)
}
我把字段写成了 member.name,但实际接口返回的是 login。因为 props 没加类型,TS 啥也没提示,运行起来 Name 列全是空的。我对着接口数据看了半天,才发现字段名写错了。
后来老老实实加上类型:
tsx
interface MemberRowProps {
member: Member;
}
const MemberRow = (props: MemberRowProps) => {
// ...
}
刚敲完 member.name,TS 直接就标红了,说 Member 类型上不存在 name 属性。你看,这就是 TS 的价值 ------ 不是为了让你多写几行代码,是帮你在写代码的时候就把低级错误掐死。
改完之后跑起来,表格就正常了:

坑 3:想当然去解析图片内容,闹了个乌龙
说个特别丢人的事。最开始拿到头像的 url,我脑子不知道哪根弦搭错了,想着要不要先把图片内容拉下来解析一下再渲染,结果直接报了个错:
文件内容是纯图片,目前无法解析出文本信息
我盯着报错愣了半分钟,才拍了下脑袋 ------img 标签直接 src 不就完了,我搁这解析啥呢?
说白了就是想复杂了。前端渲染图片,老老实实交给 img 标签就行,别总想着自己去处理文件内容。原生标签能搞定的事,就别瞎折腾。
聊点实在的:分层到底值不值?
折腾完这一圈,我估计很多人会问:就这么个小 demo,还要建 model 和 api 两个文件夹,是不是过度设计了?
说实话,我一开始也是这么想的。
什么时候该拆,什么时候别瞎拆
分层这东西,从来不是 "有就比没有好"。
- 如果你的项目就是个单页面小工具,两三个组件,写完就扔,那完全没必要分层,所有东西写一块反而快。
- 但如果是团队协作的长期项目,组件会越来越多,数据结构会反复复用,接口会经常调整,那分层就非常有必要。
我自己的判断标准很简单:当你开始复制粘贴同一段类型定义、同一段接口代码的时候,就该考虑抽一层了。
别上来就套架构,把简单的事情搞复杂。也别死活不拆,最后代码乱成一锅粥,改都没法改。架构是用来解决问题的,不是用来炫技的。
我自己的三点小收获
这次写 demo 看着简单,折腾完还是挺有感触的。
第一,TS 的威力要靠统一的类型定义才能发挥出来。类型散在各个组件里,你只会觉得 TS 又麻烦又啰嗦;把类型收到 model 层统一管理,类型提示、错误校验、自动补全才真正能帮你提效。
第二,api 层本质上是把 "变化" 隔离起来。业务需求会变,后端接口会变,网络请求方式会变,但组件的核心逻辑 ------ 拿数据、渲染页面 ------ 是稳定的。把容易变的部分抽出去,稳定的部分就不容易被影响。
第三,最好的架构都是慢慢长出来的。不用一开始就设计得完美无缺,先写起来,觉得哪里别扭了、重复了,再去拆、去重构。写代码和收拾屋子一样,不是一次收拾完就一劳永逸,是边用边整理,慢慢就顺了。
最后说两句
其实最开始我看别人的 React+TS 项目,一堆文件夹,model、api、components、utils,头都大。总觉得这些都是 "大项目才用的东西",小 demo 用不着。真自己动手写一遍,踩一遍复制粘贴的坑,才明白每一层存在的意义。
很多技术点都是这样,看别人讲十遍原理,不如自己亲手踩一遍坑印象深。
要是你也刚接触 TS 项目的分层,不妨自己写个小 demo 试试,从堆代码开始,拆成 model 和 api 两层,感受一下变化。搞懂了或者有别的想法,记得回来留个言,我也想看看你们平时是怎么组织项目结构的。