React 基础体系 · 第 13/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 与 TypeScript:Props、事件、泛型组件、Ref 和联合类型
React 组件的类型安全,核心不是“给组件加上几个类型注解”,而是明确组件 API 的输入、输出和生命周期边界:
Props描述父组件可以传入什么;- 事件类型描述回调何时发生、携带什么数据;
- 泛型组件让组件的数据类型由调用者决定;
Ref暴露 DOM 节点或受控的命令式能力;- 联合类型把互斥状态、变体和事件建模为可检查的状态空间。
这些类型最终都会参与 JSX 调用检查,但 TypeScript 只在编译阶段工作。运行时仍然可能收到 JavaScript、网络响应或服务端传来的不符合预期的数据,因此“类型声明正确”不等于“外部数据已经验证”。
一、先建立组件类型模型
一个 React 函数组件可以抽象为:
其中:
- 是 Props 类型,即调用者必须提供的数据;
- 是组件返回的 React 可渲染结果;
- 组件本身通常不直接返回 HTML 字符串,而是返回 React 元素、字符串、数字、数组、Fragment 或
null等 React 节点。
例如:
type GreetingProps = {
name: string;
};
function Greeting({ name }: GreetingProps) {
return <h1>Hello, {name}</h1>;
}
调用时:
<Greeting name="Ada" />; // 正确
<Greeting />; // 错误:缺少 name
<Greeting name={42} />; // 错误:number 不能赋给 string
这个检查来自 JSX 对函数调用的近似建模:传给 <Greeting /> 的属性必须满足 GreetingProps。
1. Props 是输入契约,不是组件内部状态
type CounterProps = {
value: number;
onChange: (nextValue: number) => void;
};
function Counter({ value, onChange }: CounterProps) {
return (
<button onClick={() => onChange(value + 1)}>
{value}
</button>
);
}
这里有两个不同方向的数据流:
父组件 ── value ──> Counter
父组件 <─ onChange ─ Counter
value 是父组件传入的当前状态,onChange 是子组件请求父组件更新状态的回调。子组件并没有改变 value 这个 Props;它只是调用了一个函数。
完整使用方式:
function CounterDemo() {
const [count, setCount] = React.useState(0);
return (
<Counter
value={count}
onChange={setCount}
/>
);
}
setCount 的类型能够赋给 (nextValue: number) => void,因为它接受一个 number 并且不要求调用者处理返回值。
2. 可选属性与默认值
type ButtonProps = {
children: React.ReactNode;
disabled?: boolean;
};
function Button({
children,
disabled = false,
}: ButtonProps) {
return (
<button disabled={disabled}>
{children}
</button>
);
}
disabled?: boolean 表示调用者可以不提供该属性。进入函数体后,由于默认值,局部变量 disabled 的实际类型是 boolean,而不是 boolean | undefined。
这两种写法含义不同:
type A = {
disabled?: boolean;
};
// 可以省略;在未开启 exactOptionalPropertyTypes 时,通常也可能显式传 undefined
type B = {
disabled: boolean | undefined;
};
// 属性必须存在,但值可以是 undefined
如果项目启用了:
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true
}
}
那么 disabled?: boolean 更严格地表示“可以不存在”,而不是“存在但值为 undefined”。
3. children 应该明确建模
children 不是所有组件天然都有的属性。只有允许嵌套内容的组件才应声明它:
type PanelProps = {
title: string;
children: React.ReactNode;
};
function Panel({ title, children }: PanelProps) {
return (
<section>
<h2>{title}</h2>
<div>{children}</div>
</section>
);
}
React.ReactNode 可以表达 React 能渲染的内容,例如:
<Panel title="说明">
<p>正文</p>
{"文本"}
{false}
</Panel>
React.ReactElement 更窄,表示一个 React 元素对象,不能表示普通字符串、数字或 null。如果组件必须接收某种特定元素,则应使用更窄的类型,而不是无条件使用 ReactNode。
例如:
type SingleElementProps = {
child: React.ReactElement;
};
function SingleElement({ child }: SingleElementProps) {
return child;
}
不要把 children 默认理解成 React.ReactNode 后再从中强制转换为某种组件。若 API 要求特定结构,应直接在类型中表达这种要求,或者在运行时验证。
4. 原生 DOM Props 的组合
组件通常需要复用 HTML 按钮的属性。可以使用 React 提供的 HTML 属性类型:
type PrimaryButtonProps =
React.ButtonHTMLAttributes<HTMLButtonElement> & {
tone?: "blue" | "red";
};
function PrimaryButton({
tone = "blue",
children,
...buttonProps
}: PrimaryButtonProps) {
return (
<button
{...buttonProps}
data-tone={tone}
>
{children}
</button>
);
}
调用:
<PrimaryButton
type="button"
disabled
aria-label="删除"
onClick={() => {
// ...
}}
>
删除
</PrimaryButton>
这里的 ...buttonProps 会把 type、disabled、aria-label 和 onClick 等属性传给真实按钮。
但组合原生属性也会带来 API 冲突。例如:
type BadProps =
React.ButtonHTMLAttributes<HTMLButtonElement> & {
onClick: (id: string) => void;
};
原生 onClick 期望的是鼠标事件处理器,而自定义 onClick 期望 string。这两个概念不应共用一个名字。可以用 Omit 移除后重新定义:
type ActionButtonProps =
Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onClick"
> & {
actionId: string;
onAction: (actionId: string) => void;
};
function ActionButton({
actionId,
onAction,
...buttonProps
}: ActionButtonProps) {
return (
<button
{...buttonProps}
onClick={() => onAction(actionId)}
/>
);
}
此时组件内部把 DOM 事件转换成领域事件:
MouseEvent ──> ActionButton ──> actionId:string
这通常比让业务方处理底层 DOM 事件更稳定,因为业务 API 不再依赖按钮的具体实现。
二、事件类型:区分 DOM 事件和业务回调
1. DOM 事件的类型参数
React 事件类型通常需要两个关键参数:
React.ChangeEvent<HTMLInputElement>
React.MouseEvent<HTMLButtonElement>
React.FormEvent<HTMLFormElement>
React.KeyboardEvent<HTMLInputElement>
第一个泛型参数描述事件绑定的元素类型。它影响 currentTarget 的类型:
function SearchInput() {
const handleChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
console.log(event.currentTarget.value);
};
return <input onChange={handleChange} />;
}
event.currentTarget 是事件处理器绑定的元素,因此这里是 HTMLInputElement。
event.target 则表示事件实际起始的目标,类型通常更宽,因为事件可能来自子元素:
function Wrapper() {
const handleClick = (
event: React.MouseEvent<HTMLButtonElement>
) => {
event.currentTarget.disabled = true;
// target 可能是按钮里的 span、svg 等后代元素
console.log(event.target);
};
return (
<button onClick={handleClick}>
<span>提交</span>
</button>
);
}
因此,访问当前监听元素的属性时优先使用 currentTarget。不能因为当前 JSX 看起来是按钮,就把任意 target 强制断言成 HTMLButtonElement。
2. JSX 上下文可以推断事件类型
以下写法通常能够得到完整推断:
function LoginForm() {
return (
<form
onSubmit={(event) => {
event.preventDefault();
// event 被推断为 React.FormEvent<HTMLFormElement>
}}
>
<input
onChange={(event) => {
// event 被推断为 React.ChangeEvent<HTMLInputElement>
console.log(event.currentTarget.value);
}}
/>
</form>
);
}
当处理器被抽离为独立函数时,应该显式标注:
function handleSubmit(
event: React.FormEvent<HTMLFormElement>
) {
event.preventDefault();
}
也可以先从 JSX 元素属性中提取处理器类型:
type InputChangeHandler =
React.ComponentProps<"input">["onChange"];
const handleInputChange: NonNullable<InputChangeHandler> = (
event
) => {
console.log(event.currentTarget.value);
};
ComponentProps<"input"> 表示 JSX input 的属性集合,["onChange"] 得到对应属性类型;由于该属性可能不存在,所以使用 NonNullable 去掉 null | undefined。
3. 自定义事件不应伪装成 DOM 事件
下面两种 API 有不同含义:
type RawInputProps = {
onChange: React.ChangeEventHandler<HTMLInputElement>;
};
type ValueInputProps = {
onValueChange: (value: string) => void;
};
第一种把 DOM 细节泄漏给调用者:
<RawInput
onChange={(event) => {
console.log(event.currentTarget.value);
}}
/>
第二种由组件负责转换:
<ValueInput
onValueChange={(value) => {
console.log(value);
}}
/>
实现:
type ValueInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange"
> & {
value: string;
onValueChange: (value: string) => void;
};
function ValueInput({
value,
onValueChange,
...inputProps
}: ValueInputProps) {
return (
<input
{...inputProps}
value={value}
onChange={(event) => {
onValueChange(event.currentTarget.value);
}}
/>
);
}
这里有一个重要的契约因果关系:
- 原生
onChange收到事件对象; - 组件读取
currentTarget.value; - 组件只把字符串交给业务层;
- 业务层不再依赖 DOM 结构。
如果组件库未来把 <input> 换成别的实现,只要仍能产生字符串,业务 API 就不必改变。
4. void 不等于“回调不能返回值”
type Props = {
onSave: () => void;
};
这表示组件不会使用返回值,而不是强制调用者的函数在运行时没有返回值。TypeScript 通常允许下面的函数赋给它:
function save(): boolean {
return true;
}
const props: Props = {
onSave: save,
};
React 调用事件处理器时也不会把这个返回值当作异步流程结果。若组件需要等待结果,应该明确声明:
type Props = {
onSave: () => Promise<void>;
};
然后实现相应的加载和错误处理:
function SaveButton({ onSave }: Props) {
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
async function handleClick() {
setSaving(true);
setError(null);
try {
await onSave();
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "保存失败"
);
} finally {
setSaving(false);
}
}
return (
<>
<button
type="button"
disabled={saving}
onClick={handleClick}
>
{saving ? "保存中..." : "保存"}
</button>
{error && <p role="alert">{error}</p>}
</>
);
}
这里的 try/finally 保证无论 Promise 成功还是失败,按钮都会退出加载状态。
三、泛型组件:让调用者决定数据类型
泛型组件适用于“组件逻辑相同,但数据类型不同”的场景。其抽象形式可以写成:
T 是调用点推断出的数据类型。例如表格并不应该把每一行固定成 any:
type Column<T> = {
key: string;
header: string;
render: (row: T) => React.ReactNode;
};
type TableProps<T> = {
rows: T[];
columns: Column<T>[];
};
function Table<T>({
rows,
columns,
}: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((column) => (
<th key={column.key}>{column.header}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((column) => (
<td key={column.key}>
{column.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
使用时:
type User = {
id: number;
name: string;
active: boolean;
};
const users: User[] = [
{ id: 1, name: "Ada", active: true },
];
function UserTable() {
return (
<Table
rows={users}
columns={[
{
key: "name",
header: "姓名",
render: (user) => user.name,
},
{
key: "status",
header: "状态",
render: (user) => user.active ? "启用" : "停用",
},
]}
/>
);
}
推导过程如下:
rows的类型是User[];- 因此
T被推断为User; columns必须是Column<User>[];- 每个
render的参数都被推断为User; render中访问不存在的属性会报错。
const invalidColumns: Column<User>[] = [
{
key: "email",
header: "邮箱",
render: (user) => user.email,
// 错误:User 没有 email
},
];
1. 泛型约束保证组件所需能力
如果组件需要根据对象属性取值,不能直接假设 T 有某个字段:
type KeyOf<T> = keyof T;
type SelectProps<T extends { id: string }> = {
options: T[];
value: string;
getLabel: (option: T) => string;
onChange: (id: string) => void;
};
function Select<T extends { id: string }>({
options,
value,
getLabel,
onChange,
}: SelectProps<T>) {
return (
<select
value={value}
onChange={(event) => {
onChange(event.currentTarget.value);
}}
>
{options.map((option) => (
<option key={option.id} value={option.id}>
{getLabel(option)}
</option>
))}
</select>
);
}
约束 T extends { id: string } 的逻辑是:
- 组件实现要访问
option.id; - TypeScript 必须证明所有可能的
T都有id: string; - 因此不允许任意
T; - 调用者仍然可以传入包含更多字段的类型。
type Project = {
id: string;
name: string;
archived: boolean;
};
const projects: Project[] = [
{ id: "p1", name: "网站", archived: false },
];
<Select
options={projects}
value="p1"
getLabel={(project) => project.name}
onChange={(id) => {
console.log(id);
}}
/>;
2. TSX 中泛型箭头函数的语法陷阱
在 .tsx 文件中,下面的 <T> 可能被解析为 JSX 标签开头:
// 可能产生 JSX 解析错误
const identity = <T>(value: T) => value;
可以加逗号消歧:
const identity = <T,>(value: T): T => value;
或者使用普通函数声明:
function identity<T>(value: T): T {
return value;
}
3. 泛型组件不应随意使用 any
any 会切断推导链:
type UnsafeTableProps = {
rows: any[];
render: (row: any) => React.ReactNode;
};
此时 render 中拼写错误和不兼容访问都可能通过检查。泛型的价值在于让同一个类型变量同时约束 rows、render、选择值和事件回调,而不是只给一个位置增加类型注解。
4. 泛型组件和 key 不是一回事
React 的 key 用于协调列表元素,不会自动作为组件 Props 传入:
type ItemProps = {
id: string;
};
function Item({ id }: ItemProps) {
return <div>{id}</div>;
}
<Item key="internal-key" id="visible-id" />;
组件能收到 id,但不能从 Props 中读取 key。如果组件逻辑需要标识符,应显式声明并传递 id。
四、Ref:从声明式渲染到有限的命令式接口
React 的正常数据流是声明式的:
状态 / Props ──> 渲染结果 ──> DOM
Ref 提供了一个受控的“从组件外部取得实例或 DOM 节点”的通道,常见用途包括:
- 聚焦输入框;
- 读取或测量 DOM;
- 滚动到某个位置;
- 集成必须操作 DOM 的第三方库;
- 暴露少量命令式方法。
1. DOM Ref 的类型
function SearchBox() {
const inputRef = React.useRef<HTMLInputElement>(null);
function focusInput() {
inputRef.current?.focus();
}
return (
<>
<input ref={inputRef} />
<button type="button" onClick={focusInput}>
聚焦
</button>
</>
);
}
初始化为 null 是因为组件首次渲染时 DOM 节点尚未挂载,而且组件卸载后也可能再次变为 null。因此 current 的类型是:
HTMLInputElement | null
必须使用可选链或显式判空:
inputRef.current?.focus();
if (inputRef.current) {
inputRef.current.focus();
}
不要写成:
inputRef.current!.focus();
非空断言只是关闭检查,不能改变运行时事实。如果调用发生在节点卸载后,仍可能得到错误。
2. React 19 中把 ref 作为 Props 接收
在 React 19 的函数组件中,组件可以直接声明 ref Prop:
type TextInputProps = {
label: string;
ref?: React.Ref<HTMLInputElement>;
};
function TextInput({
label,
ref,
}: TextInputProps) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
使用:
function Form() {
const ref = React.useRef<HTMLInputElement>(null);
return (
<>
<TextInput label="名称" ref={ref} />
<button
type="button"
onClick={() => ref.current?.focus()}
>
聚焦
</button>
</>
);
}
这里有版本边界:React 19 支持函数组件把 ref 作为普通 Props 使用,React 官方 API 文档也将 forwardRef 标记为未来弃用方向。实际项目还必须确认 react、@types/react 和框架版本一致;如果代码需要兼容 React 18 或更早版本,仍应使用 forwardRef:
type LegacyTextInputProps = {
label: string;
};
const LegacyTextInput = React.forwardRef<
HTMLInputElement,
LegacyTextInputProps
>(function LegacyTextInput({ label }, ref) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
});
不要同时把 ref 放进旧版本组件的普通 Props 类型并期待 React 18 自动转发。React 18 中,ref 对函数组件不是普通可传递属性。
3. Ref<T> 和回调 Ref
React.Ref<T> 通常可以是对象 Ref、回调 Ref 或 null:
type Ref<T> =
| React.RefObject<T | null>
| React.RefCallback<T>
| null;
组件只需要把它转发给真实节点时,可以这样声明:
type InputProps = {
ref?: React.Ref<HTMLInputElement>;
};
function Input({ ref }: InputProps) {
return <input ref={ref} />;
}
调用者可以传对象 Ref:
const ref = React.useRef<HTMLInputElement>(null);
<Input ref={ref} />;
也可以传回调 Ref:
<Input
ref={(node) => {
if (node) {
console.log("已挂载", node);
}
}}
/>;
Ref 的赋值发生在 DOM 节点提交到页面之后,而不是普通函数执行期间。渲染函数中读取:
function Component() {
const ref = React.useRef<HTMLDivElement>(null);
console.log(ref.current); // 初次渲染通常为 null
return <div ref={ref} />;
}
不能把它当成“渲染函数执行后立即同步可用”的普通变量。
4. useImperativeHandle 暴露最小接口
如果组件内部是输入框,外部通常不应拿到所有 DOM 操作权限。可以暴露一个更小的接口:
type SearchBoxHandle = {
focus: () => void;
clear: () => void;
};
type SearchBoxProps = {
ref?: React.Ref<SearchBoxHandle>;
defaultValue?: string;
};
function SearchBox({
ref,
defaultValue = "",
}: SearchBoxProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
React.useImperativeHandle(
ref,
() => ({
focus() {
inputRef.current?.focus();
},
clear() {
if (inputRef.current) {
inputRef.current.value = "";
}
},
}),
[]
);
return (
<input
ref={inputRef}
defaultValue={defaultValue}
/>
);
}
调用:
function SearchPage() {
const searchRef = React.useRef<SearchBoxHandle>(null);
return (
<>
<SearchBox ref={searchRef} defaultValue="React" />
<button onClick={() => searchRef.current?.focus()}>
聚焦
</button>
<button onClick={() => searchRef.current?.clear()}>
清空
</button>
</>
);
}
这里的接口边界是:
外部 ── focus / clear ──> SearchBox
SearchBox ── 私有 input DOM ──> 浏览器
useImperativeHandle 的依赖数组决定何时重新创建暴露对象。若对象中的方法捕获了会变化的 Props 或状态,应把这些依赖列出,否则方法可能读取过期闭包:
React.useImperativeHandle(
ref,
() => ({
submit() {
onSubmit(currentValue);
},
}),
[onSubmit, currentValue]
);
如果暴露的方法只通过 ref.current 读取最新的内部 Ref,则可以不依赖状态值,但仍应依据实际闭包分析,而不是机械写空数组。
5. Ref 不能替代状态和事件
错误的设计:
function BadCounter() {
const valueRef = React.useRef(0);
return (
<button
onClick={() => {
valueRef.current += 1;
}}
>
{valueRef.current}
</button>
);
}
点击后 ref.current 会改变,但 React 不会因为 Ref 改变而重新渲染,因此界面通常仍显示旧值。
正确做法:
function Counter() {
const [value, setValue] = React.useState(0);
return (
<button onClick={() => setValue((value) => value + 1)}>
{value}
</button>
);
}
判断原则是:
- 数据改变需要更新界面:使用 state;
- 数据只需跨渲染保存、改变本身不触发渲染:可以使用 Ref;
- 外部需要调用组件动作:使用 Ref 暴露有限命令式 API。
五、联合类型:把组件变体建模为互斥状态
联合类型 A | B 表示值属于 A 或 B。它不是“把所有字段都变成可选”,而是明确存在多个合法形状。
1. 用判别字段表达互斥 Props
假设按钮有链接模式和点击模式:
type LinkButtonProps = {
kind: "link";
href: string;
onClick?: never;
children: React.ReactNode;
};
type ActionButtonProps = {
kind: "action";
onClick: () => void;
href?: never;
children: React.ReactNode;
};
type ButtonProps =
| LinkButtonProps
| ActionButtonProps;
实现:
function Button(props: ButtonProps) {
if (props.kind === "link") {
return (
<a href={props.href}>
{props.children}
</a>
);
}
return (
<button type="button" onClick={props.onClick}>
{props.children}
</button>
);
}
调用:
<Button kind="link" href="/docs">
文档
</Button>
<Button kind="action" onClick={() => console.log("保存")}>
保存
</Button>
以下调用会被拒绝:
<Button
kind="link"
href="/docs"
onClick={() => {}}
>
错误
</Button>
其推导过程是:
kind: "link"将对象缩小到LinkButtonProps;- 该分支要求
href; onClick?: never表示这个分支不允许业务方提供onClick;kind: "action"则选择另一个对象形状;- 组件实现通过
props.kind进行类型收窄。
仅写成下面这样,约束会丢失:
type WeakButtonProps = {
href?: string;
onClick?: () => void;
children: React.ReactNode;
};
它允许 href 和 onClick 同时存在,也允许两个都不存在。若业务规则要求“二选一”,可选属性集合无法完整表达这个规则。
2. 联合类型的收窄必须依赖可靠判别
type LoadState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; error: Error };
function Result({ state }: { state: LoadState }) {
switch (state.status) {
case "idle":
return <p>尚未加载</p>;
case "loading":
return <p>加载中...</p>;
case "success":
return <p>结果数:{state.data.length}</p>;
case "error":
return <p role="alert">{state.error.message}</p>;
}
}
当 status === "success" 时,TypeScript 能证明 data 存在;当 status === "error" 时,能证明 error 存在。这比:
type WeakLoadState = {
status: string;
data?: string[];
error?: Error;
};
更安全,因为后者允许:
{
status: "success",
error: new Error("实际上失败了")
}
状态和数据之间失去了因果关系。
3. 穷尽性检查防止新增分支被遗漏
可以使用 never 辅助函数:
function assertNever(value: never): never {
throw new Error(`未处理的状态: ${String(value)}`);
}
function ResultWithCheck({
state,
}: {
state: LoadState;
}) {
switch (state.status) {
case "idle":
return <p>尚未加载</p>;
case "loading":
return <p>加载中...</p>;
case "success":
return <p>{state.data.length} 条</p>;
case "error":
return <p>{state.error.message}</p>;
default:
return assertNever(state);
}
}
如果后来新增:
| { status: "cancelled" }
但忘记修改 switch,state 在 default 中就不再是 never,编译器会指出遗漏。
4. 联合 Props 与泛型可以组合
例如选择器的值类型和选项形状同时受约束:
type Option<TValue> = {
value: TValue;
label: string;
};
type SingleSelectProps<TValue> = {
mode: "single";
value: TValue | null;
options: Option<TValue>[];
onChange: (value: TValue) => void;
};
type MultiSelectProps<TValue> = {
mode: "multiple";
value: TValue[];
options: Option<TValue>[];
onChange: (value: TValue[]) => void;
};
type SelectProps<TValue> =
| SingleSelectProps<TValue>
| MultiSelectProps<TValue>;
实现时必须先收窄 mode:
function GenericSelect<TValue>(
props: SelectProps<TValue>
) {
if (props.mode === "single") {
return (
<button
type="button"
onClick={() => {
const first = props.options[0];
if (first) {
props.onChange(first.value);
}
}}
>
选择 {props.options.length} 项中的一项
</button>
);
}
return (
<button
type="button"
onClick={() => {
props.onChange(
props.options.map((option) => option.value)
);
}}
>
全选
</button>
);
}
这里有两层约束:
TValue保证选项值、单选回调和多选回调使用同一种值类型;mode保证value与onChange的形状匹配。
六、函数类型、可赋值性和回调安全
Props 中最容易被忽略的类型关系是函数参数方向。
type ParentProps = {
onValue: (value: string) => void;
};
组件可以调用:
onValue("完成");
因此传入的函数必须至少能处理 string。在 strictFunctionTypes 下,以下函数不应被接受:
const onlyLiteral = (value: "完成") => {
console.log(value);
};
// 不能安全赋给 (value: string) => void
因为组件可能传入 "失败",而 onlyLiteral 只能处理 "完成"。
相反,能够处理任意字符串的函数可以用于只传入特定字符串的场景:
const anyString = (value: string) => {
console.log(value);
};
const handler: (value: "完成") => void = anyString;
直觉是:调用者掌握“会传什么参数”的权力,被传入的函数必须足够通用。
在组件 API 中,不要为了让类型“通过”而写:
onChange: (...args: any[]) => any;
这会放弃参数个数、顺序和返回值约束。应根据组件实际调用方式精确建模。
七、Props 默认值、派生值和状态一致性
TypeScript 能检查静态形状,但不能自动保证状态变化符合业务逻辑。下面是一个常见的受控组件:
type TabsProps = {
value: string;
tabs: readonly {
id: string;
label: string;
}[];
onChange: (id: string) => void;
};
function Tabs({
value,
tabs,
onChange,
}: TabsProps) {
return (
<div role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === value}
onClick={() => onChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
}
这个类型保证 value 和 onChange 都是字符串,却不能保证 value 一定存在于 tabs。如果希望在类型层面表达这种关系,可以让值使用选项联合:
const tabIds = ["overview", "settings"] as const;
type TabId = (typeof tabIds)[number];
// "overview" | "settings"
type StrictTabsProps = {
value: TabId;
onChange: (id: TabId) => void;
};
但如果选项来自网络或数据库,字符串集合在编译期未知,仍需要运行时检查:
function isTabId(value: string): value is TabId {
return (tabIds as readonly string[]).includes(value);
}
这说明静态类型和运行时验证负责不同问题:
源码中的固定集合 ── TypeScript 检查
外部输入 / JSON ── 运行时解析与验证
不要对网络响应直接写:
const data = response.json() as User;
类型断言不会检查 JSON。生产代码应使用验证库或手写解析器,确认字段存在、类型正确以及联合状态之间的关系成立。
八、客户端与服务端边界
TypeScript 类型不会跨进程、跨网络或跨运行时自动传递。现代 React 框架可能区分:
- 服务端组件:在服务器执行;
- 客户端组件:在浏览器执行,通常需要
"use client"; - 服务端向客户端传递的 Props:必须符合框架规定的可序列化边界。
事件处理函数和 Ref 都依赖浏览器运行时,不能作为普通可序列化数据从服务端组件传给客户端:
// 服务端组件中不能把浏览器事件处理器作为跨边界数据传递
<ClientButton onClick={() => console.log("click")} />
具体限制由框架实现决定,但因果关系稳定:
- 服务端组件的函数在服务器进程中创建;
- 浏览器无法通过 JSON 得到这个函数闭包;
- Ref 更是运行时节点或实例的引用,不具备序列化表示;
- 因此客户端边界必须在浏览器创建事件处理器和 Ref。
典型的客户端组件:
"use client";
type ClientButtonProps = {
label: string;
onClick: () => void;
};
export function ClientButton({
label,
onClick,
}: ClientButtonProps) {
return (
<button type="button" onClick={onClick}>
{label}
</button>
);
}
在服务端向客户端传递时,通常传递字符串、数字、布尔值、数组和普通对象等可序列化数据,然后在客户端组件内部创建行为:
// 服务端传递数据
<ClientButton label="刷新" />
"use client";
function ClientButton({ label }: { label: string }) {
return (
<button onClick={() => window.location.reload()}>
{label}
</button>
);
}
不要仅依据 TypeScript 能否通过来判断边界合法性。框架的服务端组件编译器、序列化协议和运行时约束是额外的一层检查。
九、把这些类型组合成一个可运行组件
下面的例子组合了:
- 联合类型表示
link和action两种按钮; - 原生按钮属性;
- 业务事件回调;
- React 19 的
refProp; - 穷尽性检查。
import * as React from "react";
type CommonProps = {
children: React.ReactNode;
className?: string;
ref?: React.Ref<HTMLButtonElement | HTMLAnchorElement>;
};
type ActionProps = CommonProps &
Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"children" | "className" | "ref" | "onClick"
> & {
kind: "action";
onAction: () => void;
href?: never;
};
type LinkProps = CommonProps &
Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
"children" | "className" | "ref"
> & {
kind: "link";
href: string;
onAction?: never;
};
type SmartButtonProps = ActionProps | LinkProps;
function assertNever(value: never): never {
throw new Error(`未知按钮类型: ${String(value)}`);
}
export function SmartButton(props: SmartButtonProps) {
switch (props.kind) {
case "action": {
const {
kind,
onAction,
children,
ref,
...buttonProps
} = props;
return (
<button
{...buttonProps}
ref={ref as React.Ref<HTMLButtonElement>}
type={buttonProps.type ?? "button"}
onClick={onAction}
>
{children}
</button>
);
}
case "link": {
const {
kind,
href,
children,
ref,
...anchorProps
} = props;
return (
<a
{...anchorProps}
ref={ref as React.Ref<HTMLAnchorElement>}
href={href}
>
{children}
</a>
);
}
default:
return assertNever(props);
}
}
使用:
function Example() {
const buttonRef =
React.useRef<HTMLButtonElement>(null);
return (
<SmartButton
kind="action"
ref={buttonRef}
onAction={() => {
console.log("执行动作");
}}
>
保存
</SmartButton>
);
}
这个例子中,ref 的联合类型意味着它可能指向按钮或链接,所以在跨变体共用一个 Props 类型时需要断言。更严格的 API 可以把 Ref 放进各自的分支:
type StrictActionProps = {
kind: "action";
ref?: React.Ref<HTMLButtonElement>;
onAction: () => void;
children: React.ReactNode;
};
type StrictLinkProps = {
kind: "link";
ref?: React.Ref<HTMLAnchorElement>;
href: string;
children: React.ReactNode;
};
type StrictProps = StrictActionProps | StrictLinkProps;
这样调用者能够得到更精确的 Ref 类型,但实现也必须先根据 kind 收窄,再把对应 Ref 传给对应元素。类型越精确,组件实现通常也需要越明确地拆分分支。
十、常见失败表现与诊断路径
1. Property 'x' does not exist on type ...
常见原因是联合类型没有先收窄:
function Broken(props: StrictProps) {
return <div>{props.href}</div>;
}
props 可能是 StrictActionProps,因此 href 不一定存在。应先判断:
function Fixed(props: StrictProps) {
if (props.kind === "link") {
return <div>{props.href}</div>;
}
return <div>动作按钮</div>;
}
诊断步骤是:
- 查看当前变量是否为联合类型;
- 找到判别字段;
- 在分支内访问该分支独有字段;
- 不要通过
as SomeBranch绕过检查。
2. 事件参数被推断为 any
通常来自以下问题:
- 处理器被声明为
(...args: any[]) => any; - 自定义组件的事件属性没有声明;
- 使用了未类型化的 JavaScript 组件;
noImplicitAny未开启。
建议至少启用:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true
}
}
然后从 JSX 原生元素类型或组件 API 反向提取事件类型,而不是把事件参数标成 any。
3. ref.current 总是为 null
需要区分三种情况:
- 在首次渲染函数执行时读取,节点还未提交;
- 条件渲染导致节点当前未挂载;
- 组件已经卸载,Ref 被清理。
错误示例:
function Broken() {
const ref = React.useRef<HTMLDivElement>(null);
const width = ref.current.offsetWidth;
return <div ref={ref}>{width}</div>;
}
修复方式是把 DOM 读取放到事件、Effect 或回调 Ref 中,并处理为空的路径:
function MeasuredBox() {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
React.useLayoutEffect(() => {
if (!ref.current) {
return;
}
setWidth(ref.current.getBoundingClientRect().width);
}, []);
return (
<div ref={ref}>
宽度:{width}
</div>
);
}
useLayoutEffect 适合必须在浏览器绘制前读取布局的场景;普通 useEffect 更适合不影响初次绘制的副作用。服务端渲染环境下,布局 Effect 的使用还要遵循框架对客户端组件的要求。
4. 泛型没有被正确推断
如果泛型只出现在返回值或无法关联的回调中,TypeScript 可能无法推断它:
type BadProps<T> = {
render: () => React.ReactNode;
};
这里 T 没有出现在输入位置,调用者传什么都无法让组件知道 T 是什么。泛型应出现在具有数据关系的 Props 中:
type GoodProps<T> = {
value: T;
render: (value: T) => React.ReactNode;
};
如果推断仍不稳定,可以显式指定:
<GenericComponent<User>
value={user}
render={(item) => item.name}
/>
显式泛型不是失败,而是在复杂组合组件中主动固定 API 的类型参数。
十一、设计取舍:什么时候该精确,什么时候该抽象
组件 API 的精确程度应与组件的稳定边界相匹配。
如果组件只是页面内部的薄包装:
function LocalButton(
props: React.ButtonHTMLAttributes<HTMLButtonElement>
) {
return <button {...props} />;
}
直接复用原生属性可能足够。
如果组件是设计系统中的公共组件,则应明确区分:
- DOM 事件和业务事件;
- 可组合的原生属性和组件专属属性;
- 互斥变体;
- 外部可见的 Ref 能力;
children是否允许任意节点;- 服务端边界是否允许该 Props 序列化。
例如,公共组件不应把所有内部 DOM 属性都偶然暴露出去,否则修改内部元素结构就可能成为破坏性变更。相反,过度使用联合类型、泛型和条件类型,也会让调用错误难以理解。应优先建模真实业务不变量:
如果“只能二选一”是业务规则,用联合类型表达;
如果“任意字符串都合法”,不要伪造字符串字面量联合;
如果“外部只需聚焦”,不要暴露整个 DOM;
如果“组件渲染不同数据结构”,用泛型保持数据关系。
最终可以用四个问题检查一个组件类型是否合理:
- 调用者能传入的所有值,是否都是真实支持的输入?
- 组件实现访问的每个字段,是否都由类型系统证明存在?
- 事件回调传出的数据,是否比底层实现更稳定而不是更模糊?
- Ref、联合分支和服务端边界,是否与实际运行时生命周期一致?
当这四个问题都能得到明确答案时,Props、事件、泛型、Ref 和联合类型就不再是分散的 TypeScript 技巧,而成为同一份组件 API 契约的不同表达方式。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 状态管理选型:Redux Toolkit、Zustand、服务端缓存和边界
- 下一篇:React 测试体系:Testing Library、Vitest、用户行为和端到端测试
- 延伸:React 组件与 Props:纯渲染、组合、Children 和 API 契约
- 延伸:React 组件库与设计系统:组合 API、Token、主题和版本治理
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论