React 组件设计的三个层次:从类型约束到状态归属,再到纯展示
用好 TypeScript,不只是写类型,更是设计组件边界。读这一篇就够了。
写在前面:一个组件,两种写法,天壤之别
假设你要实现一个用户名编辑功能:页面上显示当前用户名,有一个输入框可以编辑,点"Update"按钮提交。
功能完全相同,但代码有两种写法。
V1:父组件掌管一切
tsx
// App.tsx
const App = () => {
const [name, setName] = useState('defaultUserName');
const [editingName, setEditingName] = useState('defaultUserName');
const setUsernameState = () => {
setName(editingName);
};
return (
<>
<HelloComponent userName={editingName} />
<NameEditingComponent
editingName={editingName}
onNameUpdated={setUsernameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === '' || editingName === name}
/>
</>
);
};
tsx
// NameEditingComponent.tsx
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (newName: string) => void;
disabled: boolean;
}
const NameEditingComponent: React.FC<Props> = (props) => {
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
props.onEditingNameUpdated(e.target.value);
};
const onNameSubmit = () => {
props.onNameUpdated();
};
return (
<>
<input value={props.editingName} onChange={onChange} />
<button disabled={props.disabled} onClick={onNameSubmit}>Update</button>
</>
);
};
V2:子组件自己管自己
tsx
// App2.tsx
const App = () => {
const [username, setUserName] = useState('initialName');
return (
<>
<Hello userName={username} />
<NameEditComponent
initialUsername={username}
onNameUpdated={setUserName}
/>
</>
);
};
tsx
// NameEditComponent.tsx
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 (
<>
<input value={editingName} onChange={onChange} />
<button onClick={onNameSubmit}>Update</button>
</>
);
};
功能一模一样,代码天差地别。
V1 的 NameEditingComponent 有 4 个 props,0 个自有 state,是一个纯粹的"传声筒";V2 只有 2 个 props,1 个自有 state,是一个能独立工作的单元。
问题的核心是:状态该归谁管?
这不仅仅是一个关于 React 的问题,更是一个关于 TypeScript 组件设计的问题。因为 TypeScript 的类型约束,在组件设计之初就划定了边界。
在深入探讨状态归属之前,我们需要先理解 TypeScript 在 React 组件中到底约束了什么。为此,我们得从组件最基础的单元------React.FC------说起。
第一层:类型约束 ------ React.FC 与泛型
React.FC 的本质
React.FC 是 FunctionComponent 的简写,它的类型定义简化后如下:
tsx
type FC<P = {}> = FunctionComponent<P>;
interface FunctionComponent<P = {}> {
(props: P): ReactElement<any, any> | null;
}
它揭示了 React 组件的本质:一个函数,输入是 props,输出是 ReactNode。
React.FC 有两个实实在在的作用:
- 调用时自动检查:在 JSX 中使用组件时,传入的 props 不符合类型定义,TS 会立即报错。
- 编码时智能提示 :在组件内部使用
props时,编辑器会给出自动补全。
注意 :React 18 移除了
React.FC中隐式的children属性。如果你需要children,必须显式声明。
泛型:给类型传参
React.FC 本身是不完整的。它通过泛型 <Props> 来接收具体的 props 类型。这就像函数的参数一样:
- 函数
f(x)给值传参。 - 泛型
T<X>给类型传参。
React.FC<Props> 里的 Props 必须是一个对象类型,因为 React 的 props 永远是一个对象。这个泛型参数同时做了两件事:
- 对外约束:调用者必须传入正确的 props。
- 对内约束 :组件内部
props对象的类型是确定的。
Props 的四种定义方式
interface:最常用,适合需要扩展、导出的复杂类型。type别名 :效果与interface几乎等同,但不能重复定义。- 对象字面量:适用于只有一两个 props 的简单场景。
- 直接标注在参数上 :社区主流写法,不依赖
React.FC。
第二层:状态归属 ------ State 该放哪儿?
回到开头的例子。为什么 V1 和 V2 的差异如此巨大?关键在于状态归属(State Ownership) 的原则。
状态归属原则:state 应该放在离使用它最近的组件里。如果一个状态只影响一个组件的行为,它就应该属于那个组件。
V1 的设计缺陷
- 父组件知道太多 :父组件需要计算
disabled逻辑,而这个逻辑本该是子组件自己的事。这让父组件变得臃肿,并和子组件的内部逻辑耦合。 - 子组件没有自主权 :
onNameUpdated是一个无参函数,意味着子组件无法决定提交什么值。它被父组件的接口绑死了,难以复用。 - 接口冗余 :
editingName和onEditingNameUpdated暴露了子组件的内部实现细节,即"编辑过程是逐字变化的"。
V2 的改进
V2 的设计遵循了状态归属原则,将 editingName 下沉到 NameEditComponent 内部。这样:
- 父组件更干净 :它只需要管理最终的用户名
username。 - 子组件更独立:它是一个完整的、可复用的单元,拥有自己的状态和逻辑。
- 接口更精简 :
Props只包含"初始值"和"结果回调",清晰明了。
何时提升 State,何时下沉?
| 场景 | State 放哪 | 例子 |
|---|---|---|
| State 只被一个组件使用 | 下沉到那个组件内部 | editingName → NameEditComponent |
| State 被多个兄弟组件共享 | 提升到最近的公共祖先 | username → App(被 Hello 和 NameEditComponent 共享) |
| State 需要全局持久化 | 提升到 Context / Store | 用户登录态 → AuthContext |
第三层:演进之路 ------ 从事件透传到 UI = fn(props)
开头的 V1 和 V2 代表了两种典型的组件设计模式。但在一个更完整的实战场景中,组件的演进通常有三个阶段。理解这个演进过程,能帮你在不同的场景下做出最合适的设计选择。
阶段一:事件对象透传(原始版)
最直接的写法,子组件充当一个"透明管道",将 DOM 事件(如 React.ChangeEvent)原封不动地抛给父组件。
tsx
// 子组件
interface Props {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
// 父组件
const setUsernameState = (e: React.ChangeEvent<HTMLInputElement>) => {
setUserName(e.target.value);
};
问题 :父组件被迫处理 DOM 细节(event.target.value),React.ChangeEvent 这个复杂类型在父子组件间耦合,导致两者都变得不纯粹。
阶段二:私有状态封装(V2 模式)
子组件拥有自己的 editingName 状态,将 React.ChangeEvent 锁在内部,对外只暴露干净的 string 类型。
tsx
// 子组件
interface Props {
initialUsername: string;
onNameUpdated: (newName: string) => void;
}
优点 :父组件无需关心编辑过程,子组件封装了内部逻辑,成为一个独立的单元。 适用场景:输入过程不需要被外部感知,例如一个"标签编辑器"或"备注输入框"。
阶段三:受控组件(UI = fn(props))
最激进的设计:子组件完全没有任何 useState,所有状态全部提升到父组件。子组件成为一个"纯傀儡",完全由父组件控制。
tsx
// 子组件
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (newName: string) => void;
disabled: boolean;
}
// 父组件
const [editingName, setEditingName] = useState('');
优点 :父组件拥有绝对控制权,可以随时修改、校验、响应子组件的输入。子组件职责单一,只负责展示。 适用场景:需要对输入做复杂逻辑处理,或需要多个组件共享同一份编辑状态时。
版本对照总表
| 版本 | 子组件状态 | 数据流方向 | 子组件职责 |
|---|---|---|---|
| V1: 事件透传 | 无 | 子 -> 父(透传事件对象) | 透明管道 |
| V2: 私有状态 | 有 (useState) |
子 -> 父(提交最终值) | 独立单元 |
| V3: 受控组件 | 无 | 父 <-> 子(双向受控) | 纯展示 (UI = fn(props)) |
TypeScript 与组件设计的"化学反应"
在 TypeScript 的世界里,状态归属和组件演进从来不是孤立的。TS 的类型约束,是驱动你做出正确设计决策的"催化剂"。
1. 类型即边界
当你为一个组件编写 interface Props 时,你实际上是在定义它和外部世界的"合同"。这份合同让你在设计之初就思考:"这个组件需要什么?它要对外暴露什么?"
- 如果你发现
Props里充满了onXxxChange和isXxxDisabled这样的细节,这通常意味着你在让父组件过度参与子组件的内部事务,触犯了状态归属原则。 - 一个干净、自洽的
Props接口,应当只包含"数据输入"和"事件输出",就像 V2 的initialUsername和onNameUpdated那样。
2. 类型即约束
React.FC<P> 的泛型约束,不仅约束了 props 的类型,也约束了组件的形态。它强制组件必须是一个接受对象参数、返回 ReactElement 的函数。
当你尝试在 V2 的 NameEditComponent 里管理自己的 editingName 时,TS 不会阻止你。但当你写出 V1 那种 4 个 props 的接口时,TS 也不会报错。它给你的是"工具"而非"规则" 。但这恰恰是它的力量所在:它让你在编写代码时,每敲下一个类型,都在进行一次设计上的思考。正如第二篇文章所说:"TypeScript 的接口约束让你'设计先行'"。
3. 类型与重构
在 V3 的受控组件模式中,状态的提升和数据的双向流动,对类型的准确性要求更高。父组件管理着 editingName 和 disabled,子组件完全受控。这种模式下,Props 的定义就是父组件和子组件之间的"数据协议"。任何一方的更改,都会导致另一端出现类型错误,从而保证了两端的同步。
总结
从 React.FC 的类型约束,到状态归属的设计原则,再到 UI = fn(props) 的终极形态,这是一个渐进式的设计演化过程。
- 理解类型 :用好
React.FC和泛型,让 TypeScript 帮你划清组件边界。 - 合理归属:遵循"状态归属原则",把 state 放在离使用者最近的地方。
- 演进架构:根据场景选择设计模式------从事件透传,到私有状态封装,再到完全受控的纯展示组件。
下次你写一个新组件时,不妨先停下来想一想:这个 state 属于谁?这个 Props 会不会太多?我是在设计一个独立的"单元",还是一个纯粹的"展示层"?
你的选择,将决定你的代码是变得清晰、可维护,还是变得臃肿、难以理解。
一个行为改变 :下次你准备 useState 的时候,先问自己 3 秒钟------"这个 state,真的需要放在当前组件吗?还是放在子组件更合适?"
一个开放问题:你遇到过最臃肿的组件里有多少个 props?是怎么拆的?欢迎在评论区分享你的重构经验。