【React】一些实际项目中的 TypeScript 技巧(一)~

2,234 阅读9分钟

在React项目中熟练使用TypeScript已经是每个前端开发人员必备的技能,如果你还在用“any”一把搜哈,那你可能不需要 TypeScript!

TypeScript有神好处呢?它可以在编译时捕获许多常见的错误,如类型不匹配、属性不存在等。这可以大大减少在运行时出现的错误,并提高代码的可靠性和稳定性。通过为函数、接口、类型和变量添加类型注解,可以使代码更加可读,使其他开发人员更容易理解你的意图,这对于团队合作和维护代码库非常有帮助。

1. 枚举类型

在 TypeScript 中,要获取枚举(enum)的键(key)的类型,可以使用 keyof 关键字结合类型引用来实现。下面是一个示例:

enum MyEnum {
  Key1 = 'Value1',
  Key2 = 'Value2',
  Key3 = 'Value3',
}

type MyEnumKeys = keyof typeof MyEnum;

// MyEnumKeys 的类型为 "Key1" | "Key2" | "Key3"

keyof typeof MyEnum 表达式返回了 MyEnum 枚举的键的类型。在这个例子中,MyEnumKeys 的类型将是 "Key1" | "Key2" | "Key3"。你可以根据需要将这个类型用于其他用途,比如函数参数、变量声明等。

注意,使用 keyof 运算符获取的是枚举的键的类型,而不是枚举成员的值的类型。如果你需要获取枚举成员的值的类型,可以使用索引访问类型(index access type)来实现。例如:

enum MyEnum {
  Key1 = 'Value1',
  Key2 = 'Value2',
  Key3 = 'Value3',
}

type MyEnumValues = MyEnum[keyof typeof MyEnum];

// MyEnumValues 的类型为 "Value1" | "Value2" | "Value3"

MyEnumValues 的类型将是 "Value1" | "Value2" | "Value3",它表示了枚举成员的值的类型。

2. 将 JSX 作为 Prop 传递

React 的 Prop 非常强大,可以将组件作为 Prop 传递,可以使组件更具可重用性。将组件作为道具传递的最灵活方法之一就是让组件接收 JSX。来看看下面的例子:

interface LayoutProps {
  nav: React.ReactNode;
  children: React.ReactNode;
}
 
const Layout = (props: LayoutProps) => {
  return (
    <>
      <nav>{props.nav}</nav>
      <main>{props.children}</main>
    </>
  );
};
 
// 使用
<Layout nav={<h1>My Site</h1>}>
  <div>Hello!</div>
</Layout>;

我们将 props 定义为 React.ReactNode 类型,这是一种可接受任何有效 JSX 的类型。请注意,我这里没有使用 React.ReactElementJSX.Element

3. 将组件作为 Prop 传递

第二种方法是,我们不把 JSX 作为 props 传入,而是把整个组件作为 props 传入。

需要注意的是:JSX 是组件返回的内容,<Wrapper /> 是 JSX。Wrapper 是组件。

在 TypeScript 中,最简单方法是使用 React.ComponentType:

const Row = (props: {
  icon: React.ComponentType<{
    className?: string;
  }>;
}) => {
  return (
    <div>
      <props.icon className="h-8 w-8" />
    </div>
  );
};
 
<Row icon={UserIcon} />;

在这里,我们将图标组件定义为 React.ComponentType 类型。我们将 { className?: string } 传递给 React.ComponentType,表示这是一个可以接收 className 的组件。

这基本上表示图标可以是任何可以接收 className 的组件。这是一种非常灵活的类型,而且易于使用。

4. 将原生标签作为 Prop 传递

使用 React.ElementType 可以将本地标签作为 props 或自定义组件传递。

const Row = (props: {
  element: React.ElementType<{
    className?: string;
  }>;
}) => {
  return (
    <div>
      <props.element className="h-8 w-8" />
    </div>
  );
};
 
<Row element={"div"} />;
<Row element={UserIcon} />;

这是一个非常灵活的定义,而且非常容易使用。我们甚至可以自动完成传递给元素的所有选项。

