Typescript: React 使用泛型定义组件 props 和 state
一个泛型列表组件为什么编译不过:类型参数的作用域只到 interface 边界为止。顺带说清 Component 的两个泛型参数其实来自 @types/react,以及 React 18 的类型把 children 从 FC 里拿掉之后该怎么写。
下面这段代码是泛型列表组件的标准写法,看着没什么问题,但它编译不过:
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 一路传下去:
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); };}这样一来 items、onItemClick 的参数、selectedItem 三处被绑在同一个 T 上:items 传 User[] 时,onItemClick 的参数自动是 User,selectedItem 也只能存 User 或 null,三者对不上就报错。这是泛型组件唯一真正值得要的东西 —— 不是「更灵活」,是几个位置之间的一致性由编译器盯着。
函数组件的泛型要多写一个逗号
同样的组件写成函数式,会撞上一个和 React 无关、纯粹是语法歧义的坑:
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 了。所以泛型组件写着写着从一个参数变成两个,那个逗号可以去掉,但留着也没错。
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,是一个普通函数,接的是 props、context、updater 三个参数,而且那个仓库根本不是用 TypeScript 写的。
Component<P, S> 这个形状来自 @types/react —— DefinitelyTyped 仓库里单独维护的一份声明文件,和 React 本身不同步发布、不同版本号。这个区分不是学究,它有实际后果:
- 类型上的破坏性变更可以在 React 本身没有任何变化时发生。 只升
@types/react就可能编译不过,下一节那件事就是这么来的。 - 在 React 源码里搜
P和S搜不到东西。 想看这两个类型参数怎么串起来,要去读@types/react的index.d.ts。
同样地,useState 在 React 源码里的签名和你写代码时用的那个不是同一个。公开类型是这样:
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 17interface FunctionComponent<P = {}> { (props: PropsWithChildren<P>, context?: any): ReactElement | null;}// @types/react 18interface FunctionComponent<P = {}> { (props: P, context?: any): ReactElement | null;}在 18 之前,React.FC<Props> 隐含着「这个组件可以接 children」,哪怕它根本不渲染 children。18 之后不再隐含,要接就得自己声明:
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 上:
const [selected, setSelected] = useState(null);setSelected(user); // error TS2345: Argument of type 'User' is not assignable // to parameter of type 'SetStateAction<null>'useState 的 S 是从初始值反推的,初始值写 null,S 就是 null 这个类型本身,之后再也塞不进别的东西。这类地方必须手写类型参数:useState<User | null>(null)。
在泛型组件里就是 useState<T | null>(null) —— 这也是前面那个函数组件例子里唯一必须手写类型参数的位置,其余的 T 都能从 props 推出来。
什么时候不该把组件写成泛型
泛型组件是有成本的,不是默认选项:
T必须能从 props 推出来,否则调用方要手写<MyComponent<User> …>。 这个语法在 JSX 里写起来很别扭,一旦出现,多半说明这个组件不该是泛型的。- 组件被
React.memo、forwardRef这类高阶函数包一层之后,泛型经常丢掉。 它们的类型签名会把类型参数吃掉,结果是包装后的组件退化成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 和 AOP 和 Typescript:Nest.js 中使用声明文件定义依赖注入的类型。