面向 React 开发人员的高级 TypeScript - 第 3 部分

2025-06-08

面向 React 开发人员的高级 TypeScript - 第 3 部分

图片描述

这是“面向 React 开发者的 TypeScript 高级教程”系列的第三篇文章。在前几章中,我们与雄心勃勃的开发者 Judi 一起,了解了TypeScript 泛型在创建可复用 React 组件方面的作用,并理解了类型保护、keyof、typeof、is、as const 和索引类型等 TypeScript 概念。我们在与 Judi 合作开发亚马逊的竞争对手产品时做到了这一点:一个拥有不同商品类别并支持通过 select 组件进行选择的在线网站。现在是时候再次改进这个系统了,并在此过程中学习详尽性检查的目的是什么、类型缩减是如何工作的以及TypeScript 枚举在什么时候会派上用场。

您可以在codesandbox中看到我们开始的示例的代码

使用 never 检查彻底性

让我们回顾一下我们是如何实现带有类别的 Tab 的。我们有一个字符串数组,一个switch为每个 Tab 返回一个 select 组件的 case,以及一个用于类别本身的 select 组件。

const tabs = ["Books", "Movies", "Laptops"] as const;
type Tabs = typeof tabs;
type Tab = Tabs[number];

const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      return (
        <GenericSelect<Book> ... />
      );
    case "Movies":
      return (
        <GenericSelect<Movie> ... />
      );
    case "Laptops":
      return (
        <GenericSelect<Laptop> ... />
      );
  }
};

export const TabsComponent = () => {
  const [tab, setTab] = useState<Tab>(tabs[0]);

  const select = getSelect(tab);

  return (
    <>
      Select category:
      <GenericSelect<Tab>
        onChange={(value) => setTab(value)}
        values={tabs}
        formatLabel={formatLabel}
      />
      {select}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

所有代码都经过了完美的类型校验,所以如果任何地方出现拼写错误,Typescript 都能识别出来。但是,代码真的完美吗?如果我想在列表中添加一个新类别,会发生什么Phones?这似乎很简单:我只需将其添加到数组和 switch 语句中即可。

const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;

const getSelect = (tab: Tab) => {
  switch (tab) {
    // ...
    case "Phones":
      return (
        <GenericSelect<Phone> ... />
      );
  }
};
Enter fullscreen mode Exit fullscreen mode

在像这样的简单实现中,这不会带来太多麻烦。但在实际应用中,这些代码很可能会被分离、抽象,并隐藏在层层实现之后。如果我只是将 Phones 添加到数组中,而忘记了 switch case,会发生什么?

const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;

const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      // ...
    case "Movies":
      // ...
    case "Laptops":
      // ...
  }
};
Enter fullscreen mode Exit fullscreen mode

不幸的是,这种实现方式没什么好处。Typescript 完全可以接受,手动测试时可能会遗漏这个 bug,它会进入生产环境,而且当客户在菜单中选择“电话”时,屏幕上什么也看不到。

但情况并非一定如此。当我们使用if或 之类的运算符时, switchTypeScript 会执行所谓的“缩小”操作,即在每个语句中减少联合类型的可用选项。例如,如果我们有一个只包含“Books”的 switch case,那么“Books”类型将在第一个case语句中被消除,但其余类型将在稍后可用:

const tabs = ["Books", "Movies", "Laptops"] as const;

// Just "Books" in the switch statement
const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      // tab's type is Books here, it will not be available in the next cases
      return <GenericSelect<Book> ... />
    default:
      // at this point tab can be only "Movies" or "Laptops"
      // Books have been eliminated at the previous step
  }
};
Enter fullscreen mode Exit fullscreen mode

如果我们使用所有可能的值,typescript 将表示永远不会存在的状态作为never类型。

const tabs = ["Books", "Movies", "Laptops"] as const;

const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      // "Books" have been eliminated here
    case "Movies":
      // "Movies" have been eliminated here
    case "Laptops":
      // "Laptops" have been eliminated here
    default:
      // all the values have been eliminated in the previous steps
      // this state can never happen
      // tab will be `never` type here
  }
};
Enter fullscreen mode Exit fullscreen mode

仔细观察手势,你会发现这个技巧:在这种“不可能”的状态下,你可以明确指出 tab 应该是nevertype。如果出于某种原因,这并非不可能(例如,我们向数组中添加了“Phones”,但没有添加switch),TypeScript 就会失败!

// Added "Phones" here, but not in the switch
const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;

// Telling typescript explicitly that we want tab to be "never" type
// When this function is called, it should be "never" and only "never"
const confirmImpossibleState = (tab: never) => {
  throw new Error(`Reacing an impossible state because of ${tab}`);
};

const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      // "Books" have been eliminated
    case "Movies":
      // "Movies" have been eliminated
    case "Laptops":
      // "Laptops" have been eliminated
    default:
      // This should be "impossible" state,
      // but we forgot to add "Phones" as one of the cases
      // and "tab" can still be the type "Phones" at this stage.

      // Fortunately, in this function we assuming tab is always "never" type
      // But since we forgot to eliminate Phones, typescript now will fail!
      confirmImpossibleState(tab);
  }
};
Enter fullscreen mode Exit fullscreen mode