我会倾向于将 JSX 作为 props 传递。它不仅容易定义类型(React.ReactNode),而且对性能非常友好。当父组件重新渲染时,作为道具传递给组件的 JSX 不会重新渲染。这可以极大地提升性能。

5. 使用新特性 'Satisfies'

satisfies 运算符提供了一种为值添加类型注解而不丢失值推理的方法。

5.1 使用 satisfies 的强类型 URLSearchParams

satisfies 非常适合强类型化函数,这些函数通常使用更宽松的类型。

在使用 URLSearchParams 时,它的参数通常是 Record<string,string>。这是一种非常松散的类型,并不强制执行任何特定的键。

但通常情况下,你需要创建一些搜索参数并将它们传递给 URL。因此,这种松散的类型最终会变得相当危险。

这时,satisfies 来救你了。你可以使用它来对 params 对象进行强类型内联。

image.png

在这里,我们会收到一个错误信息,提示我们缺少一个属性正文。这很好,因为这意味着我们不会意外创建一个没有正文的 URL。

5.2 带 satisfies 的强类型 POST 请求

在发出 POST 请求时,向服务器发送正确的数据结构非常重要。服务器会期望请求正文有特定的格式,但使用 JSON.stringify 将其转化为 JSON 的过程会消除任何强类型。

但通过 satisfies 操作符,我们可以对其进行强类型。

type Post = {
  title: string;
  content: string;
};
 
fetch("/api/posts", {
  method: "POST",
  body: JSON.stringify({
    title: "New Post",
    content: "Lorem ipsum.",
  } satisfies Post),
});

在这里,我们可以用 Post 类型注释请求正文,确保标题和内容属性存在且类型正确。

5.3 用 satisfies 而不是 as const 来推断元组

通常,你会希望在 TypeScript 中声明一个元素数组,但将其推断为元组而非数组。你可能会使用 as const 断言来推断元组类型,而不是数组类型。然而,使用 satisfies 操作符,你可以在不使用 as const 的情况下获得相同的结果。

type MoreThanOneMember = [any, ...any[]];
const array = [1, 2, 3];
//    ^?
const maybeExists = array[3];
//    ^?
const tuple = [1, 2, 3] satisfies MoreThanOneMember;
//    ^?
const doesNotExist = tuple[3];

在上面的代码中,我们以两种不同的方式声明数组。如果我们不使用 satisfies 对其进行注解,它就会被推断为 number[]。这意味着当我们尝试访问数组中不存在的元素时,TypeScript 不会给出错误信息;它只是将其推断为 number | undefined

然而,当我们使用 satisfies 操作符声明元组时,它会推断出该类型是一个包含三个元素的元组。现在,当我们尝试使用 tuple[3] 访问第四个元素时,TypeScript 正确地给出了一个错误,因为索引超出了边界。

5.4 使用 satisfies 强制 as const 对象成为某种类型

使用 as const 时,我们可以指定将对象视为具有字面类型的不可变值。但是,这并不能强制对象具有任何特定的形状或属性。要强制 as const 对象具有特定形状,我们可以使用 satisfies 操作符。

在下面的示例中,我们有一个 RouteObject 类型,它代表了一个路由集合。每个路由都有一个字符串类型的 url 属性和一个可选的 searchParams 属性。我们要确保路由对象满足 RouteObject 类型。

image.png

这不仅能在属性丢失的情况下为我们提供极大的错误提示,还能为路由对象提供自动完成功能。

5.5 使用 satisfies 强制 as const 数组为特定类型

同时使用 satisfiesas const 和数组可能有点麻烦。

举个例子,我们有一个导航菜单,它由带有标题的元素、可选的 URL 以及在 children 属性下嵌套导航元素的可选数组组成。

type NavElement = {
  title: string;
  url?: string;
  children?: readonly NavElement[];
};
 
const nav = [
  {
    title: "Home",
    url: "/",
  },
  {
    title: "About",
    children: [
      {
        title: "Team",
        url: "/about/team",
      },
    ],
  },
] as const satisfies readonly NavElement[];

现在,如果我们试图访问不属于已定义形状的属性,TypeScript 会给出错误信息。

image.png

