React 搭配 TypeScript 已是现代前端企业级开发的标准方案。TS 带来的静态类型检查、编译期错误拦截、代码自文档化能力,能极大提升大型项目的可维护性与协作效率。本文将从最基础的组件 Props 类型约束出发,一步步拆解组件通信的三种演进形态,再深入 useEffect 副作用的核心概念与生命周期模拟,带你系统建立 React + TS 的开发思维。
一、函数组件的类型基石:React.FC 与 Props 契约
1.1 React.FC:函数组件的内置类型
React 本身由 TypeScript 编写,提供了丰富的内置类型声明,React.FC 就是最常用的函数组件类型。它是 React.FunctionComponent 的类型别名,本质上约定了「这是一个 React 函数组件,返回值为 ReactElement」。
React.FC 支持泛型语法 FC<P>,我们可以将 Props 的类型作为泛型参数传入,从而约束组件接收的属性:
tsx
typescript
import * as React from "react";
interface Props {
userName: string;
}
const HelloComponent: React.FC<Props> = (props) => {
return <h2>Hello {props.userName}</h2>;
};
export default HelloComponent;
这段代码的核心意义是:用接口 Props 定义一份「契约」,约定 HelloComponent 必须接收一个字符串类型的 userName 属性。调用组件时如果漏传、传错类型,TS 会在编码阶段直接红线报错,而不是等到运行时才出现 bug。
1.2 为什么优先用 interface 约束 Props
TS 中 type 类型别名和 interface 接口都可以定义对象结构,但社区约定俗成优先用 interface 定义组件 Props,核心原因有三点:
- 语义更贴合interface 本义就是「接口、契约」,用来定义组件对外暴露的入参规范,语义上完全匹配;type 更偏向通用类型别名,可定义联合类型、元组等复杂类型,语义更宽泛。
- 支持声明合并同名 interface 会自动合并属性,在封装基础组件、扩展第三方组件属性时非常实用;而 type 不允许重复声明。
tsx
kotlin
interface BaseProps { size: 'small' | 'large' }
interface BaseProps { color?: string }
// 最终 BaseProps 同时拥有 size 和 color
- 继承扩展更直观 多层组件封装时,
extends语法比交叉类型&可读性更强:
tsx
php
interface CardProps extends BaseProps {
title: string;
}
补充:当 Props 内部需要大量联合类型、条件类型等复杂运算时,再选择 type 即可。
二、组件通信与单向数据流:三种实现版本的演进
React 的核心原则是单向数据流:状态由父组件持有,通过 props 向下传递给子组件;子组件通过回调函数通知父组件修改状态。围绕「编辑用户名」这个常见场景,我们可以看到三种典型的实现演进,每一步都对应着不同的封装粒度与职责划分。
版本 1:透传事件对象,父组件处理全部逻辑
最朴素的写法是子组件完全不做封装,直接把 input 的事件对象透传给父组件:
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>
)
}
缺点非常明显:
- 事件类型
React.ChangeEvent<HTMLInputElement>泄漏到父组件,父组件也要维护复杂的事件类型 - 子组件没有任何封装性,只是一个单纯的壳
- 父组件职责过重:既要持有状态,又要处理 DOM 事件细节
这是初学者最容易写出的代码,它违背了「封装变化」的原则。
版本 2:子组件持有私有状态,内部消化事件复杂度
优化思路:把输入框的临时状态和事件处理全部收敛到子组件内部,父组件只需要在提交时拿到最终值。
tsx
typescript
import * as React from "react";
interface Props {
initialUserName: string;
onNameUpdated: (newName: string) => void;
}
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>
</>
);
};
优势:
- 事件复杂度全部封装在子组件,父组件无需关心 DOM 事件类型
- 父组件接口非常简洁:只需要传初始值和提交回调
- 草稿状态与父组件的正式状态天然隔离
局限性:
- 编辑状态在子组件内部,其他兄弟组件无法实时共享草稿值(比如实时预览)
- 父组件更新 initialUserName 时,子组件的 useState 初始值不会自动同步(只在首次挂载生效)
版本 3:状态提升至父组件,子组件变为纯展示
当多个子组件需要共享同一份状态时,标准解法是状态提升:把编辑状态上移到父组件统一管理,子组件变为纯展示的「无状态组件」。
tsx
typescript
// 子组件:只负责渲染,没有自身状态
interface Props {
editingName: string;
onNameUpdated: () => 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();
};
return (
<>
<label>Update name:</label>
<input value={editingName} onChange={onChange} />
<button disabled={disabled} onClick={onNameSubmit}>Change</button>
</>
);
};
对应的父组件统一管理所有状态:
tsx
ini
const App = () => {
// 正式生效的名字
const [name, setName] = React.useState<string>("defaultUserName");
// 编辑中的草稿
const [editingName, setEditingName] = React.useState("defaultUserName");
const setUserNameState = () => {
setName(editingName);
};
return (
<>
名字: {name}
{/* 实时预览:共享 editingName */}
<HelloComponent userName={editingName} />
<NameEditComponent
editingName={editingName}
onNameUpdated={setUserNameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === "" || editingName === name}
/>
</>
);
};
核心收益:
- 单一数据源:所有状态集中在父组件,避免数据不一致
- 组件职责单一 :子组件满足
UI = fn(props),只负责根据属性渲染,逻辑更简单、复用性更强 - 多组件共享:HelloComponent 可以实时拿到编辑中的草稿值,实现实时预览
这也是企业级项目最常用的模式:页面级组件持有状态,业务子组件保持无状态、只负责渲染与回调触发。
三、useEffect 副作用:概念、生命周期与最佳实践
3.1 到底什么是「副作用」
理解副作用的前提是先理解「纯函数」:
- 纯函数:只依赖入参计算返回值,不修改外部数据、不与外界交互,相同输入永远得到相同输出
- 副作用:函数执行过程中,对函数外部世界产生的影响
React 组件的本职工作是「根据 state 和 props 渲染 UI」,除此之外所有和外部交互的操作都是副作用,常见包括:
- 发起后端接口请求
- 定时器
setTimeout / setInterval - 操作 DOM 元素
- 读写 localStorage、订阅事件
组件渲染函数中不能直接写副作用,否则每次重渲染都会重复执行,造成定时器泄漏、重复请求等严重 bug。
useEffect的设计目的就是把副作用和渲染逻辑分离,精准控制执行时机。
3.2 useEffect 的三种写法,模拟三类生命周期
很多人会把 useEffect 和类组件的生命周期对应,准确来说:useEffect 不是生命周期,但可以通过依赖数组模拟出挂载、更新、卸载三种行为。
表格
| 写法 | 模拟生命周期 | 执行时机 |
|---|---|---|
useEffect(fn, []) |
componentDidMount + componentWillUnmount | 仅组件首次挂载执行一次;组件销毁时执行清理函数 |
useEffect(fn, [a, b]) |
componentDidMount + componentDidUpdate + componentWillUnmount | 首次挂载执行;依赖项变化时重新执行;每次重新执行前先运行上一次的清理函数 |
useEffect(fn) |
componentDidMount + 每次 componentDidUpdate | 组件每一次渲染完成后都会执行,极易造成死循环,非特殊场景不推荐 |
3.3 实战:异步加载数据的完整写法
回到我们的用户名加载场景,页面打开后异步拉取数据:
tsx
scss
const loadUsername = () => {
setTimeout(() => {
setName("name from async call");
setEditingName("name from async call");
}, 2000);
};
React.useEffect(() => {
loadUsername();
}, []);
设计思路:
- 组件先快速渲染出默认值,让用户立刻看到页面,提升感知速度
- 挂载完成后再发起异步请求,数据回来后更新状态触发重渲染
常见坑与修复:上面的代码存在内存泄漏风险:如果组件在 2 秒内被销毁,定时器依然会执行 setState。正确写法必须加上清理函数:
tsx
scss
React.useEffect(() => {
const timer = setTimeout(() => {
setName("name from async call");
setEditingName("name from async call");
}, 2000);
// 组件卸载时清除定时器
return () => clearTimeout(timer);
}, []);
四、React + TS 开发高频避坑指南
- Props 属性名大小写严格一致
userName和username在 TS 中是完全不同的两个属性,拼写不一致会直接导致类型报错,这是初学者最高频的错误。 - useState 初始值只执行一次
useState(props.initialUserName)只会在组件首次挂载时读取 props;后续 props 更新不会自动同步本地 state,需要配合 useEffect 监听依赖来同步。 - 合成事件类型要写全 输入框的 change 事件完整类型是
React.ChangeEvent<HTMLInputElement>,省略泛型会导致e.target类型丢失,无法正常读取 value。 - 状态分离原则正式数据与编辑草稿分开存储(name + editingName),既可以实现实时预览,又能支持取消编辑回滚,是表单类场景的标准实践。
五、总结
React + TypeScript 的学习不是单纯的语法记忆,核心是建立「类型契约 + 单向数据流 + 副作用管控」的完整思维体系。
从 Props 接口定义组件契约,到三种组件通信模式的演进,再到 useEffect 对副作用的精准管控,本质上都是在解决同一个问题:如何在项目复杂度提升时,依然保持代码的可维护性、可预测性与可复用性。理解了这些底层逻辑,你就能从「能写出来」进阶到「写得好」,真正掌握企业级 React 开发的核心能力。