前端开发··2 阅读·预计 10 分钟

React 组件 API 契约的类型量化:用 Discriminated Union 将运行时错误前移至编译期

引子:一个在生产环境爆炸的弹窗

// ❌ 反例:Optional Props 的恐怖组合
interface ModalProps {
  visible: boolean;
  title?: string;
  content?: string;
  onConfirm?: () => void;
  onCancel?: () => void;
}

function Modal({ visible, title, content, onConfirm, onCancel }: ModalProps) {
  if (!visible) return null;
  return (
    <div className="modal">
      <h2>{title ?? '确认'}</h2>
      <p>{content ?? '你确定要执行此操作吗?'}</p>
      <button onClick={onConfirm ?? (() => {})}>确定</button>
      <button onClick={onCancel}>取消</button>
    </div>
  );
}

// 允许传入这种荒谬状态:弹窗可见但没有确认回调
<Modal visible onCancel={() => close()} />;
// 更糟:弹窗可见但既没有标题也没有内容,也没有确认按钮回调
<Modal visible />;

每个字段都 optional,意味着 TypeScript 允许任意组合。你必须在运行时用 ?? 兜底,而这些兜底逻辑本质上是在为类型系统的失职买单。

Discriminated Union:让 Props 自己说话

Discriminated Union(可辨识联合类型)通过一个字面量字段区分变体,让 TypeScript 在编译期就拒绝非法组合:

// ✅ 正例:用 type 字段区分模态框的三种合法形态
type ModalProps =
  | {
      type: 'confirm';
      visible: boolean;
      title: string;
      content: string;
      onConfirm: () => void;
      onCancel: () => void;
    }
  | {
      type: 'alert';
      visible: boolean;
      title: string;
      content: string;
      onConfirm: () => void;
      // 注意:alert 模式不需要 onCancel
    }
  | {
      type: 'custom';
      visible: boolean;
      children: React.ReactNode;
      // custom 模式不需要 title/content,由 children 接管
    };

function Modal(props: ModalProps) {
  if (!props.visible) return null;

  switch (props.type) {
    case 'confirm':
      // TS 自动收窄:props 的类型现为 confirm 变体
      // props.onCancel 确定存在,不需要 ?. 或 ??
      return (
        <div className="modal">
          <h2>{props.title}</h2>
          <p>{props.content}</p>
          <button onClick={props.onConfirm}>确定</button>
          <button onClick={props.onCancel}>取消</button>
        </div>
      );

    case 'alert':
      // TS 知道 alert 变体没有 onCancel,访问它会报错
      return (
        <div className="modal">
          <h2>{props.title}</h2>
          <p>{props.content}</p>
          <button onClick={props.onConfirm}>知道了</button>
        </div>
      );

    case 'custom':
      // children 确定存在,title/content 不存在
      return <div className="modal">{props.children}</div>;
  }
}

// ✅ 编译通过——所有必填字段齐全
<Modal
  type="confirm"
  visible
  title="删除项目"
  content="此操作不可撤销"
  onConfirm={handleDelete}
  onCancel={closeModal}
/>;

// ❌ 编译报错——alert 变体缺少 content
<Modal type="alert" visible title="操作成功" onConfirm={closeModal} />;

// ❌ 编译报错——confirm 变体缺少 onCancel
<Modal
  type="confirm"
  visible
  title="确认"
  content="确定?"
  onConfirm={handleConfirm}
/>;

关键收益:switch 分支内 TypeScript 自动收窄类型,不需要任何 ?.??as 断言。代码里再也看不到「防御性兜底」。

进阶:数据驱动的多形态组件

组件形态往往取决于数据状态。以下是列表组件的经典反模式:

// ❌ 反例:用一个 isLoading 布尔值衍生出六个可选字段
interface ListProps {
  items?: Item[];
  isLoading?: boolean;
  error?: Error;
  emptyText?: string;
  errorText?: string;
  onRetry?: () => void;
}

function List({ items, isLoading, error, emptyText, errorText, onRetry }: ListProps) {
  if (isLoading) return <Spinner />;
  if (error) return <ErrorBanner message={errorText ?? error.message} onRetry={onRetry} />;
  if (!items?.length) return <Empty text={emptyText ?? '暂无数据'} />;
  return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}

// 可以同时传入 items 和 error——这种行为不应该被允许
<List items={data} error={new Error('network')} />;

用 Discriminated Union 重构:

// ✅ 正例:按数据状态建模,互斥状态在类型层被保证
type ListProps<T> =
  | { status: 'loading' }
  | { status: 'error'; error: Error; onRetry: () => void }
  | { status: 'empty'; emptyText?: string }
  | { status: 'success'; items: T[]; renderItem: (item: T) => React.ReactNode };

function List<T>(props: ListProps<T>) {
  switch (props.status) {
    case 'loading':
      return <Spinner />;
    case 'error':
      return (
        <ErrorBanner
          message={props.error.message}
          onRetry={props.onRetry}
        />
      );
    case 'empty':
      return <Empty text={props.emptyText ?? '暂无数据'} />;
    case 'success':
      return <ul>{props.items.map(props.renderItem)}</ul>;
  }
}

// ❌ 编译报错:不能同时传入 items 和 error
<List status="error" error={new Error('fail')} items={data} onRetry={retry} />;

// ✅ 编译通过
<List status="success" items={data} renderItem={(item) => <li>{item.name}</li>} />;

status 字段消除了状态的二义性。现在你不可能同时渲染错误信息和列表数据——因为类型系统不允许。

配合 never 做前瞻性检查

如果你未来给 ListProps 增加了一个新的 status(比如 'offline'),TypeScript 不会主动告诉你所有 switch 分支都需要更新。加一行 never 守门员:

function List<T>(props: ListProps<T>) {
  switch (props.status) {
    case 'loading': return <Spinner />;
    case 'error': return <ErrorBanner message={props.error.message} onRetry={props.onRetry} />;
    case 'empty': return <Empty text={props.emptyText} />;
    case 'success': return <ul>{props.items.map(props.renderItem)}</ul>;
    default: {
      // 如果 ListProps 新增了变体,这行会在编译期报错
      const _exhaustive: never = props;
      return _exhaustive;
    }
  }
}

现在如果有人给 ListProps 加了 { status: 'offline'; ... },这行 never 检查会直接让 CI 变红——在你合并代码之前。

总结

Discriminated Union 不是类型体操,是组件契约设计的基础设施:

  1. 用字面量 tag 字段替代 isXxx 布尔值。 一个 tag 可以表达 N 种互斥状态,而 N 个布尔值有 2^N 种非法组合。
  2. switch 分支内不要写任何 ?.?? 如果类型收窄后仍需防御性编程,说明 Discriminant 设计不够彻底。
  3. default 分支加 never 守门员。 保证未来新增变体时不会悄悄产生运行时漏洞。

isLoadinghasErrorisEmpty 这些布尔值从你的 Props 里删掉。用一个 status 就够了。

0 评论

评论区

登录 后参与评论