readonly 数组中使用 satisfies

需要注意的是在数组中使用了 readonly。如果子数组上没有 readonly,TypeScript 就会出错:

image.png

这是因为 NavElement[] 是可变的,所以需要标记为 readonly 以与 const 匹配。

如果我们漏掉最后的 readonly,情况也是一样:

image.png

这是因为我们的外部类型 NavElement[] 是可变的,因此不能赋值给 readonly as const 声明。

6. Array<T> vs T[]: 哪个更好?

在 TypeScript 中声明数组类型时,有两种选择:Array<T>T[]React Query 的维护者之一 Dominik (@TKDodo) 最近发布了一篇文章,介绍了应该选择哪种方式。他强烈主张使用 Array<T>

  • 实际上,Array<T> 和 T[] 在功能上是相同的:
const firstTest = (arr: Array<string>) => {};
 
const secondTest = (arr: string[]) => {};
 
// 是一样的
firstTest(["hello", "world"]);
secondTest(["hello", "world"]);
  • 但,在 T[] 使用 keyof 将会报错:

image.png

解决办法是使用 Array<T>

const result: Array<keyof Person> = ["id", "name"];
  • Dominik 认为 Array<string>string[] 更好读。这很主观,就像读 "字符串数组"(array of strings)和 "字符串数组"(string array)的区别一样。

  • 在悬停值或显示错误时,TypeScript 使用 T[] 语法。没有经验的 TS 开发人员在转换代码中的 Array<T> 和错误中的 T[] 时,可能会产生认知负担。

image.png

  • 总的来说,Dominik 的观点(即 Array<T> 总是更好的选择)并不完全正确。无论是哪种方法,都有足够多的注意事项,因此我不会做出这样或那样的推荐。
  • 但是,你应该保持一致。可以使用 ESLint 规则 在代码库中强制使用其中一种方法。如果让我选择,我会选择 T[]

6.1 无功能上的差异

很多开发人员喜欢语法上的争论,尤其是当两个选项在功能上差别不大的时候。如上所述,Array<T>T[] 的行为完全相同,但有一个小例外。

image.png

在这里,当我们尝试在休息位置使用 T[] 语法时,会出现错误。但正如本 PR 所示,在未来的 TS 版本中,即使是这种行为也可能会消失。因此,我们可以将两者视为功能相同。

6.2 keyof

如果要确定使用哪种语法,就需要考虑 keyof 操作符。如上所述,使用 T[]keyof 可能会导致意想不到的结果。

image.png

在这里,你会认为 keyof Person 会在 [] 操作符启动之前解析,这意味着你最终会得到一个类似 ('id' | 'name')[] 的类型。但不幸的是,[] 首先解析,所以你最终在 Person[] 上执行了 keyof

你可以用括号将 keyof Person 包起来来解决这个问题:

const result: (keyof Person)[] = ["id", "name"];

或者,你用 Array<T> 代替:

image.png

6.3 可读性

Dominik 认为 Array<T>T[] 更可读。你可能会同意这个观点,但我认为这是主观臆断。

6.3.1 Readonly Arrays

如果您想与 Array<T> 保持一致,您可能还想使用 ReadonlyArray<T> 类型:

image.png

readonly T[] 比较:

image.png

您更喜欢哪一种?我觉得这个很难区分。

6.3.2 多维数组

在处理数组的数组时,您还需要考虑 Array<Array<T>>:

const array: Array<Array<string>> = [
  ["hello", "world"],
  ["foo", "bar"],
];

比较 T[][] 写法:

const array2: string[][] = [
  ["hello", "world"],
  ["foo", "bar"],
];

哪个更好?

6.4 TypeScript Uses T[]

TypeScript 确实对它更喜欢哪种语法提出了自己的看法。在悬停和错误中,TypeScript 将始终使用 T[] 语法。

image.png

这意味着,如果您在代码中使用 Array<T>,经验不足的 TypeScript 开发人员在两种语法之间转换时会遇到一些认知负担。

这就是为什么至少对我来说,T[] 感觉更自然的一个重要原因 —— 它更多地存在于语言中,受到编译器的支持,并且在文档中随处可见。