现在实现完美了!任何拼写错误都会被 TypeScript 识别出来,不存在的类别也会被识别出来,甚至遗漏的类别也会被识别出来!顺便说一下,这个技巧叫做“详尽性检查” 。

无需 never 的彻底性检查

有趣的是,要使穷举技巧奏效,你实际上并不需要 never类型和“不可能”状态。你只需要理解这个缩小和消除的过程,以及如何在最后一步“锁定”所需的类型。

还记得吗,我们有一个formatLabel传递给选择组件的函数,该函数根据值类型返回选择选项所需的字符串?

export type DataTypes = Book | Movie | Laptop | string;

export const formatLabel = (value: DataTypes) => {
  if (isBook(value)) return `${value.title}: ${value.author}`;
  if (isMovie(value)) return `${value.title}: ${value.releaseDate}`;
  if (isLaptop(value)) return value.model;

  return value;
};
Enter fullscreen mode Exit fullscreen mode

另一个完全一样的 bug 的完美候选——如果我们将其添加Phone为数据类型之一,但忘记了实际的检查,会发生什么?在当前的实现下——同样不会有什么好结果,手机选择选项将会失效。但是,如果我们将详尽的知识应用到函数中,我们可以这样做:

export type DataTypes = Book | Movie | Laptop | Phone | string;

 // When this function is called the value should be only string
 const valueShouldBeString = (value: string) => value;

 const formatLabel = (value: DataTypes) => {
  // we're eliminating Book type from the union here
  if (isBook(value)) return `${value.title}: ${value.author}`;

  // here value can only be Movie, Laptop, Phone or string

  // we're eliminating Movie type from the union here
  if (isMovie(value)) return `${value.title}: ${value.releaseDate}`;

  // here value can only be Laptop, Phone or string

  // we're eliminating Laptop type from the union here
  if (isLaptop(value)) return value.model;

  // here value can only be Phone or string

  // But we actually want it to be only string
  // And make typescript fail if it is not
  // So we just call this function, that explicitly assigns "string" to value

  return valueShouldBeString(value);

  // Now, if at this step not all possibilities are eliminated
  // and value can be something else other than string (like Phone in our case)
  // typescript will pick it up and fail!
};
Enter fullscreen mode Exit fullscreen mode

在最后一步,我们排除了除string、 和“锁定”字符串之外的所有可能的联合类型。是不是很棒?

请参阅此 codesandbox 中的完整工作示例。

使用枚举提高代码可读性

现在是时候对这幅精美的 TypeScript 作品——也就是我们的类别实现——进行最后的润色了。我不知道你是怎么想的,但这部分我有点担心:

const tabs = ["Books", "Movies", "Laptops"] as const;
type Tabs = typeof tabs;
type Tab = Tabs[number];
Enter fullscreen mode Exit fullscreen mode

它本身并没有什么问题,只是每次看到这样的结构时,我的脑子都会有点崩溃。我总得额外花一两秒钟才能理解这里到底发生了什么。幸运的是,对于那些遇到同样问题的人来说,有一种方法可以改进它。你知道 TypeScript 支持枚举吗?它们允许定义一组命名常量。最棒的是,它们从一开始就是强类型的,你可以同时使用同一个枚举作为类型和值。🤯

基本上是这样的:

const tabs = ["Books", "Movies", "Laptops"] as const;
type Tabs = typeof tabs;
type Tab = Tabs[number];
Enter fullscreen mode Exit fullscreen mode

可以用这个来代替,可以说它更容易阅读,也更直观:

enum Tabs {
  'MOVIES' = 'Movies',
  'BOOKS' = 'Books',
  'LAPTOPS' = 'Laptops',
}
Enter fullscreen mode Exit fullscreen mode

然后,当您需要访问特定值时,您可以使用点符号,就像对象一样:

const movieTab = Tabs.MOVIES; // movieTab will be `Movies`
const bookTab = Tabs.BOOKS; // bookTab will be `Books`
Enter fullscreen mode Exit fullscreen mode

只需Tabs在您想要引用枚举作为类型时使用即可!

如果我们查看标签代码,我们可以将所有标签类型替换为枚举标签,将所有标签字符串替换为枚举值:

图片描述

并且,在Tabs组件的实际实现中也是一样:替换类型,替换值,并将枚举的值以数组的形式传递给选择组件:

图片描述

请参阅此 codesandbox 中的完整代码示例。

完美!😍😎

今天就到这里,希望你喜欢这篇文章,并且对 TypeScript 的收缩、穷举检查和枚举功能更加有信心了。下次再见 😉

...

最初发表于https://www.developerway.com。该网站还有更多类似的文章 😉

订阅时事通讯在 LinkedIn 上联系在 Twitter 上关注,以便在下一篇文章发布时立即收到通知。

鏂囩珷鏉ユ簮锛�https://dev.to/adevnadia/advanced-typescript-for-react-developers-part-3-p4j
PREV
如何在 React 中不失理智地进行去抖动和节流
NEXT
使用 Lighthouse 揭示 JavaScript 性能