likes
comments
collection
share

浅谈 React 和 TypeScript 开发中的泛型实践

作者站长头像
站长
· 阅读数 26

浅谈 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 类型,应用泛型类型 SetStateActionSetStateAction 本身是应用泛型类型 SSetStateAction 类型(它是我们的状态 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 参数的情况,因此 initialState 是未定义的。

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 应用程序的重要组成部分,所以不要回避它们。

转载自:https://juejin.cn/post/7159942291023462407
评论
请登录