Typescript: React 使用泛型定义组件 props 和 state

一个泛型列表组件为什么编译不过:类型参数的作用域只到 interface 边界为止。顺带说清 Component 的两个泛型参数其实来自 @types/react,以及 React 18 的类型把 children 从 FC 里拿掉之后该怎么写。

预计
6 分钟

下面这段代码是泛型列表组件的标准写法,看着没什么问题,但它编译不过:

这段编译不过,错在第 3 行
interface MyProps<T> { items: T[]; onItemClick: (item: T) => void; }
interface MyState { selectedItem: T | null; }
class MyComponent<T> extends React.Component<MyProps<T>, MyState> {
state: MyState = { selectedItem: null };
}

报错是 error TS2304: Cannot find name 'T'.,指着第 3 行。

原因是类型参数的作用域只到它自己那对尖括号所属的声明为止MyProps<T>T 活在 MyProps 这个 interface 里,MyComponent<T>T 活在这个类里,两者同名但互不相干。MyState 自己没有声明任何类型参数,所以它内部的 T 是一个没有定义的名字。

修法是让 MyState 也带上参数,再把类上的 T 一路传下去:

T 从类上一路传进 props 和 state
interface MyProps<T> { items: T[]; onItemClick: (item: T) => void; }
interface MyState<T> { selectedItem: T | null; }
class MyComponent<T> extends React.Component<MyProps<T>, MyState<T>> {
state: MyState<T> = { selectedItem: null };
handleClick = (item: T) => {
this.setState({ selectedItem: item });
this.props.onItemClick(item);
};
}

这样一来 itemsonItemClick 的参数、selectedItem 三处被绑在同一个 T 上:itemsUser[] 时,onItemClick 的参数自动是 UserselectedItem 也只能存 Usernull,三者对不上就报错。这是泛型组件唯一真正值得要的东西 —— 不是「更灵活」,是几个位置之间的一致性由编译器盯着

函数组件的泛型要多写一个逗号

同样的组件写成函数式,会撞上一个和 React 无关、纯粹是语法歧义的坑:

在 .tsx 里,这一行是解析错误
const identity = <T>(x: T) => x;

报错很吓人:error TS17008: JSX element 'T' has no corresponding closing tag..tsx 文件里 <T> 会被优先当成一个 JSX 标签的开头。

绕开的写法有两种,加一个尾逗号,或者给一个约束:

写法 .ts .tsx
<T>(x: T) => x 通过 解析错误
<T,>(x: T) => x 通过 通过
<T extends unknown>(x: T) => x 通过 通过
<T, U>(x: T, y: U) => … 通过 通过

最后一行值得注意:两个类型参数时不需要尾逗号。 歧义只在「一个类型参数、后面直接跟 >」时才出现,逗号一旦出现,解析器就知道这不是 JSX 了。所以泛型组件写着写着从一个参数变成两个,那个逗号可以去掉,但留着也没错。

函数组件里 T 同时约束 props 和 useState
interface MyProps<T> { items: T[]; onItemClick: (item: T) => void; }
const MyComponent = <T,>(props: MyProps<T>) => {
const [selectedItem, setSelectedItem] = useState<T | null>(null);
const handleClick = (item: T) => {
setSelectedItem(item);
props.onItemClick(item);
};
};

Component 的两个泛型参数来自 @types/react,不是 React 源码

「去 React 源码里看 Component 怎么用泛型」这条路走不通,因为 React 仓库里的 Component 上没有泛型。它在 packages/react/src/ReactBaseClasses.js,是一个普通函数,接的是 propscontextupdater 三个参数,而且那个仓库根本不是用 TypeScript 写的。

Component<P, S> 这个形状来自 @types/react —— DefinitelyTyped 仓库里单独维护的一份声明文件,和 React 本身不同步发布、不同版本号。这个区分不是学究,它有实际后果:

  • 类型上的破坏性变更可以在 React 本身没有任何变化时发生。 只升 @types/react 就可能编译不过,下一节那件事就是这么来的。
  • 在 React 源码里搜 PS 搜不到东西。 想看这两个类型参数怎么串起来,要去读 @types/reactindex.d.ts

