React 中的 TypeScript 组件与 Hook 类型
解释 React 组件 props、children、事件、状态、Reducer、Context 和自定义 Hook 的 TypeScript 类型设计边界。
[!info] related notes
- TypeScript 路线:TypeScript 学习路线
- React 路线:React 学习路线
- 包装组件:React 类型化包装组件与多态组件
- 状态机:TypeScript 可辨识联合与穷尽检查
React 中的 TypeScript 组件与 Hook 类型
[!abstract] 学习目标 目标不是给每个局部变量补类型,而是让组件输入、事件、状态转移和 Hook 返回值形成稳定契约,同时尽量保留 React 与 TypeScript 的上下文推断。
从 JSX 调用反推 Props 契约
组件类型首先约束“调用者可以传什么”,而不是描述组件内部的所有实现细节。
interface StatusCardProps {
title: string;
status: 'idle' | 'running' | 'completed';
onDismiss?: () => void;
children?: React.ReactNode;
}
function StatusCard({ title, status, onDismiss, children }: StatusCardProps) {
// 返回类型通常可以由 JSX 推断
}
- 必填 props 表达组件成立所需的不变量。
- 可选 props 表达组件确实能在缺少该能力时工作。
- 不要为了统一而强制使用
React.FC;普通函数组件的参数契约更直接。
children 不是固定的“子组件类型”
React.ReactNode:可渲染内容的宽类型,包含元素、字符串、数字、空值等。React.ReactElement:已经构造出的 React 元素,范围更窄。PropsWithChildren<P>:在已有 props 上补可选children。
TypeScript 不能可靠约束“只能传某一种 React 组件作为 children”。如果业务必须限制结构,优先设计具名 props 或数据配置,而不是假设 JSX 元素类型能证明组件身份。
事件类型来自 JSX 属性
内联回调通常可以依赖上下文推断:
<input onChange={(event) => setValue(event.currentTarget.value)} />
抽成独立函数后再显式标注:
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setValue(event.currentTarget.value);
}
currentTarget 是绑定处理器的元素,类型通常最稳定;target 可能是事件冒泡路径中的更深层节点。浏览器原生 MouseEvent 与 React.MouseEvent 也不处于同一抽象层。
state:先推断,初始值不足时再标注
const [enabled, setEnabled] = useState(false); // boolean
const [selection, setSelection] = useState<string | null>(null);
当一个状态由多个互斥阶段组成时,用对象联合代替多组可能互相矛盾的布尔值:
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Result }
| { status: 'error'; error: Error };
这样 status === 'success' 不只是运行时判断,也会让 TypeScript 知道 data 存在。
Reducer 与 Context 的类型连接
Action 联合
-> reducer(state, action): State
-> useReducer 推断 dispatch
-> Context 暴露 state 或 actions
-> 自定义 Hook 收敛空值检查
Reducer 的 Action 应使用共同的 type 判别字段,每个分支只携带该动作需要的数据。Context 没有合法默认业务值时,用 T | null 创建并在消费 Hook 中检查 Provider。完整机制见 React useReducer 与 Context 与状态管理边界。
包装原生元素和第三方组件
包装组件通常应继承被包装对象已有的 props,而不是手工重写 disabled、onClick、aria-* 等属性:
- 原生元素:
React.ComponentProps<'button'>、InputHTMLAttributes<HTMLInputElement>。 - 第三方组件:
React.ComponentProps<typeof ThirdPartyComponent>或库公开的Props。 - 需要透传 ref:根据 React 版本与项目约定选择 ref prop 或
forwardRef。
组件变体、冲突字段和多态 as 的设计见 React 类型化包装组件与多态组件。
自定义 Hook 的公开边界
Hook 内部局部变量继续依赖推断;导出的返回值应让调用者看到稳定能力:
- 多个具名能力优先返回对象。
- 固定位置、数量短小且语义明确时才返回元组。
- 不要为隐藏实现而返回过宽的
Record<string, unknown>。 - Hook 的类型安全不能代替 effect 清理、请求取消和运行时数据校验。
BodySense 实例地图
| 知识点 | 生产源码 | 阅读重点 |
|---|---|---|
ReactNode 与 Provider props | ActiveTurnContext.tsx | children 为什么是可渲染内容 |
| Action 联合 | ActiveTurnContext.tsx | TurnAction 如何限制 dispatch |
| 泛型事件契约 | packages/contracts/src/stream-events.ts | StreamEventBase 如何关联 type 与 payload |
| 第三方 props 与变体 | components/ui/Button.tsx | ButtonPrimitive.Props 与 VariantProps 如何合并 |
| 原生 input props 与 ref | components/ui/Input.tsx | InputHTMLAttributes 与 forwardRef 的类型连接 |
| 服务端状态 Hook | useConsultationSessionQuery.ts | conversationId、enabled 与 queryFn 的空值边界 |
常见误区
- 把所有 props 都写成可选,把组件不变量推给每个调用者。
- 给每个 Hook 都显式传泛型,反而掩盖错误或增加重复。
- 用
as修复第三方组件 props 冲突,而不是重新检查包装边界。 - 认为 TypeScript 能验证网络响应或限制 children 的真实组件身份。
- 把服务端缓存状态、流式临时状态和表单草稿建模成同一种状态。
从阅读到独立实现
- 预测:
Button.tsx删除VariantProps<typeof buttonVariants>后,哪些调用会失去约束? - 模仿:参照
Input.tsx为一个原生textarea包装组件设计 props 与 ref。 - 重建:不看源码,从需求实现一个支持原生 button props、
isLoading和两个视觉变体的按钮。 - 迁移:为
useConsultationSessionQuery设计一个不使用非空断言的调用边界,并解释为何 Suspense 版本不能直接照搬enabled。