持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第19天,点击查看活动详情
浅谈 React 和 TypeScript 开发中的泛型实践
泛型是 TypeScript 中的一个重要部分:它们看起来很奇怪,它们的目的不明显,而且它们可能很难分析。本文旨在帮助你理解和揭开 TypeScript 泛型的神秘面纱,特别是它们在 React 中的应用。它们并没有那么复杂:如果你理解函数,那么泛型也就不远了。
1. TypeScript中的泛型是什么?
要理解泛型,我们首先从比较标准 TypeScript 类型和 JavaScript 对象开始。
// JavaScript 对象
const user = {
name: 'John',
status: 'online',
};
// 对应的 TypeScript 类型
type User = {
name: string;
status: string;
};
如你所见,非常像。主要的区别是,在 JavaScript 中你关心的是变量的值,而在 TypeScript 中你关心的是变量的类型。
关于 User
类型,我们可以说的一点是它的 status
属性太模糊了。状态通常有预定义的值,比如在本例中,它可以是 "online"
或 "offline"
。我们可以修改我们的类型:
type User = {
name: string;
status: 'online' | 'offline';
};
但前提是我们已经知道有哪些状态。如果我们不这样做,而实际的状态列表发生了变化呢?这就是泛型的用武之地:它们允许你指定可以根据用法更改的类型。
我们将在后面看到如何实现这个新类型,但是对于我们的 User
示例,使用泛型类型看起来像这样:
// `=User 现在是泛型类型
const user: User<'online' | 'offline'>;
// 如果我们需要,我们可以很容易地添加一个新的状态 "idle"
const user: User<'online' | 'offline' | 'idle'>;
上面说的是“用户变量是一个 user
类型的对象,顺便说一下,该用户的状态选项要么是 'online'
,要么是 'offline'
”(在第二个示例中,你将 'idle'
添加到该列表中)。
下面是如何实现这种类型:
// 泛型类型定义
type User<StatusOptions> = {
name: string;
status: StatusOptions;
};
StatusOptions
被称为“类型变量”,而 User
被称为“泛型类型”。
你可能会觉得很奇怪。但这只是一个函数。如果我使用类似 JavaScript 的语法(不是有效的 TypeScript )来编写它,它看起来像这样:
type User = (StatusOption) => {
return {
name: string;
status: StatusOptions;
}
}
正如你所看到的,它实际上只是函数的 TypeScript 等价物。你可以用它做一些很有意思的事情。例如,假设我们的 User
接受一个 status
数组,而不是像以前那样接受一个 status
。对于泛型类型,这仍然非常容易做到:
// 定义类型
type User<StatusOptions> = {
name: string;
status: StatusOptions[];
};
// 类型的用法仍然相同
const user: User<'online' | 'offline'>;
如果你想了解更多关于泛型的知识,你可以查看 TypeScript 的指南。
2. 为什么泛型非常有用?
现在你知道了泛型类型是什么以及它们是如何工作的,你可能会问自己为什么需要它。毕竟,我们上面的例子中,你可以定义一个类型 Status
并使用它:
type Status = 'online' | 'offline';
type User = {
name: string;
status: Status;
};
在这个(相当简单的)例子中是这样的,但是在很多情况下你不能这样做。通常情况下,当你希望在多个实例中使用一个共享类型时,每个实例都有一些不同:你希望该类型是动态的,并适应其使用方式。
一个非常常见的例子是函数返回与其实参相同的类型。最简单的形式是 identity
函数,它返回给定的任何值:
function identity(arg) {
return arg;
}
很简单对吧?但如果参数 arg
可以是任何类型,你怎么输入这个呢?不要说使用 any
!
没错,使用泛型:
function identity<ArgType>(arg: ArgType): ArgType {
return arg;
}
它真正说的是:“identity
函数可以接受任何类型(ArgType),该类型将是其参数的类型和返回类型”。
下面是你如何使用这个函数并指定它的类型:
const greeting = identity<string>('Hello World!');
在这个特定的实例中,<string>
是没有必要的,因为 TypeScript 可以推断出类型本身,但有时它不能(或做错了),你必须自己指定类型。
3. 多个类型变量
你并不局限于一个类型变量,你可以使用任意多个类型变量。例如:
function identities<ArgType1, ArgType2>(
arg1: ArgType1,
arg2: ArgType2
): [ArgType1, ArgType2] {
return [arg1, arg2];
}
在这个实例中,identity
接受两个参数并以数组形式返回它们。
4. JSX 中箭头函数的泛型语法
你可能已经注意到,我现在只使用了常规函数语法,而没有使用 ES6 中引入的箭头函数语法。
// 一个箭头函数
const identity = (arg) => {
return arg;
};
原因是 TypeScript 处理箭头函数的能力不如常规函数(当使用 JSX 时)。你可能认为你可以这样做:
// 这个不行
const identity<ArgType> = (arg: ArgType): ArgType => {
return arg;
}
// 这个也不行
const identity = <ArgType>(arg: ArgType): ArgType => {
return arg;
}
但这在 TypeScript 中不起作用。相反,你可以通过以下方式编写:
const identity = <ArgType,>(arg: ArgType): ArgType => {
return arg;
};
// or
const identity = <ArgType extends unknown>(arg: ArgType): ArgType => {
return arg;
};
我建议使用第一个,因为它更简洁,但逗号在我看来还是有点奇怪。需要明确的是,这个问题源于我们在使用 TypeScript 和 JSX(被称为 TSX)。在普通的 TypeScript 中,你不必使用这个解决方法。
5. 关于类型变量名的警告
出于某种原因,在 TypeScript 世界中,泛型类型中的类型变量的名称通常是一个字母。
// 看到的不是这个
function identity<ArgType>(arg: ArgType): ArgType {
return arg;
}
// 你通常会看到这个
function identity<T>(arg: T): T {
return arg;
}
使用完整的单词作为类型变量名确实会使代码相当冗长,但我仍然认为这比使用单字母选项更容易理解。
6. 开源的泛型类型示例 — useState
接下来让我们看看 React 库中 useState
的泛型类型。
注:此部分比本文的其他部分要复杂一些。如果你一开始不明白,可以稍后再看。
让我们来看看 useState
的类型定义:
function useState<S>(
initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>];
让我们一步一步地理解这个类型定义:
- 我们首先定义一个函数
useState
,它接受一个名为S
的泛型类型。 - 该函数只接受一个参数:
initialState
。- 初始状态可以是类型为
S
的变量(我们的泛型类型),也可以是返回类型为S
的函数。
- 初始状态可以是类型为
- 然后
useState
返回一个包含两个元素的数组:- 第一个类型是
S
(它是我们的状态值)。 - 第二个是
Dispatch
类型,应用泛型类型SetStateAction
。SetStateAction
本身是应用泛型类型S
的SetStateAction
类型(它是我们的状态setter
)。
- 第一个类型是
最后一部分有点复杂,所以让我们进一步研究一下。
首先,让我们查看 SetStateAction
:
type SetStateAction<S> = S | ((prevState: S) => S);
SetStateAction
也是一个泛型类型它可以是类型为 S
的变量,也可以是一个参数类型和返回类型都为 S
的函数。
这让我想起 setState
提供了什么?你可以直接提供新的状态值,也可以提供一个函数,根据旧的状态值构建新的状态值。
Dispatch
是什么?
type Dispatch<A> = (value: A) => void;
这个有一个参数是泛型类型,什么都不返回。
把它们放在一起:
// 原类型
type Dispatch<SetStateAction<S>>
// 可以被重构为这个类型
type (value: S | ((prevState: S) => S)) => void
这个函数要么接受值 S
要么接受值 S => S
,然后什么都不返回。这确实与我们对 setState
的使用相匹配。
这就是 useState
的整个类型定义。现在,实际上该类型是重载的(这意味着根据上下文可能应用其他类型定义),但这是主要的一个。另一个定义只处理没有给 useState
参数的情况,因此 initialStat
e 是未定义的。
function useState<S = undefined>(): [
S | undefined,
Dispatch<SetStateAction<S | undefined>>
];
7. 在 React 中使用泛型
既然我们已经理解了 TypeScript 泛型类型的一大致概念,让我们来看看如何实践于 React 的开发中。
7.1 像 useState 这样的 hook 的泛型类型
hook 只是一些普通的 JavaScript 函数,React 对它们的处理略有不同。由此可见,泛型类型与 hook 的使用与普通 JavaScript 函数的使用是相同的:
// 普通的 JavaScript 函数
const greeting = identity<string>('Hello World');
// useState
const [greeting, setGreeting] = useState<string>('Hello World');
在上面的例子中,你可以省略显式泛型类型,因为 TypeScript 可以从参数值推断出它。但有时候 TypeScript 做不到(或者做错了),这就是要使用的语法。
7.2 组件 prop 的泛型类型
假设你正在为表单构建一个 Select
组件。是这样的:
import { useState, ChangeEvent } from 'react';
function Select({ options }) {
const [value, setValue] = useState(options[0]?.value);
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
setValue(event.target.value);
}
return (
<select value={value} onChange={handleChange}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
export default Select;
// 使用 Select
const mockOptions = [
{ value: 'banana', label: 'Banana 🍌' },
{ value: 'apple', label: 'Apple 🍎' },
{ value: 'coconut', label: 'Coconut 🥥' },
{ value: 'watermelon', label: 'Watermelon 🍉' },
];
function Form() {
return <Select options={mockOptions} />;
}
假设对于选项的值,我们可以接受字符串或数字,但不能同时接受两者。如何在 Select
组件中强制执行呢?
下面的做法并没有按我们想要的方式进行,你知道为什么吗?
type Option = {
value: number | string;
label: string;
};
type SelectProps = {
options: Option[];
};
function Select({ options }: SelectProps) {
const [value, setValue] = useState(options[0]?.value);
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
setValue(event.target.value);
}
return (
<select value={value} onChange={handleChange}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
它不起作用的原因是,在一个选项数组中,可能有一个选项的值类型为 number
,而另一个选项的值类型为 string
。我们不希望这样,但 TypeScript 会接受它。
const mockOptions = [
{ value: 123, label: 'Banana 🍌' },
{ value: 'apple', label: 'Apple 🍎' },
{ value: 'coconut', label: 'Coconut 🥥' },
{ value: 'watermelon', label: 'Watermelon 🍉' },
];
强制要求数字或整数的方法是使用泛型:
type OptionValue = number | string;
type Option<Type extends OptionValue> = {
value: Type;
label: string;
};
type SelectProps<Type extends OptionValue> = {
options: Option<Type>[];
};
function Select<Type extends OptionValue>({ options }: SelectProps<Type>) {
const [value, setValue] = useState<Type>(options[0]?.value);
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
setValue(event.target.value);
}
return (
<select value={value} onChange={handleChange}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
花一点时间来理解上面的代码。如果你不熟悉泛型类型,那么它看起来可能非常奇怪。你可能会问为什么我们必须定义 OptionValue
然后在一堆地方放 extends OptionValue
。假设我们不这样做,而不是 Type extends OptionValue
我们只是用 Type
来代替。Select
组件如何知道类型 Type
可以是数字或字符串,而不是其他类型?
注 如果你在实际的编辑器中使用上述代码,你可能会在
handleChange
函数中得到一个 TypeScript 错误。这样做的原因是event.target.value
将被转换为字符串,即使它是一个数字。useState
期望类型Type
,它可以是一个数字。
我发现处理这个问题的最好方法是使用所选元素的索引,像这样:
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
setValue(options[event.target.selectedIndex].value);
}
8. 小结
我希望本文能帮助你更好地理解泛型类型是如何工作的。当你了解他们,他们不再那么可怕😊。泛型是 TypeScript 工具箱中创建优秀 TypeScript React 应用程序的重要组成部分,所以不要回避它们。