React + TypeScript 学习笔记:从入门到组件设计演进
本文基于 React + TypeScript 实践项目(ts-demo)和 React Todo 应用(todos)的代码与笔记整理,旨在总结学习过程中遇到的重要知识点和开发思路。
一、为什么选择 React + TypeScript?
React 与 TypeScript 的组合非常适合企业级开发。TypeScript 提供了三大核心能力:
- 类型约束:在编译阶段就能发现潜在的类型错误
- 静态编译:代码更健壮,重构更安全
- 丰富的语言功能:泛型、接口、类型别名等,让代码表达力更强
值得注意的是,React 本身就是用 TypeScript 编写的,内置了诸如 ReactNode、React.FC 等类型声明,这让 TypeScript 与 React 的配合天衣无缝。
二、React 的类型约束体系
2.1 React.FC 与函数组件
React.FC 是 React 函数组件的类型声明,它的源码定义大致为:
type FC<P = {}> = FunctionComponent<P>;
这里面有几个重要的知识点:
type是类型别名,相比FunctionComponent,FC更简短好记- 泛型
<P = {}>:P是泛型参数,代表 Props 的类型;= {}表示默认值为空对象——如果你不传类型参数,Props 就默认为{} - 当你传入具体的 Props 类型时,就像这样:
React.FC<Props>,泛型就用你传递的类型来约束组件的 Props
2.2 interface 与 type
在 TypeScript 中,interface 和 type 都可以用于声明类型。但在定义组件的 Props 时,通常推荐使用 interface,因为它天然适合描述"对象需要满足哪些属性和方法"这一语义。
一个典型的 Props 接口声明:
interface Props {
userName: string; // 属性
onNameUpdated: () => void; // 无参、无返回值的函数
onEditingNameUpdated: (newEditingName: string) => void; // 带参数的函数
disabled: boolean; // 布尔值
}
注意:interface 中的成员用分号隔开,而不是逗号——这不是 JSON。
2.3 函数的类型声明
在 Props 中声明回调函数时,格式为:
() => void; // 无参数,无返回值
(newName: string) => void; // 有参数,无返回值
() => ReactNode; // 无参数,返回 React 节点
三、React 事件处理
3.1 React 合成事件
React 对原生 DOM 事件进行了封装,形成了合成事件(SyntheticEvent)。它看起来和原生事件很像,但具有跨浏览器兼容性。
3.2 React.ChangeEvent
处理表单输入时最常用的事件类型:
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// e.target.value 可以获取输入框的值
setEditingName(e.target.value);
};
这里的泛型 <HTMLInputElement> 指明了事件发生的元素类型——这很重要,因为不同元素的事件对象结构不同。TypeScript 会根据这个泛型推断出 e.target.value 等属性的类型。
四、Hooks
4.1 useState
useState 是 React 最核心的 Hook,用于在函数组件中管理状态:
const [name, setName] = React.useState<string>("defaultUserName");
const [editingName, setEditingName] = React.useState("defaultUserName");
要点:
- 泛型
<string>可以显式声明状态类型,也可以靠初始值自动推断 - 状态更新是异步的,React 会在合适的时机批量更新
- 永远不要直接修改状态,必须通过 setter 函数传入一个全新的值
懒初始化:当初始状态需要通过复杂计算得到时,可以传入一个函数:
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem('todos'));
});
这个函数只会在组件首次渲染时执行一次,后续重渲染不会再调用。
4.2 useEffect
useEffect 用于处理副作用——即那些不属于"将数据渲染为 UI"的操作,比如数据请求、DOM 操作、本地存储等。
它的核心执行时机是:组件挂载后(mounted),再根据依赖项决定是否在更新时重新执行。
根据依赖项数组的不同,有三种典型用法:
① 每次更新都执行(无依赖项数组)
useEffect(() => {
console.log('每次更新都执行');
});
② 只在挂载后执行一次(空依赖数组)
useEffect(() => {
console.log('只会在挂载后执行');
loaderUserName(); // 模拟异步请求
}, []); // 空数组 = 没有任何依赖,所以不会因状态变化而重新执行
这种模式的一个关键理念是:组件的第一要素是赶快显示出来,让用户觉得快。所以流程是:
- 组件即刻挂载,展示初始 UI(第一步)
useEffect在挂载后执行,去请求接口、拿到数据,响应式更新状态(第二步)
③ 依赖特定状态变化时执行
useEffect(() => {
console.log('count 改变也会执行');
}, [count]); // 只有 count 变化时才重新执行
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]); // todos 变化时同步到 localStorage
五、组件通信:单向数据流
这是 React 组件架构中最重要的设计原则:
父组件 ───props──→ 子组件
↑ │
└──自定义事件──────┘
核心规则
- 父组件负责持有状态和修改状态的方法
- 子组件通过 props 接收数据,通过自定义事件通知父组件
- 子组件不可以直接修改父组件的状态——这是铁律
实际案例
在 todo 应用中:
// 父组件 App.jsx — 持有 todos 状态和所有修改方法
const [todos, setTodos] = useState([]);
const addTodo = (text) => { /* ... */ }
const toggleTodo = (id) => { /* ... */ }
const deleteTodo = (id) => { /* ... */ }
// 通过 props 传给子组件
<TodoInput onAdd={addTodo} />
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
这样做的好处是:多个子组件共享统一的数据状态,数据和界面保持一致,不会出现数据不同步的问题。
组件树
父子组件通信形成了组件树结构——数据从根节点向下流动,事件从叶子节点向上冒泡。在开发前先规划好组件树,理清每个组件的职责和数据流向,对于高效开发(vibe coding)非常有帮助。
六、组件设计的三种版本演进
这是本次学习中最有启发性的部分——同一个功能,三种不同的设计方式,体现了对 React 数据流理解的逐步深入。
版本一:直接传递事件对象
// 子组件 — 把整个 event 对象暴露给父组件
interface Props {
userName: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
问题:父组件和子组件都需要知道 React.ChangeEvent<HTMLInputElement> 这个类型,增加了耦合。父组件不仅要关心"值是什么",还要关心"事件是怎么产生的"——这与父组件"持有和修改状态"的核心使命有所偏离。
版本二:子组件管理私有状态
// 子组件 — 内部管理 editingName,只把最终结果传给父组件
const [editingName, setEditingName] = React.useState(props.initialUserName);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(e.target.value); // 自己打理
};
const onNameSubmit = () => {
props.onNameUpdate(editingName); // 提交时才通知父组件
};
改进:事件细节被封装在子组件内部,父组件只需要关心"最终提交的值是什么"。职责更清晰。
版本三:状态提升,子组件变成纯展示组件
// 父组件持有 editingName 状态
const [editingName, setEditingName] = React.useState("defaultUserName");
// 子组件 — 无内部状态,纯粹通过 props 驱动
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (newEditingName: string) => void;
disabled: boolean;
}
这就是 UI = fn(props) 的理念:
- 子组件没有自己的状态,性能更好
- 子组件的职责非常单一,只负责展示
- 所有逻辑和状态由父组件统一管理
- 测试和复用都更加容易
三种版本的对比启示我们:好的组件设计,就是将状态尽可能向上提升,将展示尽可能向下传递。
七、核心开发流程总结
结合 todos 项目的开发经验,推荐的开发思路是:
- 组件化思想先行:将 UI 拆解为独立的、可复用的组件
- 规划组件树:设计项目和目录结构,明确每个组件的职责
- 确定数据流向:数据状态由父组件管理(持有 + 修改),子组件通过 props 共享数据,通过自定义事件提出修改请求
- 保证数据与界面统一:数据只能在一个地方被修改(父组件),确保整个应用状态的一致性
八、要点速查表
| 概念 | 要点 |
|---|---|
React.FC<Props> | 函数组件类型声明,泛型约束 Props |
interface | 定义对象的结构(属性和方法),成员用分号隔开 |
useState | 函数组件的状态管理,支持懒初始化 |
useEffect | 处理副作用,依赖数组决定执行时机 |
React.ChangeEvent<T> | 表单事件类型,泛型 T 指定事件源元素 |
| 单向数据流 | 父→子通过 props,子→父通过自定义事件 |
UI = fn(props) | 纯展示组件的设计理念,无状态、高性能 |
以上就是基于这些项目文件所能提炼的全部知识点。React + TypeScript 的核心在于类型约束保证健壮性 + 单向数据流保证可维护性,两者结合,正是企业级前端开发的利器。