带你一步步把 JavaScript React 项目升级为 TypeScript,同时捋清组件、状态、请求和类型那些事儿
如果你写过 JavaScript React 项目,大概率经历过这样的场景:
打开一个组件,想看看 props 里到底传了什么东西,得一路往上翻到父组件;
改了一个接口字段,结果页面直接报 undefined is not a function,然后全局搜索挨个改;
明明传的是数组,结果子组件当对象用了,控制台一片红。
这些问题本质上都是 类型不确定 带来的。
TypeScript 并不能阻止程序出错,但它能在 代码运行之前 就告诉你哪里可能有问题。相当于在你写代码的时候,旁边坐了一个细心的同事,随时提醒你:“这个变量可能是 undefined 哦”“这个函数参数类型不对”。
另外,TypeScript 的类型注解本身就是一种 文档。当你看到一个函数 (color: Color) => void,立刻就知道它接收一个 Color 类型的参数,不返回任何值。不用看实现,不用猜。
我们这次就来把一个小型的 React 项目从 JavaScript 迁移到 TypeScript,同时在这个过程中,把组件设计、状态管理、数据请求和类型定义全部捋清楚。
在项目里有一个 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,我们在迁移过程中顺手补上。这也是类型检查的好处之一——它能让你更容易发现逻辑上的遗漏。
接着看 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,保持数据同步。
在项目的 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”或“这里多了一个字段”。你可以顺着报错一个个改,不用担心漏掉哪个文件。
这就是类型系统对于 大型项目 的价值——它把“人脑记忆”变成了“机器检查”。
颜色选择器只是开胃菜,接下来我们看一个更贴近真实业务场景的组件:成员列表。
同样,在 model/member.ts 里定义接口:
ts
typescript代码解读复制代码// model/member.ts export interface MemberEntity { id: number;
login: string; avatar_url: string; }这个接口描述了一个 GitHub 用户的基本信息。
实际项目里,这种接口通常由后端 API 文档提供,前端直接照着写。有了类型定义之后,后端返回的数据结构如果变了(比如 avatar_url 改成了 avatarUrl),前端在编译阶段就能发现,而不是等到用户打开页面看到头像裂了才反应过来。
我们有一个 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 地址。
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 组件,它是整个应用的入口。
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 应用最核心的几个部分:
组件设计:ColorBrowser 只管展示,ColorPicker 只管交互,MemberTable 管列表,职责清晰。
状态管理:用 useState 管理局部状态,通过 props 传递给子组件,通过回调函数让子组件能修改父组件的状态。
数据请求:在 useEffect 里发起异步请求,拿到数据后更新 state,触发重新渲染。
类型定义:用 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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。