从"传事件"到"只传值":React + TypeScript 组件 Props 设计的两次进化
摘要:本文通过一个简单的姓名编辑组件,展示 React + TypeScript 中 Props 类型定义的三次迭代------从暴露事件对象到封装内部状态,再到状态提升与职责分离,揭示组件设计如何逐步走向清晰与可维护。
📑 目录
- 类型化的第一步:React.FC 与 Props 接口
- interface 与 type:为什么选 interface?
- 版本一:把整个事件对象传给父组件
- 版本二:子组件内部管理私有状态
- 版本三:状态提升 + 职责分离
- 演进背后的设计原则
- 互动讨论
类型化的第一步:React.FC 与 Props 接口
在 React + TypeScript 项目中,组件不仅仅是"返回 JSX 的函数",它还需要满足类型约束。React.FC(Function Component 的缩写)就是用来约束组件函数必须符合 React 函数组件规范的:
typescript
typescript
import React from "react";
interface Props {
username: string
}
const Hello: React.FC<Props> = (props) => {
return <h2>Hello {props.username}</h2>;
};
React.FC 做了两件事:第一,它约束了组件的返回值必须是 ReactNode(即 JSX);第二,它通过泛型 <Props> 约束了 props 的类型。如果父组件没有传递 username,或者传递的值不是字符串,TypeScript 都会报错。
这里隐含了一个重要机制:React.FC<P = {}> = FunctionComponent<P> 是 React 源码中的类型定义。FunctionComponent 是函数组件类的声明,返回值必须是 ReactElement。type 是 TypeScript 的类型别名,把 FC 作为 FunctionComponent 的简写。P = {} 是默认值------如果没有传泛型参数,就表示没有 props 约束。
interface 与 type:为什么选 interface?
在 TypeScript 中,声明 Props 类型有两种方式:interface 和 type。它们在功能上几乎等价,但 React 社区更倾向于使用 interface:
typescript
go
// ✅ 使用 interface
interface Props {
username: string
}
// 也可以使用 type
type Props = {
username: string
}
为什么选 interface?原因有二:
第一,语义匹配。 组件需要"满足"props 中的属性和方法,这恰好对应着接口(Interface)的概念------父组件必须传递这些属性,且数据类型必须符合声明。
第二,扩展性更好。 interface 支持同名声明自动合并扩展,而 type 不支持:
typescript
typescript
// interface 可以同名扩展
interface Props {
username: string
}
interface Props {
age?: number
}
// 最终 Props = { username: string; age?: number }
// type 不支持同名声明
type Props = { username: string }
type Props = { age?: number } // ❌ 报错:重复声明
type 更常被用来给基础类型取别名,比如 type UserId = string,目的是为类型创造语义,而非描述一个数据结构。因此在 Props 声明中,interface 是更自然的选择。
版本一:把整个事件对象传给父组件
第一个版本的 NameEditComponent 展示了"过度暴露"的设计:
typescript
typescript
interface Props {
username: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
return (
<>
<label>Update name</label>
<input value={props.username} onChange={props.onChange} />
</>
);
};
父组件中使用时,需要处理 React.ChangeEvent<HTMLInputElement>:
typescript
ini
// 父组件中
const setUsernameState = (e: React.ChangeEvent<HTMLInputElement>) => {
setUsername(e.target.value);
};
<NameEditComponent username={username} onChange={setUsernameState} />
这种设计的问题:
- 父组件需要处理 React 合成事件,必须理解
React.ChangeEvent<HTMLInputElement>的用法 - 子组件把自己的内部细节(事件对象)暴露给了父组件
- 父组件被迫"太懂"子组件的实现方式,违反了封装原则
- 两处代码都要做同样的类型约束,代码冗余
这种写法虽然符合单向数据流------父组件持有状态,通过 props 传递给子组件------但父组件的可读性和简洁性受到了影响。父组件的本来使命是"持有和修改数据状态,给子组件们共享",如果它还要处理 input 的事件对象,职责就不纯粹了。
版本二:子组件内部管理私有状态
第二次迭代把复杂度收回到子组件内部:
typescript
typescript
interface Props {
initialUserName: string;
OnNameUpdated: (newName: string) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
const [editingName, setEditingName] = useState(props.initialUserName);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(e.target.value);
};
const onNameSubmit = () => {
props.OnNameUpdated(editingName);
};
return (
<div>
<label>Update name:</label>
<input value={editingName} onChange={onChange} />
<button onClick={onNameSubmit}>Change</button>
</div>
);
};
父组件变得非常简洁:
typescript
ini
<NameEditComponent
initialUserName={username}
OnNameUpdated={setUsername}
/>
这次改进的核心变化:
- 子组件用
useState创建了自己的私有状态editingName React.ChangeEvent被完全封装在子组件内部,父组件不再需要知道- 父组件的 props 从"传事件对象"变成了"传初始值 + 传回调"
- 父组件只需要处理字符串,不需要处理事件对象
这种写法让父组件的逻辑处理变得非常简洁。子组件自己打理输入框的 onChange,只在用户点击"Change"按钮时才把最终值提交给父组件。组件的私有状态只服务于组件内部,不需要与父组件共享。
版本三:状态提升 + 职责分离
第三次迭代进一步优化,把状态"提升"回父组件,但保留了事件处理的封装:
typescript
typescript
interface Props {
editingName: string;
onEditingNameUpdated: (newEditingName: string) => void;
onNameUpdated: () => void;
disabled: boolean;
}
const NameEditComponent2: React.FC<Props> = (props) => {
const { editingName, onEditingNameUpdated, onNameUpdated, disabled } = props;
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onEditingNameUpdated(e.target.value);
};
const onNameSubmit = () => {
onNameUpdated();
};
return (
<div>
<label>Update name:</label>
<input value={editingName} onChange={onChange} />
<button onClick={onNameSubmit} disabled={disabled}>Change</button>
</div>
);
};
父组件中:
typescript
ini
const [name, setName] = useState<string>('defaultName');
const [editingName, setEditingName] = useState<string>('defaultName');
const setUsernameState = () => {
setName(editingName);
};
<NameEditComponent2
editingName={editingName}
onEditingNameUpdated={setEditingName}
onNameUpdated={setUsernameState}
disabled={editingName === "" || editingName === name}
/>
这次迭代的关键变化:
- 子组件不再持有私有状态
editingName,所有数据都来自父组件 - 子组件仍然封装了
React.ChangeEvent的处理 - 子组件职责更单一:只负责展示和交互,不管理任何数据状态
- 父组件管理了
name(已确认的名称)和editingName(正在编辑的名称)两个状态 disabled逻辑由父组件计算后传入,子组件完全不需要知道"什么时候禁用按钮"
这个版本体现了 React 的一个核心公式:UI = fn(props) 。子组件职责非常单一,只负责显示,数据状态根本不管,只需要处理渲染相关的逻辑。无论从性能层面(子组件不持有状态,不会触发自己的重新渲染)还是代码规范性层面,都是最优解。
演进背后的设计原则
三个版本的递进,体现了几个重要的设计思想:
1. 封装变化,隐藏细节
版本一的 React.ChangeEvent 是"实现细节",本不该暴露给父组件。版本二和版本三将其封装在子组件内部,父组件只需要处理字符串。好的组件设计,应该让调用者"少操心、少写代码"。
2. 状态管理的职责归属
| 版本 | 状态归属 | 特点 |
|---|---|---|
| 版本一 | 父组件管理,但子组件暴露事件对象 | 父组件承担了过多细节 |
| 版本二 | 子组件管理私有状态 | 父组件简洁,但状态分散 |
| 版本三 | 父组件统一管理状态 | 状态单一来源,子组件纯净 |
版本三的"状态提升"是 React 官方推荐的高级模式------当多个组件需要共享同一份数据时,把状态提升到它们的共同父组件中。虽然这增加了父组件的代码量,但换来了数据流的清晰可控。
3. 单一职责原则
版本三的子组件真正做到了"只负责一件事":它接收数据(props),展示界面,响应用户操作后通过回调通知父组件。它不管数据从哪里来、怎么变化、什么时候该禁用------这些都由父组件决定。子组件越"笨",越容易被复用和维护。
4. 类型约束的合理位置
版本一的类型约束分散在父子两端;版本二和版本三把复杂的泛型约束(React.ChangeEvent<HTMLInputElement>)收敛到子组件内部。类型约束应该放在"需要约束的地方",而不是"所有地方"。
互动讨论
💬 为什么版本三要把 editingName 提升到父组件,而不是留在子组件?
因为父组件需要根据 editingName 和 name 的对比来决定按钮是否禁用(disabled={editingName === "" || editingName === name})。如果 editingName 留在子组件,父组件就无法获取这个信息来计算禁用状态。状态提升是为了"让需要这份数据的组件都能访问到它"。
💬 React.FC 中的泛型 <Props> 是什么?
泛型是对函数内部的数据类型进行约束。React.FC<Props> 中的 Props 传给 React 的类型定义,要求父组件传入的 props 必须符合 Props 接口的约束。如果不传泛型参数(React.FC),就表示没有 props 约束。
💬 type 和 interface 到底怎么选?
Props 声明用 interface,因为它语义匹配(组件"满足"接口),扩展性更好(同名声明合并)。type 适合给基础类型起别名(如 type UserId = string)或定义联合类型(如 type Status = 'pending' | 'success')。两者各司其职。
💬 版本二的"私有状态"和版本三的"父组件状态"哪个性能更好?
版本三的性能更好。版本二的子组件持有私有状态,每次输入都会触发子组件自身的重新渲染;版本三的状态在父组件中,输入触发父组件重新渲染,父组件重新渲染时会同时更新所有子组件。虽然两者都是响应式更新,但版本三的数据流更可控、状态单一来源,调试更容易。
💬 如果子组件很复杂,所有状态都放父组件会不会让父组件变得臃肿?
如果父组件过于臃肿,说明组件拆分的粒度不够细。可以考虑使用 useReducer 或状态管理库(如 Zustand、Redux)来管理复杂状态。关键原则是:数据流要清晰,状态要可预测,而不是单纯追求"状态放哪里"的正确性。