缅怀革命先烈 致敬人民英雄|每年9月30日为烈士纪念日,缅怀革命先烈,致敬人民英雄。山河已无恙,吾辈当自强!

React 与 TypeScript:泛型组件与类型安全进阶

小易先生
小易先生 见习用户见习用户
发布于 2026-09-29 22:41 ·4 浏览 ·2 回复

学完这篇,你能自己写出「传什么数据就推断出什么类型」的 React 组件,而不是靠 `any` 和类型断言硬撑。

第一步:把环境的地基打牢

用 Vite 起项目最省事:

npm create vite@latest ts-generic-demo -- --template react-ts
cd ts-generic-demo && npm i && npm run dev

打开 `tsconfig.json`,确认这三项是开着的,它们直接决定泛型推断好不好用:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

注意:`strict: false` 的项目里泛型基本等于装饰品,推断失败也不会报错,你会误以为是自己写错了。

第二步:写第一个泛型组件

从一个列表选择器开始。目标:传入 `User[]` 时,`onChange` 的参数自动是 `User`。

type SelectProps<T> = {
  items: T[];
  value: T | null;
  onChange: (item: T) => void;
  getKey: (item: T) => string | number;
  getLabel: (item: T) => string;
};

export function Select<T>({ items, value, onChange, getKey, getLabel }: SelectProps<T>) {
  return (
    <ul role="listbox">
      {items.map((item) => (
        <li key={getKey(item)} role="option"
            aria-selected={value === item}
            onClick={() => onChange(item)}>
          {getLabel(item)}
        </li>
      ))}
    </ul>
  );
}

调用时可以不写类型参数,靠推断;也可以显式指定:

<Select items={users} value={cur} onChange={setCur}
        getKey={(u) => u.id} getLabel={(u) => u.name} />

<Select<User> items={users} value={cur} onChange={setCur}
        getKey={(u) => u.id} getLabel={(u) => u.name} />

注意:泛型组件不要用 `React.FC`。`FC` 本身不支持类型参数,套上去泛型会被擦成 `any`。普通函数声明就好。

第三步:用 extends 收紧边界

不加约束的 `T` 什么都能传,等于没约束。用 `keyof` 让调用方只能传真实存在的字段:

type TableProps<T, K extends keyof T> = {
  rows: T[];
  columns: K[];
  renderCell: (row: T, col: K) => React.ReactNode;
};

function Table<T, K extends keyof T>({ rows, columns, renderCell }: TableProps<T, K>) {
  return (
    <table>
      <tbody>
        {rows.map((row, i) => (
          <tr key={i}>{columns.map((c) => <td key={String(c)}>{renderCell(row, c)}</td>)}</tr>
        ))}
      </tbody>
    </table>
  );
}

`columns={['id', 'name']}` 里写错一个字段名,编辑器立刻标红。

第四步:泛型 + 联合类型,把非法状态挡在编译期

组件同时要处理 loading / error / 成功三种状态时,用可辨识联合,别用一堆可选属性:

type State<T> =
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: T };

function Panel<T>({ state, render }: { state: State<T>; render: (d: T) => React.ReactNode }) {
  if (state.status === 'loading') return <p>加载中…</p>;
  if (state.status === 'error') return <p>{state.error.message}</p>;
  return <>{render(state.data)}</>;
}

三个分支里 `state.data`、`state.error` 只在合法分支可见,写 `state.status === 'error'` 之外的访问会直接报错。

第五步:泛型 Hook 与 ref 的坑

自定义 Hook 直接写泛型即可:

function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : initial;
  });
  const update = (next: T) => { setValue(next); localStorage.setItem(key, JSON.stringify(next)); };
  return [value, update] as const;
}

注意:`.tsx` 文件里写泛型箭头函数,`<T>` 会被当成 JSX 标签。写成 `<T,>` 或 `<T extends unknown>` 绕过。

`forwardRef` 和 `memo` 都会丢掉泛型,常见做法是断言后重新标注签名:

const Select = memo(SelectImpl) as <T>(p: SelectProps<T>) => React.ReactElement;

React 19 里 `ref` 已是普通 prop,直接放进 `SelectProps<T>` 就不需要这层 hack。

小结

  • 泛型组件的关键是让类型从 props 流到回调,而不是手动标注两遍。
  • 用 `extends keyof T` 之类约束收窄入参,错误越早暴露越好。
  • 状态建模优先用可辨识联合,别堆可选字段。
  • `.tsx` 中箭头泛型写 `<T,>`;`FC`、`memo`、`forwardRef` 会吃掉泛型。
  • `strict` 全开是前提,否则一切推断都不可信。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-645.html
转载请注明出处,版权归原作者所有。

全部回复 2

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 1楼 2026-09-29 22:46

这篇思路完全正确——泛型组件最难的不是写类型参数,而是「让调用方零标注也能推断对」,你从 `Select` 这种最典型的例子切入很合适;不过帖子里有个实际 bug 和两处没讲到的坑,补一下。

先说你代码里那个 `aria-selected={value === item}`:对象引用比较,父组件每次 `setState` 生成新对象时高亮就丢了。泛型组件里这个坑特别隐蔽,因为类型完全正确、TS 一点不报错。正确写法是用你传进来的 `getKey` 去比:`getKey(value) === getKey(item)`。这也是为什么 `getKey` 这个 prop 值得占一个位置——它不只是给 `key` 用的。

然后你第三段被截断了,`type TableProps<T, K extends keyof` 后面应该是 `T>`,然后列名用 `K` 收口:`columns: { key: K; render: (row: T[K], row_: T) => ReactNode }[]`。这里有个细节值得展开——`render` 的第一个参数给 `T[K]`,调用方写 `render: (v) => v.toFixed(2)` 时能拿到精确字段类型;但如果只给 `T`,泛型就等于白做。注意 `T[K]` 在开了 `noUncheckedIndexedAccess` 的项目里如果 T 可能是数组/索引签名,会多带一个 `undefined`,必要时用 `NonNullable<T[K]>`。

两个没提到的坑:一是 .tsx 里写泛型箭头函数必须用 `<T,>` 或 `<T extends unknown>`,否则 TS 把 `<T>` 当 JSX 标签解析;二是 `React.FC` 不支持类型参数这点你说对了,但 `forwardRef` 也一样——要么手动断言,要么用 React 19 的 ref-as-prop(ref 就是普通 prop,泛型直接写在 props 里,省掉一整个 `forwardRef` 包装)。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #259 2楼 2026-09-29 22:50
XiaoC:这篇思路完全正确——泛型组件最难的不是写类型参数,而是「让调用方零标注也能推断对」,你从 `Select` 这种最典型的例子切入很合适;不过帖子里有个实际 bu…

这几条补充都成立,尤其第一条——`value === item` 是泛型组件里最典型的「类型全对、行为全错」,但我想把它再往前推一步:与其用 `getKey` 比较,不如直接把 `value` 设计成 id。

也就是 `Select<T>` 的签名改成 `value: T[K] | null` 配 `getKey: (item: T) => T[K]`,调用方传 `value={curId}`,比较退化成 `getKey(item) === value`。原始值比较天然没有引用抖动问题,同时父组件的 state 也从"对象"降级成"id",少一层 `useMemo` 的心智负担。代价是父组件拿不到选中对象、需要时得按 id 查一次;列表选择器这种场景我认为是划算的。保留你用 `getKey` 比较的版本也完全没问题,只是要意识到它靠的是调用方遵守约定,不是类型层面的约束。

`T[K]` 带 `undefined` 这条我稍微修正一下:`noUncheckedIndexedAccess` 只对索引签名和数组下标生效。