在现代前端企业级开发中,React + TypeScript 已经成为公认的主流技术栈。TypeScript 提供的静态类型约束、编译期错误检查、完整的类型系统,极大提升了大型项目的可维护性与团队协作效率,有效避免了大量运行时类型错误。
很多初学者刚上手 React + TS 时,容易在组件类型标注、事件类型、副作用处理等环节反复踩坑,甚至写出类型冗余、耦合度高的代码。本文将从基础组件定义出发,结合组件设计的演进过程,系统梳理 React + TS 开发的核心规范与最佳实践,所有内容均贴合实际开发场景。
一、函数组件的类型基石:React.FC 与 Props 定义
1.1 什么是 React.FC
React.FC 是 React 内置的函数组件类型,全称为 FunctionComponent,本质是一个泛型类型别名,约定了函数组件的入参结构与返回值类型。React 源码中的定义如下GitHub:
typescript
运行
ini
type FC<P = {}> = FunctionComponent<P>;
interface FunctionComponent<P = {}> {
(props: PropsWithChildren<P>, context?: any): ReactElement | null;
propTypes?: WeakValidationMap<P>;
defaultProps?: Partial<P>;
displayName?: string;
}
通过泛型参数 <P>,我们可以将自定义的 Props 类型注入组件,让 TypeScript 完整校验 props 的属性名、类型与必填项。
1.2 Props 的两种定义方式
在 TypeScript 中,定义组件 Props 有两种主流方式,适用场景各有侧重:
- interface(接口) :优先推荐,支持扩展、声明合并,最适合描述对象结构的 Props
- type(类型别名) :更灵活,支持联合类型、交叉类型,适合复杂类型组合
两种写法示例:
typescript
运行
typescript
// interface 写法
interface Props {
username: string;
}
// type 写法
type Props = {
username: string;
age: number;
}
1.3 两种组件写法,禁止混用
很多初学者会犯重复标注的错误:既写 React.FC<Props>,又给 props 参数单独加类型注解。正确写法二选一即可:
**写法一:React.FC 泛型标注(推荐)**语义清晰,自动兼容 children、defaultProps 等属性:
tsx
typescript
import * as React from 'react';
interface Props {
username: string;
}
const Hello: React.FC<Props> = (props) => {
return <h2>Hello {props.username}</h2>
}
export default Hello;
写法二:直接给 props 参数标注类型更轻量,适合简单组件:
tsx
javascript
const Hello = (props: Props) => {
return <h2>Hello {props.username}</h2>
}
🚩 踩坑提醒:不要写成
const Hello: React.FC<Props> = (props: Props) => {...},属于双重类型标注,完全冗余。
二、事件系统:合成事件类型与组件回调的三层演进
React 基于 W3C 规范封装了一套跨浏览器兼容的合成事件(SyntheticEvent)系统,抹平了不同浏览器的 API 差异React。在 TypeScript 中,我们需要为事件对象标注准确的类型,而组件回调的设计也直接决定了代码的解耦程度。
2.1 事件类型命名口诀
React 合成事件的类型命名遵循统一规则:React.事件名Event<DOM元素类型>,可以总结为一句口诀:什么事件 + 什么标签。
常见事件类型对应表:
表格
| 事件类型 | 适用场景 | 常用 DOM 元素 |
|---|---|---|
React.ChangeEvent<T> |
表单内容变更 | HTMLInputElement / HTMLSelectElement |
React.MouseEvent<T> |
鼠标点击、移动 | HTMLButtonElement / HTMLDivElement |
React.FormEvent<T> |
表单提交 | HTMLFormElement |
React.KeyboardEvent<T> |
键盘按键 | HTMLInputElement |
根据回调的用途,通常分为三类标注方式:
- 无参简单回调 :
() => void,仅触发动作,不传递参数、不关心返回值 - 业务传值回调 :
(value: 业务类型) => void,子组件向父组件传递业务数据(最佳实践) - 原生事件回调 :
(e: React.XXXEvent<HTMLXXXElement>) => void,需要访问事件对象本身时使用
2.2 组件回调的三层演进
我们以名称编辑组件为例,通过三个版本的迭代,理解组件解耦的核心思路。
版本 1:透传事件对象(不推荐,耦合度高)
最原始的写法是子组件直接把 input 的 change 事件透传给父组件,由父组件从事件对象中取值。
tsx
typescript
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>
)
}
问题:父组件必须感知 DOM 事件细节,依赖具体的元素类型。如果后续子组件将 input 替换为其他控件,父组件的类型定义必须同步修改,耦合度极高。
版本 2:子组件维护私有状态(推荐,内部自治)
优化思路:子组件自己维护编辑中的状态,只在提交时将最终业务值传递给父组件。父组件完全不用关心内部实现与事件类型。
tsx
typescript
interface Props {
initialUserName: string;
onNameUpdated: (newName: string) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
const [editing, setEditing] = React.useState(props.initialUserName);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditing(e.target.value);
}
const onNameSubmit = () => {
props.onNameUpdated(editing);
}
return (
<>
<label>Change name:</label>
<input value={editing} onChange={onChange} />
<button onClick={onNameSubmit}>Change</button>
</>
)
}
这是经典的组件设计原则:事件处理留在子组件,对外只暴露业务数据。父组件只需要接收字符串类型的新名称,与具体 DOM 实现彻底解耦。
版本 3:状态提升,纯展示组件(适合多组件联动)
如果编辑状态需要被多个兄弟组件共享,就需要将状态提升到最近的公共父组件,子组件变为完全受控的纯展示组件。
tsx
typescript
interface Props {
editingName: string;
onNameUpdated: (editingName: string) => void;
onEditingNameUpdated: (newEditingName: string) => void;
disabled: boolean;
}
const NameEditingComponent: React.FC<Props> = (props) => {
const { editingName, onEditingNameUpdated, onNameUpdated, disabled } = props;
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onEditingNameUpdated(e.target.value);
}
const onNameSubmit = () => {
onNameUpdated(editingName);
}
return (
<>
<label>Update name:</label>
<input
type="text"
value={editingName}
onChange={onChange}
/>
<button onClick={onNameSubmit} disabled={disabled}>change</button>
</>
)
}
此时子组件没有自身状态,完全由父组件通过 props 驱动,逻辑更可控,适合复杂表单与多组件联动场景。
对应的父组件完整示例:
tsx
typescript
import * as React from 'react';
import HelloComponent from './components/Hello'
import NameEditingComponent from './components/NameEditingComponent';
const App: React.FC = () => {
const [name, setName] = React.useState<string>("default");
const [editingName, setEditingName] = React.useState("default")
const loadUsername = () => {
setTimeout(() => {
setName("name from async call");
setEditingName("name from async call")
}, 2000);
}
React.useEffect(() => {
loadUsername();
}, [])
return (
<>
名字:{name}
<HelloComponent username={editingName} />
<NameEditingComponent
editingName={editingName}
onNameUpdated={setName}
onEditingNameUpdated={setEditingName}
disabled={editingName === "" || editingName === name}
/>
</>
)
}
export default App;
三、useEffect 副作用:生命周期、异步写法与内存清理
useEffect 是函数组件中处理副作用的核心 Hook,专门用于执行不能写在组件顶层的操作,比如接口请求、定时器、DOM 事件监听等,完整覆盖了类组件的生命周期能力。
3.1 四种执行时机
通过第二个参数「依赖数组」,可以精确控制副作用的执行时机:
- 仅挂载执行一次 :依赖数组为空
[]对应类组件componentDidMount,用于页面初始化、接口请求、全局事件绑定。
tsx
scss
React.useEffect(() => {
loadUsername();
}, []);
- 挂载 + 依赖项更新时执行 :依赖数组填写指定变量对应
componentDidMount + componentDidUpdate,依赖项变化时重新执行副作用。 - 挂载 + 任意状态更新都执行:不写第二个参数不推荐,极易引发死循环与性能问题,仅极少数特殊场景使用。
- 卸载前清理 :return 返回清理函数对应
componentWillUnmount,用于清除定时器、取消订阅、移除监听,避免内存泄漏。
3.2 清理函数与内存泄漏
持续运行的副作用如果不在组件卸载时清除,会造成内存泄漏。定时器是最典型的场景:
tsx
javascript
React.useEffect(() => {
const interval = setInterval(() => {
console.log('interval is running');
}, 2000);
// 组件卸载前执行清理
return () => {
console.log('组件卸载,清理定时器');
clearInterval(interval);
}
}, []);
💡 核心原则:凡是会持续运行的副作用,都必须编写清理函数。定时器、防抖节流、全局事件监听、WebSocket 连接均在此列。
3.3 异步请求的正确写法
绝对不能直接给 useEffect 回调加 async。因为 async 函数永远返回 Promise,而 useEffect 只允许返回清理函数或 undefined,直接写 async 会导致清理机制失效,还会触发类型报错。
标准写法:在 useEffect 内部定义异步函数,再立即调用(IIFE 或具名函数均可):
tsx
scss
React.useEffect(() => {
// 内部定义异步函数
const fetchData = async () => {
const members = await getMembersCollection();
setMemberCollection(members);
}
fetchData();
}, [])
也可以使用立即执行函数的简写形式:
tsx
scss
React.useEffect(() => {
(async () => {
const members = await getMembersCollection();
setMemberCollection(members);
})()
}, [])
四、拓展:前端本地存储 localStorage
在前端开发中,本地存储是常用的轻量数据持久化方案,localStorage 是其中最常用的一种,核心特点:
- 持久化存储,关闭浏览器后数据依然保留
- 单域名存储容量约 5MB
- 以键值对形式存储,值仅支持字符串
常用 API:
- 存储 :
localStorage.setItem(key, value),存储对象需用JSON.stringify转为字符串 - 读取 :
localStorage.getItem(key),返回值可能为 null,读取对象需用JSON.parse解析 - 删除 :
localStorage.removeItem(key)
示例:
typescript
运行
javascript
// 存储对象
localStorage.setItem('userInfo', JSON.stringify({ name: '张三', id: 1 }));
// 读取对象,处理 null 边界
const raw = localStorage.getItem('userInfo');
const userInfo = raw ? JSON.parse(raw) : null;
五、核心设计思想:单向数据流
所有类型规范与组件设计,本质都遵循 React 的核心原则 ------单向数据流:
- 数据自上而下:父组件通过 props 将数据传递给子组件,子组件不能直接修改 props
- 事件自下而上:子组件通过回调函数通知父组件修改状态,状态更新后再通过 props 下发
结合组件演进过程,可以总结出两条通用最佳实践:
- 优先让子组件自治,内部状态自行维护,对外只暴露业务回调,不传递原生事件对象
- 状态需要跨组件共享时,再提升到最近的公共父组件,避免过度状态提升
总结
React + TypeScript 的价值,从来不是为了写类型而写类型,而是通过静态类型约束,在编译期暴露问题、提升代码可维护性、降低大型项目的协作成本。
本文覆盖的核心内容,可以归纳为三条开发规范:
- 组件类型 :Props 优先使用 interface,
React.FC<Props>与 props 类型注解二选一,不重复标注 - 事件回调:优先传递业务数据,少透传原生事件对象,降低组件耦合度
- 副作用:明确 useEffect 执行时机,持续副作用必须编写清理函数,异步逻辑写在函数内部