同样地,useState 在 React 源码里的签名和你写代码时用的那个不是同一个。公开类型是这样:

@types/react · useState 的两条重载
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
function useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>];
type SetStateAction<S> = S | ((prevState: S) => S);
type Dispatch<A> = (value: A) => void;

SetStateAction<S> 是个联合类型,这解释了为什么 setCount(5)setCount(c => c + 1) 两种写法都合法 —— 它们是这个联合的两支。

React 18 的类型把 children 从 FC 里拿掉了

@types/react 18 有一处影响面很大的破坏性变更:FunctionComponent 的 props 参数从 PropsWithChildren<P> 改成了 P

改动前后
// @types/react 17
interface FunctionComponent<P = {}> {
(props: PropsWithChildren<P>, context?: any): ReactElement | null;
}
// @types/react 18
interface FunctionComponent<P = {}> {
(props: P, context?: any): ReactElement | null;
}

在 18 之前,React.FC<Props> 隐含着「这个组件可以接 children」,哪怕它根本不渲染 children。18 之后不再隐含,要接就得自己声明:

18 起 children 要显式写出来
interface CardProps {
title: string;
children?: React.ReactNode; // 不写这一行,<Card>内容</Card> 就编译不过
}
const Card: React.FC<CardProps> = ({ title, children }) => { /* … */ };

PropsWithChildren<P> 这个工具类型还在,只是 FunctionComponent 不再自动套它了,想要旧行为可以自己套:React.FC<React.PropsWithChildren<CardProps>>

这条变更是很多旧文章过时的分界线 —— 网上写「用 React.FC 就自带 children」的文章基本都停在 17 之前。反过来,「不要用 React.FC,因为它强塞 children」这条流传很广的反对意见,在 18 之后也已经不成立了。

useState 的类型靠初始值推,null 要自己标

泛型组件里最常见的类型事故不在 props 上,在 state 上:

初始值是 null 时,推出来的类型只有 null
const [selected, setSelected] = useState(null);
setSelected(user); // error TS2345: Argument of type 'User' is not assignable
// to parameter of type 'SetStateAction<null>'

useStateS 是从初始值反推的,初始值写 nullS 就是 null 这个类型本身,之后再也塞不进别的东西。这类地方必须手写类型参数:useState<User | null>(null)

在泛型组件里就是 useState<T | null>(null) —— 这也是前面那个函数组件例子里唯一必须手写类型参数的位置,其余的 T 都能从 props 推出来。

什么时候不该把组件写成泛型

泛型组件是有成本的,不是默认选项:

  • T 必须能从 props 推出来,否则调用方要手写 <MyComponent<User> …> 这个语法在 JSX 里写起来很别扭,一旦出现,多半说明这个组件不该是泛型的。
  • 组件被 React.memoforwardRef 这类高阶函数包一层之后,泛型经常丢掉。 它们的类型签名会把类型参数吃掉,结果是包装后的组件退化成 unknown 或者 any,得手写类型断言绕回来。这是泛型组件最常见的翻车点。
  • 只有一处用到 T 时不值得。 泛型的回报来自「多个位置必须一致」;只有 items: T[] 一处用到,写成 unknown[] 加一次断言更省事。
  • 类组件的 setState 在泛型下更挑剔。 上面那个 this.setState({ selectedItem: item }) 能过,是因为 Pick<S, K> 恰好对得上;state 里字段多起来之后,部分更新的类型推断会开始报一些不好读的错。

回到开头那个报错:Cannot find name 'T' 看着像是 React 的问题,其实和 React 一点关系都没有 —— 它是 TypeScript 在说「你用了一个没声明的类型名字」。泛型组件里绝大多数难懂的报错都是这一类,读的时候先把 React 摘出去。

这篇归在 TypeScript 下。同一主题里最近的另外两篇是 TypeScript:从架构分层设计到 IOC 和 AOPTypescript:Nest.js 中使用声明文件定义依赖注入的类型