在 React 页面中写一个颜色选择器并不困难,直接准备三个数字就能表示 RGB;请求一组成员数据也不复杂,在组件中调用异步函数,再把结果渲染成表格即可。
真正需要思考的是:
- 颜色对象会被多个组件使用,数据结构应该定义在哪里?
- API 返回的成员对象,怎样保证接口层和组件层使用的是同一种类型?
- 异步请求为什么不直接写在组件渲染过程中?
useEffect在首次渲染、请求数据和更新页面之间承担什么职责?
本文使用 React + TypeScript 实现 RGB 颜色展示和异步成员列表,重点理解两个知识点:用 model 统一数据结构,以及用 useEffect 处理组件渲染之外的副作用。
为什么要单独建立 Model
前端页面中的数据通常不只被一个函数使用。
RGB 颜色会同时经过:
text
App State
↓
ColorPicker
↓
ColorBrowser
成员数据则会经过:
text
memberApi
↓
MemberTable State
↓
MemberRow
如果每个文件都重新声明一遍数据结构,不仅会产生重复代码,字段发生变化时也容易出现不同步。于是可以在 model 目录中集中定义前端使用的数据模型:
text
src
├── model
│ ├── color.ts
│ └── member.ts
├── api
│ └── memberApi.ts
└── components
├── ColorBrowser.tsx
├── ColorPicker.tsx
└── MemberTable.tsx
Model 不负责渲染界面,也不负责请求接口。它只回答一个问题:这类数据应该具有什么结构。
用 Color 描述 RGB 数据
RGB 颜色由红、绿、蓝三个通道组成,因此在 model/color.ts 中定义 Color:
ts
export interface Color {
red: number
green: number
blue: number
}
这个接口规定,一个合法的 Color 对象必须同时包含三个数字字段。
在 App.tsx 中,颜色 State 直接使用这个模型:
tsx
import { useState } from 'react'
import { type Color } from './model/color'
const [color, setColor] = useState<Color>({
red: 20,
green: 200,
blue: 180,
})
useState<Color> 把 State 与数据模型连接起来。初始值必须符合 Color,以后传给 setColor 的新状态也必须包含 red、green 和 blue。
如果遗漏字段,TypeScript 会直接提示类型不完整:
tsx
// 缺少 blue,不能作为完整的 Color
setColor({
red: 100,
green: 200,
})
这里使用:
ts
import { type Color } from './model/color'
type 表明导入的 Color 只用于类型检查,不是运行时要调用的变量或函数。
同一个 Model 在多个组件间复用
颜色预览组件通过 Props 接收 Color:
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 = {
height: '7rem',
width: '11rem',
backgroundColor: `rgb(${props.color.red},${props.color.green},${props.color.blue})`,
}
return <div style={divStyle} />
}
颜色编辑组件同样使用 Color,同时规定更新函数必须接收新的颜色对象:
tsx
interface Props {
color: Color
onColorUpdated: (color: Color) => void
}
App 把同一份状态分别交给两个组件:
tsx
<ColorBrowser color={color} />
<ColorPicker color={color} onColorUpdated={setColor} />
ColorBrowser 读取颜色并生成背景色,ColorPicker 负责产生新的颜色。两个组件虽然职责不同,但对颜色结构的理解来自同一个 Model。
这就是独立 model 目录的直接价值:数据进入 State、Props 和组件回调时,使用的是同一份类型契约。
Model 约束结构,组件保证取值范围
README 中规定 RGB 的取值范围是 0 到 255。但 Color 中的字段类型只是 number:
ts
red: number
green: number
blue: number
因此,Color 能保证三个通道是数字,却不能单独保证数字一定在 RGB 的有效范围内。
当前页面通过 range 输入框限制用户操作:
tsx
<input
type="range"
min="0"
max="255"
value={props.color.red}
/>
Model 描述数据结构,组件负责当前交互中的输入边界。两者解决的是不同层面的问题。
更新对象 State 时保留完整模型
用户拖动红色滑块时,只需要更新 red,但新的状态仍然必须是完整的 Color:
tsx
onChange={event => props.onColorUpdated({
...props.color,
red: +event.target.value,
})}
...props.color 先复制原来的三个通道,再用新值覆盖 red。这样既保留了 green 和 blue,又创建了一个新的颜色对象。
绿色和蓝色的更新方式相同:
tsx
props.onColorUpdated({
...props.color,
green: +event.target.value,
})
tsx
props.onColorUpdated({
...props.color,
blue: +event.target.value,
})
event.target.value 是字符串,所以代码使用一元加号把它转换为数字,满足 Color 中字段的类型要求。
颜色选择部分说明了 Model 如何约束本地交互状态。接下来再看 Model 如何贯穿异步接口与组件渲染。
用 MemberEntity 统一接口数据
成员数据定义在 model/member.ts:
ts
export interface MemberEntity {
id: number
login: string
avatar_url: string
}
它规定成员对象包含数字类型的 id,以及字符串类型的 login 和 avatar_url。
API 层导入这个模型,并声明异步函数最终返回成员数组:
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)
})
}
返回类型是:
ts
Promise<MemberEntity[]>
它表达了两个信息:
- 数据不是立即返回,而是通过 Promise 异步返回;
- Promise 完成后得到的是
MemberEntity数组。
api 目录负责提供数据,model 目录负责描述数据。这样组件调用接口时,不需要重新猜测返回对象中有哪些字段。
什么是 useEffect 的副作用
React 组件的主要职责是根据 Props 和 State 计算界面。例如 ColorBrowser 根据 color 计算背景色,这属于正常渲染逻辑。
但请求数据、设置定时器、订阅事件等操作,会与组件之外的系统发生交互,不能只通过 JSX 描述结果。这类操作通常被称为副作用。
成员表格需要在组件显示后调用 getMemberCollection()。这个异步调用不是表格结构本身,因此放在 useEffect 中执行:
tsx
React.useEffect(() => {
(async () => {
const members = await getMemberCollection()
setMemberCollection(members)
})()
}, [])
这段代码体现了 useEffect 的基本职责:组件完成一次渲染后,再执行需要与外部数据来源同步的逻辑。
为什么请求不直接写在组件函数中
React 函数组件会在状态更新时重新执行。如果把请求直接写在组件函数主体中:
tsx
getMemberCollection().then((members) => {
setMemberCollection(members)
})
请求返回后会更新 State,State 更新又会触发组件重新执行,组件重新执行时又可能再次请求数据,数据获取与渲染就会相互触发。
useEffect 把副作用从渲染计算中分离出来:
tsx
React.useEffect(() => {
// 在这里请求成员数据
}, [])
当前代码传入空依赖数组 [],表示这段 Effect 不依赖组件中会变化的 Props 或 State,用于在组件挂载后发起本次数据加载。
为什么 Effect 内部使用异步立即执行函数
数据请求需要 await:
tsx
const members = await getMemberCollection()
代码没有直接把 Effect 回调声明为 async,而是在 Effect 内部创建并立即执行一个异步函数:
tsx
React.useEffect(() => {
(async () => {
const members = await getMemberCollection()
setMemberCollection(members)
})()
}, [])
执行过程可以拆成四步:
text
MemberTable 首次渲染
↓
useEffect 执行异步函数
↓
等待 getMemberCollection() 返回成员数组
↓
setMemberCollection(members) 更新 State
这里的异步立即执行函数负责等待 Promise,useEffect 负责决定这项副作用应该在组件渲染后执行。
用数组 State 接收 API 数据
MemberTable 使用 MemberEntity[] 保存成员列表:
tsx
const [MemberCollection, setMemberCollection] =
React.useState<MemberEntity[]>([])
初始值是空数组。组件第一次渲染时,tbody 中没有成员行,但表格本身已经可以先显示出来。
API 返回数据后:
tsx
setMemberCollection(members)
成员数组进入 State,React 根据新状态重新执行组件,随后通过 map 渲染表格行:
tsx
<tbody>
{MemberCollection.map((member: MemberEntity) => (
<MemberRow key={member.id} member={member} />
))}
</tbody>
MemberRow 的 Props 同样由 Model 约束:
tsx
const MemberRow = (props: { member: MemberEntity }) => {
const { member } = props
return (
<tr>
<td>
<img
src={member.avatar_url}
style={{ maxWidth: '10rem' }}
/>
</td>
<td>{member.id}</td>
<td>{member.login}</td>
</tr>
)
}
从 API 返回值到 State,再到子组件 Props,整个过程始终使用 MemberEntity。这使异步数据流中的每一层都遵守同一份结构约定。
Model 与 useEffect 怎样配合
Model 和 useEffect 解决的不是同一个问题,但它们会在异步数据处理中相遇。
Model 负责回答:
text
数据长什么样?
useEffect 负责回答:
text
什么时候获取数据并同步到组件?
成员列表的完整流程如下:
text
MemberEntity 定义成员结构
↓
memberApi 声明 Promise<MemberEntity[]>
↓
useEffect 在组件挂载后调用 API
↓
useState<MemberEntity[]> 保存结果
↓
MemberRow 按 MemberEntity 渲染数据
如果只有 Model,没有 useEffect,组件虽然知道数据结构,却没有合适的时机加载异步数据。
如果只有 useEffect,没有 Model,接口、State 和子组件之间的数据形状就缺少统一约束。
两者结合后,异步数据既有明确的加载过程,也有贯穿各层的类型契约。
总结
在 React + TypeScript 中,model 不只是存放接口声明的目录。它负责为跨组件、跨模块的数据建立统一结构。
颜色数据通过 Color 串联了:
text
useState<Color>
→ ColorPicker Props
→ onColorUpdated(Color)
→ ColorBrowser Props
成员数据通过 MemberEntity 串联了:
text
Promise<MemberEntity[]>
→ useEffect 异步请求
→ useState<MemberEntity[]>
→ MemberRow Props
而 useEffect 的重点也不只是"组件挂载后执行一次"。它用于把数据请求等副作用从渲染逻辑中分离出来,在合适的时机获取外部数据,再通过 State 驱动页面更新。
理解 Model 与 useEffect 的配合后,再面对真实接口和更复杂的组件树时,就能更清楚地判断:数据结构定义在哪里、异步操作何时执行,以及返回的数据怎样安全地进入页面。