摘要:React加上TypeScript后,组件怎么写类型?
React.FC泛型如何约束props?React.ChangeEvent怎么锁定事件来源?本文从Hello组件的基础类型声明出发,用同一组件的三个版本演进——事件对象上抛、子组件私有状态、状态提升父组件纯展示——讲清TS类型边界与组件职责划分。
目录
- React.FC 泛型:给组件 props 加锁
- React.ChangeEvent:锁定事件来源
- 三个版本演进:从事件上抛到纯展示
- useEffect 生命周期与 localStorage 持久化
- 总结
一、React.FC 泛型:给组件 props 加锁
JavaScript 写 React 组件时,props 是一个"黑盒"——父组件传了什么、传没传对,只能靠 PropTypes 在运行时检查。TypeScript 的做法是在编译阶段就把 props 锁死。
interface 定义 props 契约
interface Props {
userName: string;
}
interface 是一份"接口契约"——任何使用这个组件的父组件必须提供 userName 这个 string 类型的 prop。
React.FC 泛型
const HelloComponent: React.FC<Props> = (props) => {
return <h2>Hello {props.userName}</h2>;
};
React.FC<Props> 是泛型语法——把 Props 作为类型参数传给 React.FC。React 源码中 FC 的定义本质是 type FC<P = {}> = FunctionComponent<P>,P 就是 props 的类型参数。如果不传 <Props>,P 默认是空对象 {}——组件不接受任何 props。
type 还是 interface?
| 方式 | 写法 | 适用场景 |
|---|---|---|
interface | interface Props { userName: string } | 对象结构的类型声明,组件 props 首选 |
type | type Props = { userName: string } | 联合类型、交叉类型等复杂场景 |
两者都能用于组件 props,但 interface 语义上更接近"定义一份对象需要满足的属性和方法",在组件场景中更直观。
二、React.ChangeEvent:锁定事件来源
TypeScript 下处理 input 的 onChange 事件,不能像 JS 那样写 (e) => setValue(e.target.value)——e 的类型不明确,e.target 是不是 input 元素编译器不知道。
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(e.target.value);
};
| 类型参数 | 含义 | 不传会怎样 |
|---|---|---|
HTMLInputElement | 该事件发生在 input 元素上 | e.target 是通用 EventTarget,没有 value 属性 |
这是 TS 在 React 中最大的价值——把"运行时才知道类型"的事件对象,变成"编译时就知道"的类型约束。Refactor 时改了元素类型(input 改 select),编译器立刻标出所有相关代码需要修改的地方。
三、三个版本演进:从事件上抛到纯展示
同一个 NameEdit 组件经历了三个版本迭代,每一次迭代都是一次"类型边界放在哪"的决策。
版本一:事件对象上抛
// 父组件
const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserName(event.target.value);
};
// 子组件
interface Props {
username: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
return <input value={props.username} onChange={props.onChange} />;
};
父组件和子组件两边都要 import React.ChangeEvent<HTMLInputElement>。类型声明污染了父子双方的代码,父组件被迫关心"这个事件是从哪个元素来的"——而这本是子组件的内部细节。
版本二:子组件私有状态
interface Props {
initialUserName: string;
onNameUpdate: (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.onNameUpdate(editingName); // 只上报最终值
};
return (
<>
<input value={editingName} onChange={onChange} />
<button onClick={onNameSubmit}>Change</button>
</>
);
};
React.ChangeEvent<HTMLInputElement> 被关在子组件内部。父组件只需要知道 onNameUpdate: (newName: string) => void——"子组件会给我一个 string,我不需要关心它是从 input 还是 select 来的"。
版本三:状态提升,子组件纯展示
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (newEditingName: string) => void;
disabled: boolean;
}
const NameEditComponent: React.FC<Props> = (props) => {
const { editingName, onEditingNameUpdated, onNameUpdated, disabled } = props;
return (
<>
<input value={editingName}
onChange={(e) => onEditingNameUpdated(e.target.value)} />
<button disabled={disabled} onClick={onNameUpdated}>Change</button>
</>
);
};
子组件完全没有自己的 state——所有数据通过 props 流入,所有修改通过回调上报。组件变成了纯函数 UI = fn(props),职责单一到极致:只负责显示,不负责管理。
| 版本 | state 在哪 | TS 类型复杂度 | 子组件职责 |
|---|---|---|---|
| 一 | 父组件 | 高(父子双方都需声明 ChangeEvent) | 被动渲染 |
| 二 | 子组件内部 | 中(ChangeEvent 关在子组件) | 管理输入 + 渲染 |
| 三 | 父组件 | 最低(子组件只接收基础类型 props) | 纯展示 |
四、useEffect 生命周期与 localStorage 持久化
useEffect 是 React 的"副作用钩子"——组件挂载后、更新后、卸载前这四种时机,通过第二个参数(依赖数组)来控制。
// 只在挂载后执行一次
useEffect(() => {
loadUsername(); // 模拟异步请求
}, []);
// todos 每次变化都存到 localStorage
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
// 每次重渲染都执行
useEffect(() => {
console.log('每次更新都执行');
});
// 有清理工作(卸载前)
useEffect(() => {
const timer = setInterval(...);
return () => clearInterval(timer); // 组件卸载时清理
}, []);
| 依赖数组 | 触发时机 | 典型场景 |
|---|---|---|
[] | 只在挂载后一次 | 初始化请求、订阅 |
[todos] | 挂载后 + todos 变化时 | 数据持久化、同步 |
| 不传 | 每次渲染后 | 调试日志 |
| return 清理函数 | 组件卸载前 | 清除定时器、取消订阅 |
副作用之所以叫"副作用"——因为组件的核心职责是渲染 UI,localStorage 写入、API 请求这些都是"附带做的事"。
useEffect把这些附加操作从渲染逻辑中分离出来,让组件函数保持纯粹。
五、总结
React.FC<Props>是组件的类型锁:interface定义 props 契约,泛型让编译器在调用侧就能发现缺失或类型错误。React.ChangeEvent<HTMLInputElement>精确锁定事件来源:告诉 TS 这个事件是从哪个元素触发的,e.target.value才能通过类型检查。- 版本演进的核心是"TS 类型边界放在哪":从事件对象上抛(版本一)到子组件内部消化(版本二)到状态提升纯展示(版本三),TS 复杂度逐步降低,组件职责越来越单一。
useEffect四种生命周期:[]只挂载、[dep]跟变化、不传每次跑、return 做清理。UI = fn(props)是终极目标:子组件没有自己的 state 时,性能最好、测试最简单、TS 类型最干净。
—— 预编译不是拖慢开发,是在你写错之前告诉你会错在哪里。