从“能用”到“好维护”:React + TypeScript 实战指南

带你一步步把 JavaScript React 项目升级为 TypeScript,同时捋清组件、状态、请求和类型那些事儿


一、为什么我们需要 TypeScript?

如果你写过 JavaScript React 项目,大概率经历过这样的场景:

  • 打开一个组件,想看看 props 里到底传了什么东西,得一路往上翻到父组件;
  • 改了一个接口字段,结果页面直接报 undefined is not a function,然后全局搜索挨个改;
  • 明明传的是数组,结果子组件当对象用了,控制台一片红。

这些问题本质上都是 类型不确定 带来的。

TypeScript 并不能阻止程序出错,但它能在 代码运行之前 就告诉你哪里可能有问题。相当于在你写代码的时候,旁边坐了一个细心的同事,随时提醒你:"这个变量可能是 undefined 哦""这个函数参数类型不对"。

另外,TypeScript 的类型注解本身就是一种 文档 。当你看到一个函数 (color: Color) => void,立刻就知道它接收一个 Color 类型的参数,不返回任何值。不用看实现,不用猜。

我们这次就来把一个小型的 React 项目从 JavaScript 迁移到 TypeScript,同时在这个过程中,把组件设计、状态管理、数据请求和类型定义全部捋清楚。


二、从一个颜色选择器开始

2.1 先看最简单的"展示"组件

在项目里有一个 ColorBrowser 组件,它的作用很简单:根据传入的颜色值,渲染一个带背景色的方块。

tsx

typescript 复制代码
// ColorBrowser.tsx
import * as React from 'react';

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} />;
};

export default ColorBrowser;

这里有几个知识点值得单独拎出来说。

第一个是 interface Props

interface 是 TypeScript 里定义对象结构的方式。这里我们声明 Props 必须包含一个 color 字段,而这个 color 的类型是 Color

第二个是 React.FC<Props>

FCFunctionComponent 的缩写,表示这个变量是一个函数组件。<Props> 表示这个组件接收的 props 类型是刚才定义的 Props。这样写之后,如果你在父组件里传了一个没有 color 的 props,TypeScript 会直接报错。

第三个是 React.CSSProperties

这是 TypeScript 内置的类型,用来描述 CSS 样式对象。你可能会问:"样式对象不就是普通对象吗,为什么要加类型?"

因为加上之后,你在写 width: '11rem' 的时候,如果写错了属性名(比如写成 wdith),TypeScript 会提醒你。如果写错了属性值类型(比如写成 width: 11rem 不加引号),也会报错。对初学者来说,这种即时反馈能省去很多查 bug 的时间。

其实原本的代码里,ColorBrowserdivStyle 缺少了 backgroundColor,我们在迁移过程中顺手补上。这也是类型检查的好处之一------它能让你更容易发现逻辑上的遗漏。


2.2 让用户"调色"的交互组件

接着看 ColorPicker 组件。它提供了三个滑块,分别控制红、绿、蓝三个通道的值。

tsx

ini 复制代码
// ColorPicker.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,
          })
        }
      />
      {props.color.red}
      <br />
      <input
        type="range"
        min="0"
        max="255"
        value={props.color.green}
        onChange={(event) =>
          props.onColorUpdated({
            ...props.color,
            green: +event.target.value,
          })
        }
      />
      {props.color.green}
      <br />
      <input
        type="range"
        min="0"
        max="255"
        value={props.color.blue}
        onChange={(event) =>
          props.onColorUpdated({
            ...props.color,
            blue: +event.target.value,
          })
        }
      />
      {props.color.blue}
      <br />
    </div>
  );
};

export default ColorPicker;

这个组件比 ColorBrowser 多了一个东西:回调函数

onColorUpdated 是一个函数类型的 prop,它接收一个 Color 类型的参数,没有返回值(void)。当滑块的值发生变化时,我们调用这个函数,并传入一个新的颜色对象。

注意这里的一个细节:+event.target.value

event.target.value<input type="range" /> 里取出来是字符串 "20" 这样的形式,前面加一个 + 号把它转成数字 20。因为 Color 类型里 redgreenblue 都是 number,如果不转换,TypeScript 会报类型不匹配。

这里你可能会好奇:"为什么父组件里 setColor 可以直接传给 onColorUpdated?"

在父组件 App 中,我们定义了 const [color, setColor] = useState<Color>(...)

setColor 的类型是 (newColor: Color) => void,它接收一个新的 Color 对象并更新状态。而 ColorPickeronColorUpdated 类型也是 (color: Color) => void,所以两者完美匹配。

