React + TypeScript 中的 Model 与 useEffect:从数据建模到异步列表渲染

0 阅读8分钟

在 React 页面中写一个颜色选择器并不困难,直接准备三个数字就能表示 RGB;请求一组成员数据也不复杂,在组件中调用异步函数,再把结果渲染成表格即可。

真正需要思考的是:

  • 颜色对象会被多个组件使用,数据结构应该定义在哪里?
  • API 返回的成员对象,怎样保证接口层和组件层使用的是同一种类型?
  • 异步请求为什么不直接写在组件渲染过程中?
  • useEffect 在首次渲染、请求数据和更新页面之间承担什么职责?

本文使用 React + TypeScript 实现 RGB 颜色展示和异步成员列表,重点理解两个知识点:用 model 统一数据结构,以及用 useEffect 处理组件渲染之外的副作用。

为什么要单独建立 Model

前端页面中的数据通常不只被一个函数使用。

RGB 颜色会同时经过:

App State
   ↓
ColorPicker
   ↓
ColorBrowser

成员数据则会经过:

memberApi
   ↓
MemberTable State
   ↓
MemberRow

如果每个文件都重新声明一遍数据结构,不仅会产生重复代码,字段发生变化时也容易出现不同步。于是可以在 model 目录中集中定义前端使用的数据模型:

src
├── model
│   ├── color.ts
│   └── member.ts
├── api
│   └── memberApi.ts
└── components
    ├── ColorBrowser.tsx
    ├── ColorPicker.tsx
    └── MemberTable.tsx

Model 不负责渲染界面,也不负责请求接口。它只回答一个问题:这类数据应该具有什么结构。

用 Color 描述 RGB 数据

RGB 颜色由红、绿、蓝三个通道组成,因此在 model/color.ts 中定义 Color

export interface Color {
  red: number
  green: number
  blue: number
}

这个接口规定,一个合法的 Color 对象必须同时包含三个数字字段。

App.tsx 中,颜色 State 直接使用这个模型:

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 的新状态也必须包含 redgreenblue

如果遗漏字段,TypeScript 会直接提示类型不完整:

// 缺少 blue,不能作为完整的 Color
setColor({
  red: 100,
  green: 200,
})

这里使用:

import { type Color } from './model/color'

type 表明导入的 Color 只用于类型检查,不是运行时要调用的变量或函数。

同一个 Model 在多个组件间复用

颜色预览组件通过 Props 接收 Color

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,同时规定更新函数必须接收新的颜色对象:

interface Props {
  color: Color
  onColorUpdated: (color: Color) => void
}

App 把同一份状态分别交给两个组件:

<ColorBrowser color={color} />
<ColorPicker color={color} onColorUpdated={setColor} />

ColorBrowser 读取颜色并生成背景色,ColorPicker 负责产生新的颜色。两个组件虽然职责不同,但对颜色结构的理解来自同一个 Model。

这就是独立 model 目录的直接价值:数据进入 State、Props 和组件回调时,使用的是同一份类型契约。

Model 约束结构,组件保证取值范围

README 中规定 RGB 的取值范围是 0255。但 Color 中的字段类型只是 number

red: number
green: number
blue: number

因此,Color 能保证三个通道是数字,却不能单独保证数字一定在 RGB 的有效范围内。

当前页面通过 range 输入框限制用户操作:

<input
  type="range"
  min="0"
  max="255"
  value={props.color.red}
/>

Model 描述数据结构,组件负责当前交互中的输入边界。两者解决的是不同层面的问题。

更新对象 State 时保留完整模型

用户拖动红色滑块时,只需要更新 red,但新的状态仍然必须是完整的 Color

onChange={event => props.onColorUpdated({
  ...props.color,
  red: +event.target.value,
})}

...props.color 先复制原来的三个通道,再用新值覆盖 red。这样既保留了 greenblue,又创建了一个新的颜色对象。

绿色和蓝色的更新方式相同:

props.onColorUpdated({
  ...props.color,
  green: +event.target.value,
})
props.onColorUpdated({
  ...props.color,
  blue: +event.target.value,
})

event.target.value 是字符串,所以代码使用一元加号把它转换为数字,满足 Color 中字段的类型要求。

颜色选择部分说明了 Model 如何约束本地交互状态。接下来再看 Model 如何贯穿异步接口与组件渲染。

用 MemberEntity 统一接口数据

成员数据定义在 model/member.ts

export interface MemberEntity {
  id: number
  login: string
  avatar_url: string
}

它规定成员对象包含数字类型的 id,以及字符串类型的 loginavatar_url

API 层导入这个模型,并声明异步函数最终返回成员数组:

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)
  })
}

返回类型是:

