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

React 受控组件类型安全体系:用 Discriminated Union 消灭不可能的 UI 状态

前言

React 组件最常见的 Props 类型是这样写的:

interface UserCardProps {
  user?: User;
  loading?: boolean;
  error?: string;
}

一眼看过去没问题。但当你 review 到这个状态时,沉默就开始了:

// loading 和 error 同时为 true —— UI 应该渲染什么?
<UserCard loading={true} error="网络超时" />
// user 有值但 loading 为 true —— 数据到了却继续转圈
<UserCard user={bob} loading={true} />

非法状态组合在类型系统里是完全合法的。这篇文章用 Discriminated Union 把这个洞补上。

一、问题根源:布尔值矩阵爆炸

当一个组件的状态由 N 个独立布尔值控制时,可能的状态数是 2^N,而合法的通常只有 N+1 个:

// ❌ 6 个字段 = 64 种状态组合,合法的不超过 5 种
interface AsyncListProps {
  data: Item[];
  loading: boolean;
  error: string | null;
  isEmpty: boolean;
  isRefreshing: boolean;
  hasMore: boolean;
}

// 这段代码能通过编译,但它在语义上完全悖论
<AsyncList
  data={items}
  loading={true}
  isEmpty={true}
  error="网络错误"
  isRefreshing={true}
  hasMore={false}
/>

这种设计强迫每个使用者自行推断哪些组合是合理的,也强迫维护者在每个渲染分支里做防御性检查。

二、解决方案:Discriminated Union Props

kind 字段作为判别键,将多变量状态空间降维为互斥单变量:

// ✅ 5 种状态,每种都有明确的 Props 结构
type AsyncListProps =
  | { kind: 'loading' }
  | { kind: 'error'; message: string; onRetry: () => void }
  | { kind: 'empty'; description: string }
  | { kind: 'data'; items: Item[]; hasMore: boolean; onLoadMore: () => void }
  | { kind: 'data-refreshing'; items: Item[]; hasMore: boolean };

const AsyncList = (props: AsyncListProps) => {
  switch (props.kind) {
    case 'loading':
      return <Skeleton count={5} />;
    case 'error':
      return <ErrorBanner message={props.message} onRetry={props.onRetry} />;
    case 'empty':
      return <EmptyState description={props.description} />;
    case 'data':
      return <InfiniteList items={props.items} hasMore={props.hasMore} onLoadMore={props.onLoadMore} />;
    case 'data-refreshing':
      return <><InfiniteList items={props.items} /><RefreshIndicator /></>;
  }
};

TypeScript 会根据 kind 自动窄化类型。在 case 'error' 分支里,props.message 的类型推断为 string,不需要 ?.!,不需要非空断言。如果你尝试在 case 'loading' 里访问 props.items,编译器会报错。

三、进阶:表单场景的多态 Props

表单组件经常根据 type 切换 UI 结构:

// ❌ 所有字段平铺,依赖运行时分支
interface FieldProps {
  type: 'text' | 'select' | 'date-range';
  value: string;
  options?: SelectOption[];      // 只有 select 用
  minDate?: string;               // 只有 date-range 用
  maxDate?: string;               // 只有 date-range 用
  placeholder?: string;
  onChange: (value: string) => void;
}
// type === 'text' 时,options/minDate/maxDate 是无意义的噪音

用 Discriminated Union 重构:

// ✅ 每种 type 只暴露相关 Props
type FieldProps =
  | {
      type: 'text';
      value: string;
      placeholder?: string;
      maxLength?: number;
      onChange: (value: string) => void;
    }
  | {
      type: 'select';
      value: string;
      options: SelectOption[];
      placeholder?: string;
      onChange: (value: string) => void;
    }
  | {
      type: 'date-range';
      value: [string, string];       // 类型变窄:从 string 到 tuple
      minDate?: string;
      maxDate?: string;
      onChange: (value: [string, string]) => void;
    };

const FormField = (props: FieldProps) => {
  if (props.type === 'date-range') {
    // props.value 自动推断为 [string, string],不再需要断言
    return <DateRangePicker value={props.value} onChange={props.onChange} />;
  }
  // ...
};

注意 valueonChange 的类型也跟着 type 变化了——'date-range'value[string, string]'select'options 是必填项。这杜绝了"忘记传 options"的运行时 bug。

四、提取公共字段:减少重复

当每种状态共享某些字段时,用交集类型而非全量重复:

// 公共 Props
interface CommonFieldProps {
  name: string;
  label: string;
  required?: boolean;
  disabled?: boolean;
}

type FieldProps = CommonFieldProps & (
  | { type: 'text'; value: string }
  | { type: 'select'; value: string; options: SelectOption[] }
  | { type: 'date-range'; value: [string, string]; minDate?: string; maxDate?: string }
);

// 使用时同时享有 CommonFieldProps 和变体 Props 的类型推导
<FormField name="email" label="邮箱" type="text" value="a@b.com" />
<FormField name="birthday" label="出生日期" type="date-range" value={['2000-01-01', '2024-01-01']} />

五、何时不该用

Discriminated Union 不是银弹,有明确的边界:

  • Props 变体 > 3 且字段差异大 → ✅ 推荐使用
  • 只有 1-2 个可选字段 → 用 ?: 就够了,引入 Union 反而增加心智负担
  • 动态表单(Schema 驱动) → Discriminated Union 的枚举是静态的,无法覆盖运行时未知字段组合
  • 需要部分渲染(如 Builder 模式)→ 考虑 Render Props 或 Slots 模式

总结

策略效果
布尔值 Props 平铺非法状态在运行时被"悄悄放过"
Discriminated Union Props非法状态在编译期直接报错

用类型系统收窄状态空间,本质是在编译器里建模业务状态机。少写一个 if (loading && !error) 不是目的,目的是让那个 if 永远不需要被写出来。

// 最终效果:传入非法 Props 会在 IDE 里直接标红
<AsyncList kind="loading" items={[]} />  // ❌ TS Error: 'items' does not exist on type '{ kind: "loading"; }'
0 评论

评论区

登录 后参与评论