这就是 状态提升 的典型做法:子组件不自己持有状态,而是通过 props 接收状态和更新函数,把修改权交还给父组件。这样父组件可以同时控制 ColorBrowserColorPicker,保持数据同步。


2.3 颜色数据模型

在项目的 model/color.ts 里,我们定义了 Color 接口:

ts

typescript 复制代码
// model/color.ts
export interface Color {
  red: number;
  green: number;
  blue: number;
}

你可能觉得这个文件太简单了,没什么用。

但在实际项目中,这个文件会被很多地方引用:ColorBrowserColorPickerApp,以及未来可能新增的主题编辑器、图表组件等等。

如果有一天产品经理说"我们颜色要加一个透明度 alpha 通道",你只需要改这一个文件:

ts

typescript 复制代码
export interface Color {
  red: number;
  green: number;
  blue: number;
  alpha: number;   // 新增
}

然后 TypeScript 会在所有用到 Color 的地方报错,告诉你"这里缺少 alpha"或"这里多了一个字段"。你可以顺着报错一个个改,不用担心漏掉哪个文件。

这就是类型系统对于 大型项目 的价值------它把"人脑记忆"变成了"机器检查"。


三、成员列表:数据请求与列表渲染

颜色选择器只是开胃菜,接下来我们看一个更贴近真实业务场景的组件:成员列表。

3.1 先定义成员数据结构

同样,在 model/member.ts 里定义接口:

ts

typescript 复制代码
// model/member.ts
export interface MemberEntity {
  id: number;
  login: string;
  avatar_url: string;
}

这个接口描述了一个 GitHub 用户的基本信息。

实际项目里,这种接口通常由后端 API 文档提供,前端直接照着写。有了类型定义之后,后端返回的数据结构如果变了(比如 avatar_url 改成了 avatarUrl),前端在编译阶段就能发现,而不是等到用户打开页面看到头像裂了才反应过来。

3.2 模拟接口请求

我们有一个 api/memberApi.ts,里面模拟了从 GitHub 获取成员列表的过程:

ts

typescript 复制代码
// api/memberApi.ts
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[]> 表示这个函数返回一个 Promise,这个 Promise 成功时返回的数据是一个 MemberEntity 数组。

setTimeout 模拟了 500 毫秒的网络延迟。在实际项目中,这里会换成 fetchaxios 请求真实的 API 地址。

3.3 列表组件:从状态到渲染

MemberTable 组件展示了如何用 useStateuseEffect 配合异步请求。

tsx

typescript 复制代码
// MemberTable.tsx
import * as React from 'react';
import { type MemberEntity } from '../model/member';
import { getMembersCollection } from '../api/memberApi';

const MemberRow = (props: { member: MemberEntity }) => {
  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>
  );
};