Promise<MemberEntity[]>

它表达了两个信息:

  1. 数据不是立即返回,而是通过 Promise 异步返回;
  2. Promise 完成后得到的是 MemberEntity 数组。

api 目录负责提供数据,model 目录负责描述数据。这样组件调用接口时,不需要重新猜测返回对象中有哪些字段。

什么是 useEffect 的副作用

React 组件的主要职责是根据 Props 和 State 计算界面。例如 ColorBrowser 根据 color 计算背景色,这属于正常渲染逻辑。

但请求数据、设置定时器、订阅事件等操作,会与组件之外的系统发生交互,不能只通过 JSX 描述结果。这类操作通常被称为副作用。

成员表格需要在组件显示后调用 getMemberCollection()。这个异步调用不是表格结构本身,因此放在 useEffect 中执行:

React.useEffect(() => {
  (async () => {
    const members = await getMemberCollection()
    setMemberCollection(members)
  })()
}, [])

这段代码体现了 useEffect 的基本职责:组件完成一次渲染后,再执行需要与外部数据来源同步的逻辑。

为什么请求不直接写在组件函数中

React 函数组件会在状态更新时重新执行。如果把请求直接写在组件函数主体中:

getMemberCollection().then((members) => {
  setMemberCollection(members)
})

请求返回后会更新 State,State 更新又会触发组件重新执行,组件重新执行时又可能再次请求数据,数据获取与渲染就会相互触发。

useEffect 把副作用从渲染计算中分离出来:

React.useEffect(() => {
  // 在这里请求成员数据
}, [])

当前代码传入空依赖数组 [],表示这段 Effect 不依赖组件中会变化的 Props 或 State,用于在组件挂载后发起本次数据加载。

为什么 Effect 内部使用异步立即执行函数

数据请求需要 await

const members = await getMemberCollection()

代码没有直接把 Effect 回调声明为 async,而是在 Effect 内部创建并立即执行一个异步函数:

React.useEffect(() => {
  (async () => {
    const members = await getMemberCollection()
    setMemberCollection(members)
  })()
}, [])

执行过程可以拆成四步:

MemberTable 首次渲染
        ↓
useEffect 执行异步函数
        ↓
等待 getMemberCollection() 返回成员数组
        ↓
setMemberCollection(members) 更新 State

这里的异步立即执行函数负责等待 Promise,useEffect 负责决定这项副作用应该在组件渲染后执行。

用数组 State 接收 API 数据

MemberTable 使用 MemberEntity[] 保存成员列表:

const [MemberCollection, setMemberCollection] =
  React.useState<MemberEntity[]>([])

初始值是空数组。组件第一次渲染时,tbody 中没有成员行,但表格本身已经可以先显示出来。

API 返回数据后:

setMemberCollection(members)

成员数组进入 State,React 根据新状态重新执行组件,随后通过 map 渲染表格行:

<tbody>
  {MemberCollection.map((member: MemberEntity) => (
    <MemberRow key={member.id} member={member} />
  ))}
</tbody>

MemberRow 的 Props 同样由 Model 约束:

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 负责回答:

数据长什么样?

useEffect 负责回答:

什么时候获取数据并同步到组件?

成员列表的完整流程如下:

MemberEntity 定义成员结构
          ↓
memberApi 声明 Promise<MemberEntity[]>
          ↓
useEffect 在组件挂载后调用 API
          ↓
useState<MemberEntity[]> 保存结果
          ↓
MemberRow 按 MemberEntity 渲染数据

如果只有 Model,没有 useEffect,组件虽然知道数据结构,却没有合适的时机加载异步数据。

如果只有 useEffect,没有 Model,接口、State 和子组件之间的数据形状就缺少统一约束。

两者结合后,异步数据既有明确的加载过程,也有贯穿各层的类型契约。

总结

在 React + TypeScript 中,model 不只是存放接口声明的目录。它负责为跨组件、跨模块的数据建立统一结构。

颜色数据通过 Color 串联了:

useState<Color>
→ ColorPicker Props
→ onColorUpdated(Color)
→ ColorBrowser Props

成员数据通过 MemberEntity 串联了:

Promise<MemberEntity[]>
→ useEffect 异步请求
→ useState<MemberEntity[]>
→ MemberRow Props

useEffect 的重点也不只是“组件挂载后执行一次”。它用于把数据请求等副作用从渲染逻辑中分离出来,在合适的时机获取外部数据,再通过 State 驱动页面更新。

理解 Model 与 useEffect 的配合后,再面对真实接口和更复杂的组件树时,就能更清楚地判断:数据结构定义在哪里、异步操作何时执行,以及返回的数据怎样安全地进入页面。