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

1,439 阅读6分钟

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

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

1. React-Router 中的 useParams

在业务开发中使用 React Router 时,很可能会遇到下面这个问题:

解决方案:

2. 模板字面类型的妙用

TypeScript 4.1 中引入的模板字面类型(Template Literal Types)是类型定义的一个有趣转折。它允许你使用字符串连接动态构建类型。现在,你可以拥有和你一样有趣、富有表现力的类型。

比如你可以这样用:

image.png

3. 映射类型中的键重映射

键重映射可以让你的对象类型在不破坏整个代码库的情况下不断扩展。比如:

type Employee = { firstName: string, lastName: string };  
type ModifiedEmployee = { [K in keyof Employee as `super_${K}`]: Employee[K] };

image.png

4. 递归类型别名

递归类型别名(Recursive Type Aliases)使复杂的嵌套数据结构导航变得简单易行。可以把它想象成类型的 "俄罗斯套娃"。一层嵌套另一层,可以优雅地描述树和链表等无限嵌套结构。

type Tree<T> = {  
    value: T;  
    children?: Tree<T>[];  
};

5. 映射类型和修饰符

比如,我们日常可能会遇到这种情况,可以用映射类型来copy基础类型中的全部类型:

type User = {
  name: string
  age: number
  userName: string
}

type AdvancedUser = {
  [Property in keyof User]: User[Property]
  // ...
}

还可以加一些限定符,比如 readonly:

type AdvancedUser = {
  readonly [Property in keyof User]: User[Property]
  // ...
}

有时候,遇到基础类型中有 readonly 限定符,但又不希望新类型是只读,那就可以使用“-”来删除类型中的所有 readonly 标志即可:

type User = {
  readonly name: string
  readonly age: number
  readonly userName: string
}

type AdvancedUser = {
  -readonly [Property in keyof User]: User[Property];
};

// 会得到 👇
type AdvancedUser = {
    name: string;
    age: number;
    userName: string;
}

同样的,在遇到可选修饰符的时候,也可以这样:

type User = {
  name?: string
  age?: number
  userName: string
}

type AdvancedUser = {
  [Property in keyof User]-?: User[Property];
};

// 会得到 👇
type AdvancedUser = {
    name: string;
    age: number;
    userName: string;
}

结合上面模板字面类型:

type User = {
  name: string
  age: number
  userName: string
}

type RenameKey<Type> = {
  [Property in keyof Type as `canUpdate${string & Property}`]: Type[Property]
}

type AdvancedUser = RenameKey<User>

// 会得到 👇
type AdvancedUser = {
    canUpdatename: string;
    canUpdateage: number;
    canUpdateuserName: string;
}

还可以通过 Capitalize 来固定属性名的大小写:

type User = {
  name: string
  age: number
  userName: string
}

type Copy<Type> = {
  [Property in keyof Type as `canUpdate${Capitalize<string & Property>}`]: Type[Property];
};

type AdvancedUser = Copy<User>

// 会得到 👇
type AdvancedUser = {
    canUpdateName: string;
    canUpdateAge: number;
    canUpdateUserName: string;
}

结合其他的 Utility Types 使用:

type User = {
  name: string
  age: number
  userName: string
}

type CopyWithoutKeys<Type, Keys> = {
  [Property in keyof Type as Exclude<Property , Keys>]: Type[Property];
};

type UserCopyWithoutNameAndUsername = CopyWithoutKeys<User, 'name' | 'userName'>

// 会得到 👇
type UserCopyWithoutNameAndUsername = {
    age: number;
}

6. 类型缩小

6.1 什么是类型缩小?

类型缩小就像它听起来的那样——将一般类型缩小为更精确的类型。如果你曾经处理过联合类型,string | number那么你肯定遇到过这种情况。事实上,可选类型x?: number通常也需要缩小范围,因为这种类型等同于x: number | undefined. 在这两种情况下,你可能需要在代码中处理每种情况,为此你需要先缩小类型范围。

6.2 缩小这些类型的方法

要将联合类型缩小为一个,我们需要考虑每种情况。我们可以使用 JavaScript 中良好的老式控制流来做到这一点,因为 TypeScript 编译器足够聪明,可以从我们的条件逻辑中推断出缩小范围。通常,这仅意味着使用if或switch语句。

让我们考虑一个常见的、真实世界的例子,我相信你们都写过一两次:一个返回给定类型糖果的美味分数的函数。

type Candy =
  | { name: "Skittles"; type: "Regular" | "Tropical" }
  | { name: "Black Licorice"; qty: number }
  | { name: "Runts"; isBanana: boolean };

function rateCandy(candy: Candy): number {
  switch (candy.name) {
    case "Skittles":
      return candy.type === "Regular" ? 8 : 7;
    case "Black Licorice":
      return candy.qty * -1;
    case "Runts":
      return candy.isBanana ? 11 : 5;
    default:
      throw new Error(`"${candy}" is not a valid candy!`);
  }
}

因为这些糖果每个都共享一个公共字段 ( name),我们可以使用它来缩小特定类型糖果的范围,并使用独特的字段,例如type和isBanana而不会混淆 TypeScript。