const MemberTable: React.FC = () => {
  const [memberCollection, setMemberCollection] = React.useState<MemberEntity[]>([]);

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

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

export default MemberTable;

我们来拆解一下这个组件里的几个重要概念。

useState<MemberEntity[]>([])

这里的 useState 接受了一个类型参数 <MemberEntity[]>,表示这个 state 是一个数组,数组里的每一项都符合 MemberEntity 接口。初始值传了一个空数组 [],TypeScript 能推断出这个空数组的类型就是 MemberEntity[],所以不会报错。

useEffect 的依赖数组 []

第二个参数传了空数组,表示这个副作用只在组件挂载时执行一次(相当于 class 组件里的 componentDidMount)。

如果你不传依赖数组,它会在每次渲染后都执行,可能导致无限循环(尤其是在里面调用了 setState 的情况下)。

async/await 处理异步请求

useEffect 的回调函数不能直接写成 async,因为它需要返回一个清理函数或者不返回。所以我们内部定义了一个 fetchMembers 函数,然后立即调用它。

map 遍历渲染

memberCollection.map((member) => <MemberRow key={member.id} member={member} />)

这里 key 是 React 用来优化列表渲染的特殊属性,需要传一个唯一标识。member.id 正好是数字且唯一,直接拿来用就行。

MemberRow 的 props 类型

我们在 MemberRow 的参数里直接解构了 { member },并且标注了类型 { member: MemberEntity }。这样在 imgsrc 里写 member.avatar_url 时,TypeScript 知道 member 上有这个字段,不会报错。


四、App 组件:把一切串联起来

最后看 App 组件,它是整个应用的入口。

tsx

javascript 复制代码
// App.tsx
import { useState } from 'react';
import ColorBrowser from './components/ColorBrowser';
import { type Color } from './model/color';
import ColorPicker from './components/ColorPicker';
import MemberTable from './components/MemberTable';

function App() {
  const [color, setColor] = useState<Color>({
    red: 20,
    green: 240,
    blue: 180,
  });

  return (
    <>
      <ColorBrowser color={color} />
      <ColorPicker color={color} onColorUpdated={setColor} />
      <MemberTable />
    </>
  );
}

export default App;

这里有两个知识点值得注意:

useState<Color> 的类型参数

我们告诉 TypeScript,这个 state 的类型是 Color。初始对象 { red: 20, green: 240, blue: 180 } 符合 Color 接口的定义,所以没问题。

如果哪天你不小心写成了 { red: 20, green: 240 }(漏了 blue),TypeScript 会在你保存文件的那一刻就报错,而不是等到页面运行时才发现。

<>...</> 片段语法

这个叫 React Fragment,用来包裹多个子元素而不引入额外的 DOM 节点。它等价于 <React.Fragment>...</React.Fragment>


五、从这些代码里我们能学到什么?

梳理完整个项目,你会发现它其实涵盖了一个 React 应用最核心的几个部分:

  1. 组件设计ColorBrowser 只管展示,ColorPicker 只管交互,MemberTable 管列表,职责清晰。
  2. 状态管理 :用 useState 管理局部状态,通过 props 传递给子组件,通过回调函数让子组件能修改父组件的状态。
  3. 数据请求 :在 useEffect 里发起异步请求,拿到数据后更新 state,触发重新渲染。
  4. 类型定义 :用 interface 定义数据结构,在组件 props、state、API 返回值等各处使用,让代码更健壮。

当然,这个项目目前还是比较简单的。在实际工作中,你可能会遇到更复杂的情况:

  • 多个组件需要共享同一个数据,这时候可能需要 Context 或状态管理库(如 Zustand、Redux);
  • 表单处理、路由、错误边界、性能优化等等。

但无论如何,类型先行 的习惯一旦养成,后面遇到任何复杂场景都能更从容地应对。


六、写在最后

迁移 TypeScript 这件事,很多团队会觉得"麻烦""浪费时间"。但事实上,类型系统节省的不是写代码的时间,而是 调试沟通 的时间。

你不需要再对着一个变量猜它里面有什么字段,编辑器会告诉你。

你不需要在代码里写一堆 if (obj && obj.user && obj.user.name) 来防御,TypeScript 会在编译阶段就提醒你处理 undefined

你不需要在重构的时候满项目搜索某个字段名,TypeScript 会精确地告诉你有多少地方用了它,哪些地方可能有问题。

这些收益,在项目初期可能感受不明显,但随着项目规模扩大、团队成员增多,会变得越来越重要。

如果你现在还在用纯 JavaScript 写 React,不妨从下一个新组件开始尝试 TypeScript。先把 interface 定义好,再写逻辑,你会慢慢发现代码变得比以前"稳"了。


接下来你可以做什么?

  • Color 接口里加一个 alpha 字段,看看 TypeScript 会在哪些地方提醒你修改;
  • getMembersCollection 换成真实的 fetch 请求,处理 loadingerror 状态;
  • MemberRow 加上点击事件,实现"查看用户详情"的功能。

每一步都会让你对 React + TypeScript 的理解更深一层。

相关推荐
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 11 篇:状态管理
前端·vue.js·react.js
GuWenyue1 小时前
90%前端写React TS都踩坑!一套父子组件+Hooks完整实战,彻底搞懂类型约束
前端·react.js
张元清1 小时前
React useMeasure Hook:用 ResizeObserver 测量 DOM 元素 (2026)
javascript·react.js
朝阳393 小时前
react19【实战】配置化路由登录鉴权完整方案
前端·javascript·react.js
名字还没想好☜3 小时前
React 用 IntersectionObserver 实现图片懒加载与无限滚动:封装一个 useInView Hook
前端·javascript·vue.js·react.js·react
朝阳394 小时前
react19【系列实用教程】setSearchParams
react.js
张元清20 小时前
React useDisclosure Hook:管理模态框和抽屉的打开关闭状态 (2026)
javascript·react.js
学高数就犯困21 小时前
React:常见的性能优化手段
前端·react.js
濮水大叔21 小时前
为什么 AI 最擅长 React/Next.js,却很少看到真正好用的 Next.js 开源项目?
react.js·node.js·next.js