为什么大厂项目都在用 React + TypeScript?本文通过一个 NameEdit 组件实战,带你掌握 TS 在 React 中的核心用法。
一、为什么 React + TypeScript 是绝配?
React 本身就是用 TypeScript 写的,它内置了大量类型声明,比如 React.FC、ReactNode、React.ChangeEvent 等。TypeScript 为 React 提供了:
- 编译期类型检查 — 拼写错误、参数类型不匹配在写代码时就暴露
- 智能提示 — IDE 自动补全 props,不用来回翻文档
- 大型项目可维护性 — 类型即文档,新人接手代码成本大幅降低
二、核心概念:React.FC 和 Props 类型约束
React.FC 是什么?
看 React 源码中的定义:
type FC<P = {}> = FunctionComponent<P>;
FC是FunctionComponent的别名,就是一个类型缩写<P = {}>是泛型,P代表 props 的类型,默认值为空对象{}- 如果你传入具体的 Props 类型,就会按你传的类型来约束组件
用 interface 还是 type?
两者都可以声明类型,但在组件 Props 场景下,推荐用 interface:
// 推荐:interface 更适合描述对象形状
interface Props {
userName: string;
}
// 也可以:type 别名
type Props = {
userName: string;
};
区别:interface 支持声明合并(同名自动合并),type 不行。对于组件 Props 这种描述"对象需要满足哪些属性"的场景,interface 更语义化。
Hello 组件实战
// Hello.tsx
import * as React from 'react';
type Props = {
userName: string;
};
const HelloComponent: React.FC<Props> = (props) => {
return <h2>Hello {props.userName}</h2>;
};
export default HelloComponent;
这样,当父组件传 props 时,TS 会强制检查:
// ✅ 正确
<HelloComponent userName="张三" />
// ❌ 编译报错:缺少 userName
<HelloComponent />
// ❌ 编译报错:userName 应该是 string,不是 number
<HelloComponent userName={123} />
三、组件通信:单向数据流
React 的数据流是单向的:父组件持有状态,通过 props 传给子组件。
三种常见模式
| 模式 | 数据流向 | 适用场景 |
|---|---|---|
| 直接传 props | 父 → 子 | 只读展示 |
| 传 props + 回调 | 父 ⇄ 子 | 子组件通知父组件修改 |
| 子组件私有状态 | 内部 | 不需要共享的表单状态 |
模式一:父组件直接传 props
// App.tsx
const App = () => {
const [name, setName] = React.useState("defaultUserName");
return (
<>
<HelloComponent userName={name} />
</>
);
};
子组件只负责展示,不修改数据。这是最简单的单向数据流。
模式二:传回调函数实现双向通信
当子组件需要修改父组件的状态时,父组件把修改方法作为回调传给子组件:
// App2.tsx — 父组件
const App2: React.FC = () => {
const [username, setUserName] = React.useState("initialName");
return (
<div>
<HelloComponent userName={username} />
<NameEditComponent
initialUserName={username}
onNameUpdated={setUserName}
/>
</div>
);
};
// NameEditComponent.tsx — 子组件
interface Props {
initialUserName: string;
onNameUpdated: (newName: string) => any;
}
const NameEditComponent: React.FC<Props> = (props) => {
const [editingName, setEditingName] = React.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>
</>
);
};
关键设计思路:
- 子组件用
editingName管理自己的输入状态,不直接修改父组件的数据 - 只有点击按钮时,才通过
onNameUpdated回调通知父组件 - 父组件收到新值后,用
setUserName更新状态,再通过 props 传回给HelloComponent
为什么子组件要有自己的状态,而不是直接 onChange 更新父组件?
如果每次按键都更新父组件,会导致:
- 父组件频繁重渲染,影响性能
- 子组件失去独立性,耦合度高
- 用户可能想取消编辑,直接改父组件数据不可逆
模式三:复杂的自定义事件类型
如果子组件不需要自己管理状态,也可以直接把事件抛给父组件处理:
interface Props {
username: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
return (
<div>
<label>Update name:</label>
<input value={props.username} onChange={props.onChange} />
</div>
);
};
这种模式适用于简单表单,状态完全由父组件管理。
四、React.ChangeEvent — 最常用的事件类型
React 使用合成事件(SyntheticEvent),看起来像原生事件,但实际上是 React 封装的跨浏览器事件对象。
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(e.target.value);
};
React.ChangeEvent— 泛型,指定事件发生在哪个元素上<HTMLInputElement>— 告诉 TS 这是 input 元素的 change 事件e.target.value— TS 知道 target 是 input,所以有value属性
常见事件类型速查:
| 类型 | 适用元素 |
|---|---|
React.ChangeEvent<HTMLInputElement> | input、textarea 的 onChange |
React.MouseEvent<HTMLButtonElement> | button 的 onClick |
React.KeyboardEvent<HTMLInputElement> | input 的 onKeyDown |
React.FormEvent<HTMLFormElement> | form 的 onSubmit |
五、useEffect — 组件挂载后的异步操作
const App = () => {
const [name, setName] = React.useState("defaultUserName");
const [editingName, setEditingName] = React.useState("defaultUserName");
const loadUsername = () => {
setTimeout(() => {
setName("name from async call");
setEditingName("name from async call");
}, 2000);
};
React.useEffect(() => {
loadUsername();
}, []); // 空依赖数组 = 只在组件挂载时执行一次
return (
<>
<HelloComponent userName={name} />
</>
);
};
useEffect 的执行逻辑:
- 组件先快速渲染,显示初始值
"defaultUserName" - 挂载完成后,
useEffect触发loadUsername - 2 秒后,
setName和setEditingName更新状态 - 组件重新渲染,显示
"name from async call"
为什么要把初始值设置好,而不是等数据返回再渲染?
这是 React 的设计哲学:组件第一要素是赶快显示出来,让用户觉得快。先展示占位内容,数据回来后再更新。如果等接口返回再渲染,用户看到的就是白屏。
六、完整数据流总结
┌─────────────────┐
│ App2 (父组件) │
│ │
│ useState │
│ username ──────►│──── props ────► HelloComponent
│ setUserName ◄───│── callback ─── NameEditComponent
└─────────────────┘
│
┌─────────┴─────────┐
▼ ▼
┌──────────────┐ ┌────────────────────┐
│ HelloComponent│ │ NameEditComponent │
│ │ │ │
│ 展示 username │ │ editingName (私有) │
│ 只读 props │ │ onChange → setState │
└──────────────┘ │ 点击按钮 → onNameUpdated│
└────────────────────┘
七、总结
| 概念 | 要点 |
|---|---|
React.FC<Props> | 用泛型约束 props 类型 |
interface Props | 定义组件需要哪些属性 |
| 单向数据流 | 父组件持有状态,通过 props 向下传递 |
| 回调通信 | 子组件通过回调通知父组件修改状态 |
React.ChangeEvent<T> | 合成事件类型,指定事件来源元素 |
useEffect(fn, []) | 组件挂载后执行副作用(如请求数据) |
核心原则:React + TypeScript 不只是"加类型",而是通过类型系统把组件之间的数据契约显式化。当一个新人接手你的代码时,他不需要看实现,只需要看一眼 interface Props,就知道这个组件需要什么、返回什么。
类型不是负担,是给未来的自己和你同事的说明书。