写在前面:今天学了一个非常"实用"但又有点抽象的知识点------React + TypeScript 中父子组件如何优雅地通信 。老师拿了一个"修改用户名"的小功能,前后写了三个版本。从最原始的"把整个事件对象传给父组件",到"子组件自己管理输入状态",再到"状态提升到父组件但子组件无状态"------三次迭代,我一开始觉得"不都一样吗?"看完之后才发现,差距太大了。TypeScript 的强类型约束 + React 的单向数据流,写出来的代码可读性和健壮性完全不是一个量级。
一、TypeScript 给 React 带来了什么?
1.1 为什么要用 TypeScript?
老师说:
"React + TS 非常适合企业级开发。TS 提供了类型约束、静态编译、大型语言的丰富功能。"
纯 JS 写 React:
jsx
const Hello = (props) => {
return <h2>Hello {props.userName}</h2>
};
用 TS 写 React:
tsx
import * as React from 'react';
interface Props {
userName: string;
}
const Hello: React.FC<Props> = (props) => {
return <h2>Hello {props.userName}</h2>;
};
区别在哪?
| 对比 | 纯 JS | TypeScript |
|---|---|---|
| props 传错了 | 运行时才能发现 | 编译时直接报错 |
| 少传了属性 | 显示 undefined | ❌ 编译不过 |
| 类型约束 | 无 | interface Props 强制约束 |
| 代码提示 | 没有 | 编辑器自动提示 props 字段 |
React.FC<Props> 的含义:
老师说:
"
React.FC------React 函数组件类型。() => ReactNode。React 本身就是用 TS 写的,ReactNode、React.FC都是内置的类型声明。"
FC= FunctionComponent(函数组件)。<Props>= 泛型参数,告诉 TypeScript "这个组件的 props 必须满足 Props 接口的定义"。
二、React.FC 和泛型
2.1 源码层面理解
老师说:
"
type FC<P = {}> = FunctionComponent<P>------React 源码。FunctionComponent函数组件类的声明,返回一定是ReactElement。type类型别名,FC简短一些。type FC<P = {}>------默认值为{},如果你传呢?用传递的类型参数来约束。"
typescript
// 不传泛型:props 默认是空对象
const Hello: React.FC = (props) => { };
// 传泛型:props 必须满足 Props 接口
interface Props {
userName: string;
}
const Hello: React.FC<Props> = (props) => {
return <h2>Hello {props.userName}</h2>;
};
如果父组件这么调用:
tsx
// ❌ 错误:Props 要求 userName 是 string,但没传
<Hello />
// ✅ 正确:
<Hello userName="张三" />
// ❌ 错误:Props 没有 age 属性
<Hello userName="张三" age={18} />
TypeScript 在编译时就会拦截这些错误------不会等到运行时才发现。
三、版本一:把事件对象传给父组件(不优雅)
3.1 代码
看 NameEditComponent.tsx 中注释的部分:
tsx
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>
);
};
父组件 App 中:
tsx
const App = () => {
const [username, setUsername] = React.useState('initialName');
const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
setUsername(event.target.value);
};
return (
<NameEditComponent
userName={username}
onChange={setUsernameState}
/>
);
};
3.2 问题出在哪?
老师说:
"把子组件的 event 对象传给父组件,导致两边都要
React.ChangeEvent<HTMLInputElement>------单向数据流、父子组件通信的 state 交给父组件,props 传给子组件们,应用状态正确的前提(法律?)。"
这个版本有两个问题:
| 问题 | 说明 |
|---|---|
| 父组件被事件对象污染 | 父组件需要知道 ChangeEvent<HTMLInputElement> 这个类型 |
| 耦合性高 | 如果输入框换成别的组件(比如下拉框),父组件也要改 |
父组件本应只关心"值",但现在它不得不关心"事件对象"。
四、版本二:子组件自己管状态(好一些)
4.1 代码
看 NameEditComponent2.tsx:
tsx
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>
</>
);
};
这个版本的变化:
| 变化 | 之前 | 现在 |
|---|---|---|
| 子组件 | 无状态,只接收 props | 有自己的 editingName 状态 |
| 通信方式 | 传事件对象 | 点按钮时才传值 onNameUpdated(editingName) |
| 父组件接口 | <input> 的事件类型 |
只接收 string 值 |
父组件现在只需要:
tsx
<NameEditComponent
initialUserName={username}
onNameUpdated={setUsername}
/>
父组件不需要知道 ChangeEvent 是什么,它只接受一个 string。 子组件内部的输入逻辑完全私有化。
老师说:
"子组件中添加了私有状态
editingName,onChange 自己修改。提交父组件时只需要给值就好。"
五、版本三:状态提升 + 无状态子组件(性能最优)
5.1 为什么还要升级?
老师说:
"将私有状态提升到父组件,通过 props 传过来,onChange 修改
editingName。子组件没有状态,性能会更好,就负责展示。UI = fn(props)------子组件职责非常单一,就是负责显示。"
版本二的问题:子组件自己有状态,但父组件有时也需要知道"当前输入了什么"------比如想禁用"提交"按钮(当输入为空或和原来一样时)。
5.2 最终版代码
看 NameEditComponent.tsx(非注释版本):
tsx
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (editingName: string) => void;
disabled: boolean;
}
const NameEdiningComponent: 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>
</>
);
};
父组件 App.tsx:
tsx
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}
/>
</>
);
};
5.3 这个版本做了什么?
| 功能 | 谁管的 | 怎么实现的 |
|---|---|---|
| 显示当前名字 | App(父组件) | HelloComponent 展示 name |
| 输入框的值 | App 通过 Props 传的 | editingName 是父组件的状态 |
| 输入框变化时 | 子组件通过事件上报 | onEditingNameUpdated(e.target.value) |
| 提交时 | 子组件通知父组件 | onNameUpdated() |
| 按钮禁用 | 父组件算好传下来 | `disabled={editingName === "" |
子组件完全没有自己的状态------它所有的数据都是从 Props 来的,所有的操作都是通过事件上报的。
老师说:
"
UI = fn(props)------子组件没有状态,性能会更好,就负责展示。子组件职责非常单一,就是负责显示。"
这个模式的好处:
- 子组件可以复用------同样的输入框组件可以用在任何需要编辑文本的地方。
- 性能更好------没有自己的状态,不会触发不必要的重新渲染。
- 数据流清晰------所有状态都在父组件,修改路径单一。
六、useEffect:异步加载初始数据
6.1 为什么需要 useEffect?
看 App.tsx 中的代码:
tsx
React.useEffect(() => {
loadUsername(); // 组件挂载后,异步加载用户名
}, []);
老师说:
"
useEffect------副作用。在组件挂载(mounted)后,再去请求接口,拿到数据,响应式更新。满足组件即刻挂载,快(第一步),更新状态(第二步)。"
useEffect 让组件先快速显示出来(第一步),再去后台加载数据(第二步)。 这是 React 性能优化的核心思想------用户不用等数据加载完才看到页面。
[] 作为依赖数组,表示"只在组件挂载时执行一次",不会在每次更新时重复执行。
七、三个版本的进化对比
| 版本 | 子组件状态 | 通信方式 | 父组件复杂度 | 适用场景 |
|---|---|---|---|---|
| V1 | 无状态 | 传事件对象 | 高(要处理 ChangeEvent) | 快速实现,不推荐 |
| V2 | 有私有状态 | 提交时传值 | 低(只接收 string) | 简单表单输入 |
| V3 | 无状态 | 所有事件上报 | 中(状态在父组件) | 需要父组件控制的复杂表单 |
老师说:
"版本的变迁:① 把子组件的 event 对象传给父组件 → 影响父组件的可读性。② 子组件中添加私有状态
editingName,提交父组件时只需要给值。③ 将私有状态提升到父组件,子组件没有状态,性能会更好,就负责展示。UI = fn(props)。"
八、总结:React + TS 的最佳实践
| 概念 | 说明 |
|---|---|
| React.FC | 函数组件类型,泛型参数约束 props |
| interface Props | 定义组件需要的属性和方法 |
| 单向数据流 | 父组件持有状态,通过 Props 传给子组件 |
| 自定义事件 | 子组件通过调用父组件传的函数来"上报" |
| 状态提升 | 多个子组件共享的状态放在共同父组件 |
| 无状态子组件 | UI = fn(props),性能更好,更易复用 |
| React.ChangeEvent | React 合成事件的类型,泛型指定元素类型 |
| useEffect | 副作用,挂载后异步加载数据 |
React + TypeScript 的核心原则:用 interface 约束数据,用单向数据流保证可预测性,用状态提升实现共享。Version 3 是最佳实践------子组件无状态,父组件统一管理,数据流清晰透明。
写在最后
今天这个知识点有点抽象------三个版本的代码看起来都能跑,但可维护性天差地别。以前我写 React 组件,event 对象到处传,从来没考虑过"父组件要不要知道 ChangeEvent 是什么"。现在知道了:父组件应该只关心"值",不应该关心"事件"。
下次面试官问你:"React 父子组件怎么传事件?"
你可以淡定地说:
"最优雅的方式是遵循'子组件无状态,所有操作通过自定义事件上报 '的原则。子组件通过 Props 接收父组件传下来的数据和事件处理函数,内部不管理状态(纯展示组件)。输入变化时子组件调用 onEditingNameUpdated(value) 把值上报,提交时调用 onNameUpdated()。父组件统一管理状态,通过 disabled 等属性控制子组件的行为。这样父组件不需要关心 ChangeEvent 的类型,子组件完全可复用。加上 TypeScript 的 interface Props 约束,数据流清晰、类型安全。如果需要在组件挂载后异步加载数据,用 useEffect 实现,让组件先快速显示再更新数据。"
然后看着面试官满意的表情,心里默念:这波,又稳了。
本文所有代码示例均来自课堂学习资料,真实可运行。