带你一步步把 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 应用最核心的几个部分:
- 组件设计 :
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 的理解更深一层。