React 基础体系 · 第 13/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 与 TypeScript:Props、事件、泛型组件、Ref 和联合类型

React 组件的类型安全,核心不是“给组件加上几个类型注解”,而是明确组件 API 的输入、输出和生命周期边界:

  • Props 描述父组件可以传入什么;
  • 事件类型描述回调何时发生、携带什么数据;
  • 泛型组件让组件的数据类型由调用者决定;
  • Ref 暴露 DOM 节点或受控的命令式能力;
  • 联合类型把互斥状态、变体和事件建模为可检查的状态空间。

这些类型最终都会参与 JSX 调用检查,但 TypeScript 只在编译阶段工作。运行时仍然可能收到 JavaScript、网络响应或服务端传来的不符合预期的数据,因此“类型声明正确”不等于“外部数据已经验证”。


一、先建立组件类型模型

一个 React 函数组件可以抽象为:

C:PRC: P \rightarrow R

其中:

  • PP 是 Props 类型,即调用者必须提供的数据;
  • RR 是组件返回的 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 会把 typedisabledaria-labelonClick 等属性传给真实按钮。

但组合原生属性也会带来 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);
      }}
    />
  );
}

这里有一个重要的契约因果关系:

  1. 原生 onChange 收到事件对象;
  2. 组件读取 currentTarget.value
  3. 组件只把字符串交给业务层;
  4. 业务层不再依赖 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 成功还是失败,按钮都会退出加载状态。


三、泛型组件:让调用者决定数据类型

泛型组件适用于“组件逻辑相同,但数据类型不同”的场景。其抽象形式可以写成:

C<T>:Props<T>ReactNodeC<T>: Props<T> \rightarrow ReactNode

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 ? "启用" : "停用",
        },
      ]}
    />
  );
}

推导过程如下:

  1. rows 的类型是 User[]
  2. 因此 T 被推断为 User
  3. columns 必须是 Column<User>[]
  4. 每个 render 的参数都被推断为 User
  5. 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 } 的逻辑是:

  1. 组件实现要访问 option.id
  2. TypeScript 必须证明所有可能的 T 都有 id: string
  3. 因此不允许任意 T
  4. 调用者仍然可以传入包含更多字段的类型。
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 中拼写错误和不兼容访问都可能通过检查。泛型的价值在于让同一个类型变量同时约束 rowsrender、选择值和事件回调,而不是只给一个位置增加类型注解。

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 表示值属于 AB。它不是“把所有字段都变成可选”,而是明确存在多个合法形状。

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>

其推导过程是:

  1. kind: "link" 将对象缩小到 LinkButtonProps
  2. 该分支要求 href
  3. onClick?: never 表示这个分支不允许业务方提供 onClick
  4. kind: "action" 则选择另一个对象形状;
  5. 组件实现通过 props.kind 进行类型收窄。

仅写成下面这样,约束会丢失:

type WeakButtonProps = {
  href?: string;
  onClick?: () => void;
  children: React.ReactNode;
};

它允许 hrefonClick 同时存在,也允许两个都不存在。若业务规则要求“二选一”,可选属性集合无法完整表达这个规则。

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" }

但忘记修改 switchstatedefault 中就不再是 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 保证 valueonChange 的形状匹配。

六、函数类型、可赋值性和回调安全

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>
  );
}

这个类型保证 valueonChange 都是字符串,却不能保证 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")} />

具体限制由框架实现决定,但因果关系稳定:

  1. 服务端组件的函数在服务器进程中创建;
  2. 浏览器无法通过 JSON 得到这个函数闭包;
  3. Ref 更是运行时节点或实例的引用,不具备序列化表示;
  4. 因此客户端边界必须在浏览器创建事件处理器和 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 能否通过来判断边界合法性。框架的服务端组件编译器、序列化协议和运行时约束是额外的一层检查。


九、把这些类型组合成一个可运行组件

下面的例子组合了:

  • 联合类型表示 linkaction 两种按钮;
  • 原生按钮属性;
  • 业务事件回调;
  • React 19 的 ref Prop;
  • 穷尽性检查。
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>;
}

诊断步骤是:

  1. 查看当前变量是否为联合类型;
  2. 找到判别字段;
  3. 在分支内访问该分支独有字段;
  4. 不要通过 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;
如果“组件渲染不同数据结构”,用泛型保持数据关系。

最终可以用四个问题检查一个组件类型是否合理:

  1. 调用者能传入的所有值,是否都是真实支持的输入?
  2. 组件实现访问的每个字段,是否都由类型系统证明存在?
  3. 事件回调传出的数据,是否比底层实现更稳定而不是更模糊?
  4. Ref、联合分支和服务端边界,是否与实际运行时生命周期一致?

当这四个问题都能得到明确答案时,Props、事件、泛型、Ref 和联合类型就不再是分散的 TypeScript 技巧,而成为同一份组件 API 契约的不同表达方式。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。