前言
如果你刚学会 React,开始用 TypeScript 写项目时可能会一脸懵:为什么多了这么多 <>、interface、:?这篇文章基于真实的学习路径,从最简单的组件开始,一步步走到组件通信、状态管理和副作用处理,把 React + TypeScript 的核心知识点讲清楚。
一、为什么 React + TypeScript?
纯 JavaScript 写 React 的问题
| 场景 | 纯 JS | 加 TS |
|---|---|---|
| 传 props | 写错了运行才报错 | 写的时候就提示"你少传了属性" |
| 用 props | 不知道 props. 后面有什么 | 敲 props. 自动提示所有可用属性 |
| 团队协作 | 靠文档和口头约定 | 类型本身就是文档 |
| 重构 | 改了组件,不知道哪里受影响 | TS 编译器逐个排查所有调用处 |
一句话:TypeScript 就像给代码装了护栏,防止你在高速行驶时掉进坑里。
TypeScript 提供了什么
- 类型约束:变量、函数参数、返回值都有明确的类型
- 静态编译:代码还没跑就能发现错误
- 泛型:像函数参数一样传递类型,灵活又安全
二、React 的类型约束:React.FC
它是什么?
const App: React.FC = () => {
return <div>Hello</div>
}
React.FC 是 React 内置的类型声明,它的全称是 FunctionComponent。它的作用是告诉 TypeScript:"这是一个 React 函数组件"。
它做了什么?
- 约束返回值:函数必须返回
ReactElement(JSX 元素)或null - 自带 children 类型(React 18 之前)
- 为 props 提供类型检查入口
源码长什么样
type FC<P = {}> = FunctionComponent<P>;
// ^^^^^^ ^^^^^^^^^^^^^^^^^^^^
// 泛型参数P 真实的类型定义
// 默认值为{} 如果你不传,P就是空对象
P 是一个泛型参数,就像函数的形参一样,但它是用来传类型的:
// 不传泛型 → P 默认是 {},props 里什么都不能有
const A: React.FC = () => ...
// 传了 Props → P 变成 Props,props 必须满足 Props 的约束
const B: React.FC<Props> = (props) => ...
之前遇到的坑:为什么空函数体报错?
// ❌ 报错
const App: React.FC = () => {
// 什么都没 return
}
箭头函数用花括号 {} 时,没有 return 语句,返回类型被推断为 void。但 React.FC 要求返回 ReactElement | null,类型不匹配就报错了。
// ✅ 正确:有 return
const App: React.FC = () => {
return <div></div>
}
三、Props 接口:父子组件之间的"合同"
什么叫接口?
interface Props {
username: string; // 合同第一条:必须传一个字符串
onNameUpdated: (newName: string) => void; // 合同第二条:必须传一个函数
}
子组件说:"想用我,就必须遵守这份合同。缺一条都不行。"
谁定义 Props?
定义 interface Props 的文件就是子组件。 因为 Props 是别人传给你的东西 → 你被动接收 → 你是子组件。
type vs interface
// 方式一:interface(推荐用于 props)
interface Props {
username: string;
}
// 方式二:type(也可以,更通用)
type Props = {
username: string;
}
interface | type | |
|---|---|---|
| 用途 | 专门描述对象的形状 | 可以描述任何类型 |
| 扩展 | extends | & 交叉类型 |
| 设计意图 | "合同/接口" | "别名" |
90% 的场景可以互换。React 社区更常用 interface 定义 props。
函数类型的声明
onNameUpdated: (newName: string) => void
// ^^^^^^^^^^^^^^^^ ^^^^
// 参数:接收一个string 返回值:不返回任何东西(void)
TS 里看到 () => 某类型 这种箭头结构,就知道这是一个函数类型。注意它和 JS 箭头函数长得像但含义不同——这是类型层面的声明。
关于 void
void 表示函数"不返回任何东西"。但参数照样可以往里传:
onNameUpdated: (newName: string) => void
// ^^^^^^^^^^^^^^^^ 进 ^^^^ 出
// 名字从参数进去 不需要返回值出来
参数是进门,返回值是出门。void 说的是出门不拿东西,不影响进门。
四、React 事件类型:React.ChangeEvent<>
为什么需要事件类型?
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(e.target.value)
// ^^^^^^^ TS 知道 target 是 input,有 value 属性
}
泛型里填的是"事件发生在哪个元素上"
| 泛型 | 可用属性 |
|---|---|
HTMLInputElement | e.target.value, e.target.checked |
HTMLSelectElement | e.target.value |
HTMLDivElement | e.target.textContent(没有 value) |
不填会怎样?
(e: React.ChangeEvent) => {
e.target.value // ❌ TS 不知道 target 是谁,不敢让你用
}
类比
你去快递站拿快递。不写泛型 = "我来拿个快递",老板不知道该给你哪个。写了 <HTMLInputElement> = "我来拿顺丰的快递",老板立刻找到。
事件最重要的就是事件发生的元素,不同元素有不同属性,写清楚了 TS 才能正确提示。
五、组件通信:单向数据流
React 的核心法则
状态只属于一个组件。 数据永远单向流动,改变状态的唯一方式是该组件自己调用 setState。
父子通信模式
父组件(拥有状态) 子组件(接收 + 展示)
┌──────────────────┐ ┌──────────────────────┐
│ username = "峰哥" │ props │ │
│ setUserName() │ ───────→ │ props.username │
│ │ username│ props.onNameUpdated() │
│ │ │ │
│ │ ←─────── │ 调用回调函数 │
│ setUserName(新值) │ 回调 │ (子组件只是喊一声, │
└──────────────────┘ │ 不知道函数里干了什么)│
└──────────────────────┘
为什么不是双向?
子组件调用 props.onNameUpdated("新名字") 的时候:
- 子组件不知道父组件会拿这个名字干什么
- 子组件没有碰父组件的任何变量
- 子组件只是喊了一嗓子,通知父组件
就像你按门铃——你不知道门铃后面是开门、报警还是播放音乐。你只是按了,仅此而已。真正改数据的人永远是父组件自己。
类比
| 角色 | 类比 |
|---|---|
| 父组件 | 老板,钱(状态)在她手里 |
| 子组件 | 收银员,收了钱只能汇报,不能自己揣兜里 |
| props | 账本,老板给收银员看的 |
| 回调函数 | 对讲机,收银员喊"老板,有人交钱了" |
| setState | 老板自己亲手记账 |
六、组件架构的三次进化
这是理解 React 组件设计最精华的部分。我们从一个简单的"改名字"功能出发,看它怎么一步步优化。
版本一:事件对象直接透传(新人写法)
// 父组件
const App = () => {
const [username, setUserName] = useState("initialName");
const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserName(event.target.value)
}
return <NameEditComponent username={username} onChange={setUsernameState} />
}
// 子组件
interface Props {
username: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
return <input value={props.username} onChange={props.onChange} />
}
问题:
- 父组件需要知道
React.ChangeEvent<HTMLInputElement>的存在 - 复杂度泄漏到了父组件,父组件本该只关心数据和业务逻辑
- 子组件只是一个"传声筒",没有承担应有的封装职责
版本二:子组件内部消化复杂度(推荐起步写法)
// 子组件
interface Props {
initialUserName: string;
onNameUpdated: (newName: string) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
// 自有状态:子组件内部自己管理草稿
const [editingName, setEditingName] = useState(props.initialUserName);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(e.target.value); // 只改自己的状态
}
const onNameSubmit = () => {
props.onNameUpdated(editingName); // 点了按钮才通知父组件
}
return (
<>
<label>Update name:</label>
<input value={editingName} onChange={onChange} />
<button onClick={onNameSubmit}>Change</button>
</>
)
}
改进点:
- 父组件只需要收一个干净的
string,不再接触React.ChangeEvent React.ChangeEvent<HTMLInputElement>的复杂性被封装在子组件内部- 子组件有了自己的状态(
editingName),可以"自己打理"表单
类比:洗衣机把复杂的内部机械结构封装起来,你只需要扔衣服进去、按按钮、拿干净衣服。
版本三:无状态子组件(追求极致性能)
// 父组件同时持有 name 和 editingName
const App = () => {
const [name, setName] = useState("defaultUserName");
const [editingName, setEditingName] = useState("defaultUserName");
const setUserNameState = () => {
setName(editingName);
}
return (
<>
名字: {name}
<NameEditComponent
editingName={editingName}
onNameUpdated={setUserNameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === "" || editingName === name}
/>
</>
)
}
// 子组件:纯展示,没有任何自己的状态
const NameEditComponent: React.FC<Props> = (props) => {
return (
<>
<label>Update name:</label>
<input
value={props.editingName}
onChange={(e) => props.onEditingNameUpdated(e.target.value)}
/>
<button
onClick={props.onNameUpdated}
disabled={props.disabled}
>
Change
</button>
</>
)
}
改进点:
- 子组件完全无状态,只负责展示
UI = fn(props)——给同样的 props,永远渲染同样的界面- 性能更好,职责非常单一
- 所有状态集中在父组件管理,更容易追踪
三个版本的对比
| 版本一 | 版本二 | 版本三 | |
|---|---|---|---|
| 子组件有状态吗 | 无 | 有(editingName) | 无 |
| 父组件接触 Event 对象吗 | 接触 | 不接触 | 不接触 |
| 封装性 | 差 | 好 | 最好 |
| 子组件职责 | 传声筒 | 自己管表单 | 纯展示 |
| 适用场景 | 不推荐 | 表单组件 | 追求极致简洁 |
七、useEffect:副作用与生命周期
什么是副作用?
React 组件的主业是:根据 props 和 state,返回 JSX(渲染 UI)。除此之外做的事都是副作用:
- 请求接口拿数据
- 操作 localStorage
- 设置定时器
- 操作 DOM
组件的首要任务是赶快显示出来,让用户觉得快。 耗时的副作用放后面做。
useEffect 的四种模式
// 模式一:每次渲染都执行
useEffect(() => {
console.log('每次更新都执行');
});
// 模式二:只在挂载后执行一次
useEffect(() => {
console.log('只会在挂载后执行');
}, []); // ← 空数组 = 没有依赖 = 只执行一次
// 模式三:依赖项变化时执行
useEffect(() => {
console.log('count 改变时执行');
}, [count]); // ← count 变了就触发
// 模式四:依赖项变化 + 卸载前清理
useEffect(() => {
console.log('todos 更新后执行');
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
一张表搞懂依赖数组
| 第二个参数 | 执行时机 | 常见用途 |
|---|---|---|
| 不传 | 每次渲染后都执行 | 不推荐(性能浪费) |
[] | 只在挂载后执行一次 | 请求初始数据、设置监听 |
[a, b] | 挂载后 + a 或 b 变化时 | 响应特定状态变化 |
| 无 | 不存在 | — |
卸载前的清理工作
const Demo = () => {
useEffect(() => {
const interval = setInterval(() => {
console.log('interval is here');
}, 1000);
// 返回的函数会在组件卸载前执行
return () => {
console.log('组件卸载前,清理定时器');
clearInterval(interval);
}
}, []);
return <>Demo</>
}
为什么要清理?
如果不清理定时器,即使组件已经被移除了,定时器还在后台跑。这个定时器引用的变量永远无法被垃圾回收——这就是内存泄漏。
清理工作清单:
clearInterval/clearTimeout- 取消网络请求
- 移除事件监听器
实际应用:同步 localStorage
const [todos, setTodos] = useState(() => {
// 初始化:从 localStorage 恢复数据
return JSON.parse(localStorage.getItem('todos')) || [];
});
// 每次 todos 变化,自动存到 localStorage
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
这样数据就持久化了——刷新页面也不会丢。
八、前端本地存储小知识
| 存储方式 | 容量 | 用途 |
|---|---|---|
| 浏览器缓存 | — | 静态资源(JS/CSS/图片) |
localStorage | 约 5MB | key-value 配置、关键数据持久化 |
| IndexedDB | 较大 | 类数据库,存更多结构化数据 |
localStorage 只能存字符串,存对象需要用 JSON.stringify:
// 存
localStorage.setItem('todos', JSON.stringify(todos));
// 取
JSON.parse(localStorage.getItem('todos')) || [];
九、完整实战:一个带本地持久化的用户名编辑器
项目结构
src/
├── App.tsx ← 父组件,持有所有状态
├── components/
│ ├── Hello.tsx ← 展示组件
│ └── NameEditComponent.tsx ← 编辑组件
App.tsx
import * as React from 'react';
import HelloComponent from './components/Hello';
import NameEditComponent from './components/NameEditComponent';
const App = () => {
const [name, setName] = React.useState<string>("defaultUserName");
const [editingName, setEditingName] = React.useState("defaultUserName");
// 副作用:模拟从接口获取数据
const loadUsername = () => {
setTimeout(() => {
setName("name from async call");
setEditingName("name from async call");
}, 2000);
}
React.useEffect(() => {
// 组件挂载后,赶快渲染 → 再去拿数据
loadUsername();
}, []);
const setUserNameState = () => {
setName(editingName);
}
return (
<>
名字: {name}
<HelloComponent userName={editingName} />
<NameEditComponent
editingName={editingName}
onNameUpdated={setUserNameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === "" || editingName === name}
/>
</>
)
}
export default App;
Hello.tsx
import * as React from 'react';
interface Props {
userName: string;
}
const Hello: React.FC<Props> = (props) => {
return <h2>Hello {props.userName}</h2>
}
export default Hello;
NameEditComponent.tsx
import * as React from 'react';
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (newName: string) => void;
disabled: boolean;
}
const NameEditComponent: React.FC<Props> = (props) => {
return (
<>
<label>Update name:</label>
<input
value={props.editingName}
onChange={(e) => props.onEditingNameUpdated(e.target.value)}
/>
<button
onClick={props.onNameUpdated}
disabled={props.disabled}
>
Change
</button>
</>
)
}
export default NameEditComponent;
十、核心要点回顾
| 知识点 | 一句话总结 |
|---|---|
React.FC | 给组件贴上"我是函数组件"的标签,约束返回值 |
泛型 <T> | 像函数参数一样传类型,React.FC<Props> 意思是用 Props 约束 props |
interface Props | 子组件写给父组件的"合同",声明我需要什么数据 |
| 事件类型 | React.ChangeEvent<HTMLInputElement>,告诉 TS 事件发生在哪个元素上 |
| 单向数据流 | 状态只归一个组件管,子组件通过回调通知,不是传数据回去 |
| 版本进化 | 复杂度内聚 → 子组件封装 → 纯展示组件 |
useEffect | 副作用的总管:请求数据、存储、定时器、清理 |
依赖数组 [] | 只挂载后执行一次;[x]:x 变了就执行 |
清理函数 return () => {} | 组件卸载前执行,防止内存泄漏 |
写在最后
React + TypeScript 的学习曲线确实比纯 JS 要陡一些,但一旦跨过那道坎,你会发现写代码变得更安心——改一个组件,TS 会逐个排查所有调用处;接别人的代码,看 interface Props 就知道这个组件怎么用。
本文从最基础的 React.FC 类型讲起,到 props 接口声明、事件类型、组件通信模式、架构进化,再到 useEffect 副作用处理,覆盖了 React + TypeScript 日常开发的绝大部分场景。希望对你有所帮助!