186-6908-0178
当前位置:首页 >前端技术
从“能用”到“好维护”:React + TypeScript 实战指南
2026-08-05 24次浏览

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

第三个是 React.CSSProperties。
这是 TypeScript 内置的类型,用来描述 CSS 样式对象。你可能会问:“样式对象不就是普通对象吗,为什么要加类型?”
因为加上之后,你在写 width: '11rem' 的时候,如果写错了属性名(比如写成 wdith),TypeScript 会提醒你。如果写错了属性值类型(比如写成 width: 11rem 不加引号),也会报错。对初学者来说,这种即时反馈能省去很多查 bug 的时间。

其实原本的代码里,ColorBrowser 的 divStyle 缺少了 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 类型里 red、green、blue 都是 number,如果不转换,TypeScript 会报类型不匹配。

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

在父组件 App 中,我们定义了 const [color, setColor] = useState<Color>(...)。
setColor 的类型是 (newColor: Color) => void,它接收一个新的 Color 对象并更新状态。而 ColorPicker 的 onColorUpdated 类型也是 (color: Color) => void,所以两者完美匹配。

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

2.3 颜色数据模型

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

ts

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

你可能觉得这个文件太简单了,没什么用。
但在实际项目中,这个文件会被很多地方引用:ColorBrowser、ColorPicker、App,以及未来可能新增的主题编辑器、图表组件等等。

如果有一天产品经理说“我们颜色要加一个透明度 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 毫秒的网络延迟。在实际项目中,这里会换成 fetch 或 axios 请求真实的 API 地址。

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

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

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 }。这样在 img 的 src 里写 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 请求,处理 loading 和 error 状态;

  • 给 MemberRow 加上点击事件,实现“查看用户详情”的功能。

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


作者:阿黎梨梨
链接:https://juejin.cn/post/7670111026742870026
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


回到顶部