当然,我们也可以用if语句来写这个,但你明白了。由于我们的条件逻辑是详尽无遗的,在我们的例子中,TypeScript 实际上推断出一个never类型,这意味着永远不会抛出错误(除非我们竭尽全力欺骗编译器)。candydefault

6.2.1 使用类型

假设我们有一个double接受字符串或数字参数的函数。当给定一个字符串时,我们重复它,当给定一个数字时,我们将它乘以二。为此,我们可以使用typeof运算符来缩小我们的输入范围,并以 TS 可以理解的方式处理每种情况。

function double(x: string | number) {
  if (typeof x === 'string') {
    return x.repeat(2);
  } else {
    return x * 2;
  }
}

所以现在double(5)returns 10,double('Pop!')returns Pop!Pop!, TypeScript 非常高兴。

6.2.2 in 和 instanceof 运算符

假设我们有一个函数来获取电影或连续剧的总长度。

type Movie = {
  title: string;
  releaseDate: Date | string;
  runtime: number;
}

type Show = {
  name: string;
  episodes: {
    releaseDate: Date | string;
    title: string;
    runtime: number;
  }[];
}

function getDuration(media: Movie | Show) {
  if ('runtime' in media) {
    return media.runtime;
  } else {
    return media.episodes.reduce((sum, { runtime }) => sum + runtime, 0);
  }
}

Movie这是可行的,因为我们能够使用运算符检查唯一的顶级字段in,并单独处理唯一可能的情况(Show类型)。

但是,如果我们想获得节目或电影首映的年份怎么办?我们可以getFullYear在日期对象上使用,但如果它是日期字符串,我们必须将其转换为Date第一个。幸运的是,TypeScript 让我们可以使用运算符安全地缩小范围instanceof。

function getPremiereYear(media: Movie | Show) {
  const releaseDate =
    "releaseDate" in media ? media.releaseDate : media.episodes[0].releaseDate;

  if (releaseDate instanceof Date) {
    return releaseDate.getFullYear();
  } else {
    return new Date(releaseDate).getFullYear();
  }
}

如果你不熟悉instanceof,它只是计算为一个布尔值,表示表达式的左侧是否是右侧对象的实例。你也可以使用它instanceof来检查自定义类的实例。

6.2.3 类型谓词

现在来看一个你可能已经遇到过的更高级的案例。如果我们向用户询问他们最喜欢的食物,但将该信息设为可选,我们最终可能会得到如下数据:

const favoriteFoods = [
  'Pizza',
  null,
  'Cheeseburger',
  'Wings',
  null,
  'Salad?',
];

我们可以用这样的东西过滤掉空值:

const validFavoriteFoods = favoriteFoods.filter(food => food != null); 

// 或者,如果我们想排除所有的假植
const validFavoriteFoods = favoriteFoods.filter(Boolean);

不幸的是,虽然这确实设法过滤掉了空值,但 TypeScript 不够聪明,无法确定并且仍会为......推断出一种(string | null)[]类型。validFavoriteFoods

在这种情况下,我们可以利用自定义类型保护,它基本上是一个返回布尔值的函数,用于确定参数是否为特定类型。我们通过使用所谓的“类型谓词”作为该函数的返回类型来做到这一点。

function isValidFood(food: string | null): food is string {
  return food !== null;
}

这个方便的类型保护让我们安全地处理空值,例如

for (const food of favoriteFoods) {
  if (isValidFood(food)) {
    console.log(food.toUpperCase());
  }
}

现在没有运行时错误或编译器错误——生活很美好!对于像这样的常见模式,我们可以通过组合使用 TS 实用程序类型、泛型和类型谓词来获得更好的效果:

const isNotNullish = <T>(value: T): value is NonNullable<T> => value != null;

现在,如果我们将其用作我们的过滤器,我们将获得我们最初期望的类型。

const validFavoriteFoods = favoriteFoods.filter(isNotNullish); // string[]

6.3 关于类型断言的说明

类型断言通常用于对编译器说“相信我,兄弟”。如果你不熟悉类型断言,它们通常如下所示:

const user = {} as User;

或者(如果你不使用 tsx 并且更喜欢尖括号语法):

const user = <User>{};

虽然有时感觉这是不可避免的,但上面概述的模式通常是更好的选择,因为它们不会削弱应用程序的类型安全性。除了缩小技术之外,类型注释也可能是一种更安全的选择,例如:

const user: User = res.data;

请注意,如果你要注释的数据具有any类型,那么即使使用类型注释方法,你也只会给自己一种类型安全的错觉。

在处理api数据的时候经常会出现这个问题。如果你完全相信 api 将遵守数据协定并且不会意外更改,那么这可能是类型断言的可接受用例。

但是,如果你使用的 api 不断变化,并不完全可靠,或者你只是有点偏执,那么有几个库可以提供帮助。Zodio-ts等工具通过运行时架构验证缓解了这些问题,因此当 api 返回意外时,你不会最终调试应用程序代码中的下游问题。