从 React.FC 到 UI = fn(props):一堂 TS 组件设计实战课
今天这堂课的核心是回答一个问题:TypeScript 在 React 组件里到底约束了什么,以及为什么约束的方式会随着设计理念从 v1 演进到 v3。
老师先在 readme 笔记里打下了概念框架,然后打开代码文件逐版推进——从最原始的"事件对象透传",到子组件封装私有状态,最后落点到子组件零状态、纯展示的 UI = fn(props)。整个过程不是孤立的知识点堆砌,而是一条连贯的设计演进链。
阶段零:在 readme 建立类型概念框架
在实际动手前,readme 先铺开了几个核心概念。这些不是随手写的——它们构成了后续所有代码实验的理论基础。
React.FC 是什么
React.FC 是 React 内置的函数组件类型标注,它的源代码定义简化后长这样:
type FC<P = {}> = FunctionComponent<P>;
interface FunctionComponent<P = {}> {
(props: P): ReactElement<any, any> | null;
}
泛型 P 就是你组件的 Props 类型。如果什么都不传,P 默认是 {}(空对象)。
注意 React 18 一个重要的变化:之前 React.FC 隐式包含 children 属性,React 18 之后移除了,需要 children 必须显式声明。
React.FC 同时约束输入和输出
readme 里并列写了两个类型:
() => void
() => ReactNode
这两个类型揭示了 React 组件的本质——React.FC 展开后,本质上就是一个函数:输入是 props,输出是 ReactNode(React 能渲染的任何东西)。而 () => void 则是普通回调的类型(事件处理、副作用),不返回 UI。两者对照,就把"组件的本质是返回 UI 的函数"这个事实摆在了台面上。
type 别名:FC 本身就是别名
FC 就是 FunctionComponent 的 type 别名,少打十来个字符而已。type 关键字的核心价值就三个:少写代码、改一处全生效、见名知义。
学到这会想问: React.FC 到底有什么实际作用?是不是所有组件都要写?为什么 App 不接收 props 也写了 React.FC?还有,Props 和 props 是不是同一个东西?
解答:React.FC 就两个实实在在的作用。第一,调用时自动检查——你在 JSX 里写
<Hello username={42} />而 Props 要求string,TS 直接标红,代码还没跑就知道错了。第二,写代码时有智能提示——敲props.编辑器自动弹出所有可用字段,拼错马上标红。但 React.FC 不是必须的。只有接收 props 的组件才需要在
<>里传泛型约束,App 不接收任何 props,写React.FC纯粹是项目中风格统一,去掉完全没问题。社区主流甚至倾向于不写多余的 React.FC。至于 Props(大写 P)和 props(小写 p),这是两个完全不同层面的东西。
Props是一个 TypeScript 接口——编译时存在,编译成 JavaScript 后彻底消失;props是 React 运行时传给组件的真实 JavaScript 对象,浏览器里实实在在地存在。前者是"合同",后者是"快递包裹"。
阶段一:Hello 组件 —— 最简的 Props 传递
readme 建立概念后,老师打开了 Hello.tsx 和 App.tsx,演示最基础的父子组件组合。
Hello.tsx:
import * as React from 'react';
interface Props {
userName: string
}
const Hello: React.FC<Props> = (props) => {
return (
<h2>Hello {props.userName}</h2>
)
}
export default Hello;
App.tsx:
import * as React from 'react';
import Hello from './components/Hello';
const App: React.FC = () => {
const [userName, setUserName] = React.useState('initialName');
return (
<div>
<Hello userName={userName} />
</div>
)
}
export default App;
这里有一个学生容易混淆的点:interface Props { userName: string } 和 React.FC<Props> 各司其职。interface 定义"合同"——规定了别人调用 Hello 时必须传什么、什么类型。React.FC<Props> 把这份合同绑定到组件上,告诉 TypeScript"这个组件对外承诺接收 Props 类型的参数"。
数据流是单向的:
App 持有 userName 状态
│
▼
<Hello userName={userName} />
│
▼
Hello 收到 props = { userName: "initialName" }
│
▼
渲染 <h2>Hello initialName</h2>
这里引出了一个重要认知:React.FC 不仅约束参数的输入,还约束返回值的输出。组件必须返回 ReactElement | null。如果你 return 123 或者 return 'hello',TypeScript 直接报错。JSX(<h2>xxx</h2>)编译后就是一个 ReactElement 对象,包含 type、props、$$typeof 等字段,React 运行时读到这个对象就知道要渲染什么。
阶段二:深入泛型与 Props 的定义方式
Hello 组件讲完后,自然会引出两个深层问题:<> 里到底能放什么,以及除了 interface 还能怎么定义 Props。
泛型:给类型传参数
React.FC 本身是不完整的——它不知道这个组件的 props 长什么样。往 <> 里塞一个类型,它就完整了,就像函数 f(x) 给值传参,泛型 T<X> 给类型传参。同一个模板,填不同的具体类型,得到不同的版本。
React.FC<Props> 里的 Props 必须是对象类型——因为 React 的 props 永远是一个对象。你传 <Hello userName="布" />,React 内部组装成 { userName: "布" } 传给组件。理论上 <> 里可以写 string,语法不报错,但组件没法正常用——JSX 传不了裸值,一定是对象。
四种定义 Props 的方式
// 方式一:interface(最常用,字段多、需继承、需导出时首选)
interface Props {
username: string
age: number
}
// 方式二:type 别名(效果等同 interface,但不能重复定义)
type Props = {
username: string
age: number
}
// 方式三:对象字面量直接写(一两个字段、写了就扔时最方便)
const Hello: React.FC<{ username: string; age: number }> = ...
// 方式四:不用 React.FC,约束挂参数上(社区主流)
const Hello = ({ username }: { username: string }) => ...
interface 和 type 在这个场景下几乎等同,唯一的细微差别是 interface 可以分两处写自动合并,type 不行(重名会报错)。日常基本碰不到这个差异,所以随便用。
接口的名字也可以随意取——Props 只是社区约定俗成的名字,写成 MyComponentProps、HelloParams 都能正常工作。
学到这会想问: 既然泛型
<Props>约束了参数,那是不是意味着用了 React.FC 就不用在函数体里写逻辑了?还有,<> 里面只能放一个接口吗?以及这个 <> 是专门约束后面的参数的?解答:React.FC 只是一个类型标签,贴在组件上告诉 TypeScript"这个函数的 props 长这样"。标签不替你写逻辑,花括号里该写什么还得写。它和内部逻辑完全是两码事,TypeScript 的类型在编译后会被全部擦除,浏览器里根本没有它存在过。
至于
<>,泛型参数确实只接受一个类型。但如果你需要多个约束可以组合——用&交叉类型(React.FC<Base & Extra>)或用extends合并多个接口。不管怎么组合,塞进<>里的始终只是一个整体类型。这个
<>里的类型同时做了两件事:对外约束调用者必须传什么(<Hello />缺少 username 直接标红),对内约束 props 能点出什么属性(敲props.时弹出正确提示)。一个类型,双向约束。
阶段三:React 合成事件 —— 当 Props 里出现函数
readme 里有一条关键记录:interface 自定义事件。这里"自定义事件"指的不是 DOM 事件,而是 Props 里的函数类型字段。
打开 NameEditComponent 的第一版(现在被注释掉了),Props 里出现了函数:
interface Props {
username: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
onChange 的类型不是 string、不是 number,而是一个函数。React 组件间的通信,数据往下流、回调往上流——数据走的是 string 通道,回调走的是函数通道。但从 TypeScript 的角度看,它们在 Props 接口里的地位完全平等:都是一种类型约束。
React.ChangeEvent 拆解
e: React.ChangeEvent<HTMLInputElement>
│ └──────┬──────┘ └────┬────┘
│ React 合成事件 泛型:事件发生在哪个元素上
│
参数名
这个类型声明做了两件事。第一,React.ChangeEvent 告诉 TypeScript 这是 React 包装过的合成事件——React 把浏览器的原生事件统一包了一层,抹平了 Chrome、Firefox、Safari 之间的差异,提供一致的 API。第二,<HTMLInputElement> 泛型标注了事件发生在 <input> 元素上,所以 e.target.value 的类型被自动推导为 string,可以直接用,不用 as any。
不同事件类型有固定的内置名称:
| React 事件类型 | 对应行为 |
|---|---|
React.ChangeEvent | <input> <select> <textarea> 值改变 |
React.MouseEvent | 点击、双击、鼠标移入移出 |
React.KeyboardEvent | 按键按下、抬起 |
React.FormEvent | 表单提交 |
不同元素的泛型也不同:HTMLInputElement、HTMLSelectElement、HTMLTextAreaElement、HTMLButtonElement。TS 根据泛型知道 e.target 是什么元素,从而决定有哪些属性可用。比如 <input> 的 e.target 有 .value 也有 .checked(checkbox 场景),两个都会提示。
学到这会想问: React.ChangeEvent 是固定的吗?如果是 checkbox 怎么办?还有,Props 里这个函数类型的声明,里面的参数名是干什么的?为什么要写
onNameUpdate而不是直接复用useState的setUserName?解答:
React.ChangeEvent是 React 内置的固定类型,不用自己定义,直接拿来用。React 提供了一整套事件类型——React.MouseEvent、React.KeyboardEvent、React.FormEvent等等,用的时候选对应类型套上元素泛型就行,记不住就输入React.等编辑器提示。Checkbox 的场景仍然用React.ChangeEvent<HTMLInputElement>,因为 checkbox 也是<input>标签,只是取值从e.target.value变成e.target.checked。类型声明里的参数名(如
newEditingName)本身不影响类型检查,TypeScript 只看类型不看名字。它的作用是给读代码的人看——(newEditingName: string) => void比(string) => void更能表达意图,同时 IDE 也会用这个参数名做智能提示。至于
onNameUpdate和setUserName的关系,它们本质上是同一个函数,只是换了个名字。父组件把setUserName传给<NameEditComponent onNameUpdate={setUserName} />,子组件通过props.onNameUpdate拿到它。子组件只知道这个函数叫onNameUpdate,不知道也不关心它在父组件里原名叫什么——就像一个电话号码存进别人手机,显示名叫"老张"但打的还是同一个号。
阶段四:三版组件演进 —— 从事件透传到纯展示
readme 里有一段关键的版本变迁记录,这也是今天最深度的教学内容:
版本的变迁:
1. 把子组件的 event 对象传给父组件,导致两边都要 React.ChangeEvent
2. 在子组件之中添加私有状态 editingName,onChange 自己修改,提交时只给值
3. 将私有状态提升到父组件,通过 props 传过来,子组件没有状态
UI = fn(props),子组件职责单一,只负责展示
这三个版本不是随手改的——它们代表了对"状态应该放在哪里"这个问题越来越成熟的理解。
版本 1:事件对象透传(原始版)
子组件代码(已被注释):
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>
)
}
父组件代码:
const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserName(event.target.value);
}
<NameEditComponent username={username} onChange={setUsernameState} />
版本 1 的核心特征:子组件是一个透明管子。用户在输入框敲字,React 生成合成事件对象,子组件不做任何处理,直接把事件对象整个丢给父组件。父组件拿到事件对象后,得自己从 event.target.value 里把字符串抠出来。
问题很明显:React.ChangeEvent<HTMLInputElement> 这个复杂的类型同时出现在子组件的 Props 和父组件的回调参数里,两处耦合。父组件本来只管业务逻辑("名字变了,存新名字"),现在被迫处理 DOM 细节("事件对象的 target 属性上有一个 value 字段")。
版本 2:子组件私有状态(App1 + NameEditComponent1)
子组件 NameEditComponent1:
interface Props {
initialUsername: string
onNameUpdated: (newName: string) => void // 注意:管子变细了,只传 string
}
const NameEditComponent1: 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 (
<div>
<label>Update name:</label>
<input value={editingName} onChange={onChange} />
<button onClick={onNameSubmit}>Update</button>
</div>
)
}
父组件 App1:
const [username, setUserName] = React.useState('initialName');
<NameEditComponent1
initialUsername={username}
onNameUpdated={setUserName}
/>
版本 2 做了一个关键设计决策:子组件拥有自己的私有状态 editingName。它用 useState(props.initialUsername) 拿一次初始值,之后 editingName 就和 props.initialUsername 彻底脱钩——props 变了,editingName 不会自动更新。
这个设计的巧妙之处在于保护了用户的临时编辑。假设用户在输入框里打了半截字,如果外部 props 突然更新导致输入框内容被覆盖,用户编辑了一半的内容就会丢失。useState 只在组件出生时读一次初始值,之后完全由子组件自己管理,直到用户主动点按钮,才通过 props.onNameUpdated(editingName) 把最终值提交给父组件。
管子变细了:onNameUpdated: (newName: string) => void。React.ChangeEvent 被锁在了子组件内部,父组件收到的是一个干净的 string,它的签名恰好和 useState 的 setter((value: string) => void)完全匹配,所以可以直接传 onNameUpdated={setUserName},不需要包一层中间函数。
版本 3:受控组件 + useEffect(App2 + NameEditComponent2)
子组件 NameEditComponent2:
interface Props {
initialUsername: string
editingName: string // 编辑值也从父组件来
onNameUpdated: () => void // 确认——不传值了
onEditingNameUpdated: (newName: string) => 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(); // 只发通知,不带值,因为父组件自己就有 editingName
};
return (
<>
<label>Update name</label>
<input value={editingName} onChange={onChange} />
<button disabled={disabled} onClick={onNameSubmit}>Update</button>
</>
)
}
父组件 App2:
const [name, setName] = React.useState<string>('defaultUserName');
const [editingName, setEditingName] = React.useState<string>('defaultUserName');
const setUsernameState = () => {
setName(editingName); // editingName 就在自己身上,直接拿
};
// useEffect:组件先渲染,再异步拉数据
const loadUserName = () => {
setTimeout(() => {
setName('name from async call');
setEditingName('name from async call');
}, 2000);
};
React.useEffect(() => {
loadUserName();
}, []); // 空数组 = 只在组件挂载后执行一次
<NameEditComponent2
initialUsername={name}
editingName={editingName}
onNameUpdated={setUsernameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === "" || editingName === name}
/>
版本 3 是最激进的:子组件完全没有任何 useState,所有状态全部提升到父组件。
这带来了一个数据闭环。用户每次按键,onEditingNameUpdated(e.target.value) 直接把值传给父组件的 setEditingName,父组件更新状态后重渲染,再把新的 editingName 通过 props 灌回子组件:
用户按键
→ onChange 触发
→ onEditingNameUpdated(e.target.value) // 从子流向父
→ 父 setEditingName(...) // 父更新状态
→ App2 重渲染
→ <NameEditComponent2 editingName={...} /> // 从父流回子
→ input.value 更新 // 用户看到最新文字
点按钮时,onNameUpdated() 连值都不传了——因为 editingName 本身就在父组件手里,父组件的 setUsernameState 直接从自己身上拿就行。这和版本 2 形成鲜明对比:版本 2 是子组件拿着草稿说"给你值",版本 3 是父组件说"不用给我,我自己有"。
App2 还引入了 useEffect,演示真实项目里数据是从后端异步加载的这个事实。useEffect 的第二个参数是空数组 [],表示只在组件挂载后执行一次。这体现了 React 的核心设计哲学:组件的第一要素是赶快显示出来,让用户看到。页面先渲染初始值,2 秒后数据回来,自动更新 UI。用户感知是"快"的。
disabled 是版本 3 的一个典型体现——连按钮能不能点都由父组件算好:
disabled={editingName === "" || editingName === name}
// ────────────────── ──────────────────
// 输入框为空,不让你存 没改动,也不需要存
子组件不判断、不算逻辑,它只知道 props 里来了一个 disabled,true 就灰掉,false 就亮着。以后要改禁用规则,只改 App2 这一行,子组件不用动。
三个版本对照总表
| 版本 1 | 版本 2 | 版本 3 | |
|---|---|---|---|
| 子组件状态 | 无 | 有 useState | 无 |
| 编辑状态存哪 | 父(但父只存展示值) | 子组件自己 | 父(存展示值+编辑值) |
| 每次按键 | 父组件重渲染 | 子组件自己重渲染 | 父组件重渲染,再灌回子组件 |
| 子传父传什么 | 事件对象 | 最终 string | 每次按键传 string |
| 子组件职责 | 透传 | 封装内部逻辑 | 纯展示 |
收束:回到 readme,理解 UI = fn(props)
readme 在版本变迁记录的最后写了一行字:
UI = fn(props),子组件职责非常单一,就是负责展示,不负责修改状态。
这不是一句口号,而是三个版本演进之后的必然结论。版本 3 的子组件就是一个纯函数:输入 props,输出 UI。输入什么就显示什么,自己不存状态也不会做决策;用户做了什么,立刻通知外面,让外面决定。disabled 这种逻辑也由父组件算好,子组件只管"你让我灰我就灰"。
这并不是说版本 3 永远比版本 2 好。版本 2 把编辑状态封装在子组件内部,父组件更简洁,适合输入过程不需要被外部感知的场景。版本 3 把所有状态提升到父组件,子组件成为纯傀儡,适合需要对外部状态变化做细粒度控制的场景。选哪个,取决于"状态的控制权应该归谁"。
今日知识清单
类型系统基础:
React.FC<P>同时约束了组件的输入(Props)和输出(必须返回ReactElement | null)FC是FunctionComponent的type别名- 泛型
<>给类型传参,和函数传参对等
Props 接口:
interface和type都可以声明 Props 类型,效果几乎等同- 四种定义方式:
interface、type、对象字面量、参数直接标注 - Props 里既有数据(
string、number),也有函数(() => void、(e: XxxEvent) => void) Props(接口,编译时存在)和props(运行时对象)是两个层面的东西
React 合成事件:
- React 把浏览器原生事件包成合成事件,跨浏览器统一 API
React.ChangeEvent<HTMLInputElement>中,ChangeEvent是事件类型,<HTMLInputElement>是泛型标注事件在哪发生e.target.value、e.target.checked的类型由泛型自动推导- 事件类型是 React 内置的固定类型,不用自己定义
useEffect:
- 组件先快速渲染,副作用在挂载后触发,让用户感知"快"
- 第二个参数
[]表示只在挂载后执行一次
三版组件演进:
- v1 事件对象透传:子组件是透明管子,父组件被迫处理 DOM 细节
- v2 私有状态封装:
useState只读一次初始值,编辑过程对外部隔离,提交时才通知父 - v3 受控组件:子组件零状态,
UI = fn(props),一切由父组件管理 disabled作为受控 prop,体现了"父组件算逻辑,子组件只展示"的设计原则
代码演进路线: NameEditComponent 经历了 v1(事件透传)→ v2(添加私有 useState + 按钮)→ v3(移除 useState,改为 editingName、onEditingNameUpdated、disabled 三个受控 prop),每一步都在把状态管理的职责往更合理的位置移动。
后续延伸方向: 版本 3 的受控模式进一步可以引出 React 状态管理库(Context / Redux / Zustand)的讨论——当多个组件需要共享同一份编辑状态时,状态应该放在哪里。useEffect 的依赖数组和清理函数也是下一个值得深入的话题。
#前端 #TypeScript #React #学习笔记