欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

玩转 TypeScript:深入理解与实际运用中的泛型机制

最编程 2024-07-27 21:59:45
...

写在最前:本文转自掘金

一、 泛型是什么

泛型可以用来创建重用的组件,一个组件可以支持多种类型的数据。这样用户就可以以自己的数据类型来使用组件。
设计泛型的关键目的是在成员之间提供有意义的约束,这些成员可以是:类的实例成员、类的方法、函数参数和函数返回值。

function identity(value:number):number{
  return value;
}
console.log(identity(1))  // 1

这里identity方法可以将number类型分配给参数和返回类型,使该函数仅可用该原始类型。但该函数并不是可扩展或通用的。

如果我们把number换成了any,那么就是去了定义返回哪种类型的能力,也会是去编译器类型保护的作用,为了实现任何特定的类型,我们可以使用泛型来解决这个问题,具体实现如下:

function identity<T>(value:T):T{
  return value;
}
console.log(identity<number>(1))  // 返回1 
// 更常见的做法是省略尖括号内的类型变量,编译器会自动选择类型
console.log(identity(1))  // 返回1 

其中T代表Type,在定义泛型时通常作第一个类型变量名称。但实际上可以用任何有效名称代替。除了T之外,以下是常见泛型变量代表的意思:

  • K(Key): 表示对象中键类型;
  • V(Value): 表示对象中值类型;
  • E(Element): 表示元素类型;

其实并不是只能定义一个类型变量,我们可以引入希望定义的任何数量的类型变量。比如我们引入一个新的类型变量 U,用于扩展我们定义的 identity 函数:

function identity <T, U>(value: T, message: U) : T {
  console.log(message);
  return value;
}
console.log(identity<number, string>(68, "Semlinker"));

相比之前定义的 identity 函数,新的 identity 函数增加了一个类型变量 U,但该函数的返回类型我们仍然使用 T。如果我们想要返回两种类型的对象该怎么办呢?针对这个问题,我们有多种方案,其中一种就是使用元组,即为元组设置通用的类型:

function identity <T, U>(value: T, message: U) : [T, U] {
  return [value, message];
}

虽然使用元组解决了上述的问题,但有没有其它更好的方案呢?答案是有的,你可以使用泛型接口。

二、泛型接口

为了解决上面提到的问题,首先让我们创建一个用于identity函数通用Identitied接口:

interface Identities<V,M>{
  value: v,
  message: M
}

在上述的Identities接口中,我们引入了类型变量VM,进一步说明有效的字母都可以用于表示类型变量,之后我们就可以将Identities接口作为identity函数的返回类型:

function identity<T, U> (value: T, message: U): Identities<T, U>{
  console.log(value + ": " + typeof (value));
  console.log(message + ": " + typeof (message));
  let identities: Identities<T, U> = {
    value,
    message
  };
  return identities;
}
console.log(identity(68, "Semlinker"));
// 68: number
// Semlinker: string
// {value: 68, message: "Semlinker"}

三、泛型类

在类中使用泛型也很简单,我们只需要在类名后面,使用<T,...>的语法定义任意多个类型变量,具体示例如下:

interface GenericInterface<U>{
  value: U;
  getIdentity: () => U;
}

class IdentityClass<T> implements GenericInterface<T>{
  value: T

  constructor(value: T){
    this.value = value
  }

  getIdentity(): T{
    return this.value
  }
}

const myNumberClass = new IdentityClass<Number>(68);
console.log(myNumberClass.getIdentity()); // 68

const myStringClass = new IdentityClass<string>("Semlinker!");
console.log(myStringClass.getIdentity()); // Semlinker!

接下来我们以实例化myNumberClass 为例,来分析一下其调用过程:

  • 在实例化IdentityClass对象时,我们传入number类型和构造函数的参数68;
  • 之后在IdentityClass 类中,类型变量T的值变成bumber类型;
  • IdentityClass类实现了GenericInterface<T>,而此时T表示number类型,因此等价于该类实现了GenericInterface<Number>接口;
  • 而对于GenericInterface<U>接口来说,类型变量U也变成了number

我们该在什么时候使用泛型呢?通常有以下两个参考标准:

  • 当你的函数、接口或类将处理多种数据类型时;
  • 当函数、接口或类在多个地方使用该数据类型时。

四、泛型约束

有时我们可能希望限制每个类型变量接受的类型数量,这就是泛型约束的作用。下面我们来举几个例子,介绍一下如何使用泛型约束。

4.1确保属性存在

需要约束变量对应的类型上存在某些属性时,这时,除非我们显式的将特定属性定义为类型变量,否则编译器不会知道它们的存在。

function identity<T>(arg: T): T{
  console.log(arg.length);  // error
  return arg
}

在这种情况下,编译器不会知道T确实含有length属性,我们需要做的是让类型变量extends一个含有我们所需属性的接口,比如这样:

interface Lenth{
  length: number;
}
function identity<T extends Length>(arg: T): T{
  console.log(arg.length)  // 可以获取length属性
  return arg
}

T extends Length 用于告诉编译器,我们支持已经实现Length接口的任何类型。之后,当我们使用不含有length属性的对象作为参数调用identity函数时,会提示相关错误。

此外,我们还可以使用,号来分隔多种约束,比如<T extends Length,Type2,Type3>。而对于上述的length属性问题,我们可以显式地将变量设置为数组类型,也可以解决该问题:

function identity<T>(arg: T[]): T[]{
  console.log(arg.length);  
  return arg; 
}

// or
function identity<T>(arg: Array<T>): Array<T> {      
  console.log(arg.length);
  return arg; 
}

4.2 检查对象上的键是否存在

在具体演示之前,我们先了解下keyof操作符,keyof操作符是TypeScript2.1版本引入的,该操作符可以用于获取某种类型的所有建,其返回类型是联合类型。举个例子:

interface Person{
  name: string;
  age: number;
  location: string;
}
type k1 = keyof Person; // 'name' | 'age' | 'location'
type k2 = keyof Person[]; // number | 'length' | 'push' |...
type k3 = keyof {[x: string]:Person}; // string | number

通过keyof操作符,我们就可以获取指定类型的所有键,之后我们就可以结合前面介绍的extends约束,即限制输入的属性名包含在keyof返回联合类型中,具体使用方式如下:

function getProperty<T, K extends keyof T>(obj: T, key:K): T[K]{
  return obj[key]
}

在以上的getProperty的函数中,我们通过K extends keyof T确保参数key一定是对象中含有的键,这样运行时就不会发生错误。这是一个类型安全的解决方案,与简单调用let value = obj[key] 不同。
下面我们来看下如何使用getProperty函数:

enum Difficulty {
  Easy,
  Intermediate,
  Hard
}

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

let tsInfo = {
   name: "Typescript",
   supersetOf: "Javascript",
   difficulty: Difficulty.Intermediate
}
 
let difficulty: Difficulty = 
  getProperty(tsInfo, 'difficulty'); // OK

let supersetOf: string = 
  getProperty(tsInfo, 'superset_of'); // Error

很明显通过使用泛型约束,在编译阶段我们就可以提前发现错误,大大提高了程序的健壮性和稳定性。接下来,我们来介绍一下泛型参数默认类型。

五、泛型参数默认类型

在TypeScript2.3之后,我们可以为泛型中的类型指定默认类型。当使用泛型时没有在代码中直接指定类型参数,从实际值参数中也无法推断出类型时,这个默认类型就会起作用。
泛型参数默认类型与普通函数默认值类似,对应的语法很简单,即<T=Default Type>,对应的使用示例如下:

interface A<T=string>{
  name: T;
}
let strA: A = { name: "semlinker" };
let numB: A<number> = { name: 101 };

泛型参数的默认类型遵循以下规则:

  • 有默认类型的类型参数被认为是可选的。
  • 必选的类型参数不能在可选的类型参数后。
  • 如果类型参数有约束,类型参数的默认类型必须满足这个约束。
  • 当指定类型实参时,你只需要指定必选类型参数的类型实参。未指定的类型参数会被解析为它们的默认类型。
  • 如果指定了默认类型,且类型推断无法选择一个候选类型,那么将使用默认类型作为推断结果。
  • 一个被现有类或接口合并的类或者接口的声明可以为现有类型参数引入默认类型。
  • 一个被现有类或接口合并的类或者接口的声明可以引入新的类型参数,只要它指定了默认类型。

六、 泛型条件类型

在TypeScript2.8中引入条件类型,使得我们可以根据某些条件得到不同的类型,这里所说的条件是类型兼容性约束。尽管以上代码中使用了extends关键字,也不一定要强制满足继承关系,而是检查是否满足结构兼容性。
条件类型会以一个条件表达式进行类型关系检测,从而在两种类型中选择其一:

T extends U ? X : Y

以上表达式的意思是: 若T能够赋值给U,那么类型是X,否则为Y。在条件类型表达式中,我们通常还会结合infer关键字,实现类型抽取:

interface Dictionary<T = any>{
  [key: string]: T;
}
type StrDict = Dictionary<string>

type DictMember<T> = T extends Dictionary<infer V> > V : never
type StriDictMember = DictMember<StrDict>  // string

在上面实例中,当类型T满足T extends Dictionary 约束时,我们会使用infer关键字声明一个类型变量V,并返回该类型,否则返回never类型。

七、 泛型工具类型

参考《深入讲解Ts中高级类型工具》文章第二部分

八、 使用泛型创建对象

8.1 构造签名

有时,泛型类可能需要传入的泛型T来创建其类型相关的对象,比如:

class FirstClass {
  id: number | undefined;
}
class GenericCreator<T> {
  create(): T {
    return new T();
  }
}

在以上代码中,我们定义了一个普通类,和一个泛型类GenericCreator<T>。在泛型类中,我们定义了一个名为create的成员方法,该方法会使用new关键字来调用传入的实际类型的构造函数,来创建对象,但是以上是代码并不能运行,因为编译器会抛出错误:T类型不能作为值使用。根据ts文档,为了使通用类能够创建T类型的对象,我们需要通过其构造函数来引用T类型。我们先来介绍下构造签名:

在ts接口中,你可以使用new关键字来描述一个构造函数:

interface Point{
  new (x: number, y: number): Point;
}

以上接口中的new (x: number, y: number)我们称为构造签名,其语法如下

ConstructSignature: new TypeParametersopt (ParameterListopt) TypeAnnotationopt
// TypeParametersopt(可选的类型参数)
// ParameterListopt(可选的参数列表)
// TypeAnnotationopt(可选的类型注解)

与该语法相对应的集中常见使用形式如下:

new C  
new C ( ... )  
new C < ... > ( ... )

看完构造签名,在介绍个与之相关的概念,构造函数类型。

8.2 构造函数类型

通过ts语言规范描述,我们可以得到以下结论:

  • 包含一个或多个构造签名的对象类型被称为构造函数类型;
  • 构造函数类型可以使用构造函数类型字面量或者包含构造签名的对象类型字面量来编写。

那么什么是构造函数类型字面量呢?构造函数类型字面量是包含单个构造签名的对象类型的简写。具体来说,构造函数字面量的形式如下:

new <T1, T2, ...>(p1, p2, ...) => R

该形式与以下对象类型字面量是等价的:

{new <T1, T2, ...>(p1, p2, ...) => R}

下面我们来举个例子:

// 构造函数类型字面量
new (x: number, y: number) => Point

等价于以下对象类型字面量:

{
   new (x: number, y: number): Point;
}

构造函数类型的应用

在介绍构造函数类型的应用前,先看下面这个例子:

interface Point {
  new (x: number, y: number): Point;
  x: number;
  y: number;
}

class Point2D implements Point {
  readonly x: number;
  readonly y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

const point: Point = new Point2D(1, 2);

以上代码,编译器会抛出错误:

不能将类型“Point2D”分配给类型“Point”。
类型“Point2D”提供的内容与签名“new (x: number, y: number): Point”不匹配。

相信刚接触ts不就的小伙伴都会遇到这种问题,要解决这个问题,我们需要将接口属性和构造函数类型进行分离:

interface Point {
  x: number;
  y: number;
}
interface PointConstructor{
  new (x: number, y: number): Point;
}

完成接口拆分后,除了签名已经定义的Point2D类之外,我们又定义了一个newPoint工厂函数,该函数用于根据传入的PointConstructor类型的构造函数,来创建对应的Point对象。

class Point2D implements Point {
  readonly x: number;
  readonly y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

function newPoint(
  pointConstructor: PointConstructor,
  x: number,
  y: number
): Point {
  return new pointConstructor(x, y);
}

const point: Point = newPoint(Point2D, 1, 2);

8.4 使用泛型创建对象

了解完构造签名和构造函数类型之后,下面我们解决一开始的问题,首先我们要重构下create方法,具体如下:

class GenericCreator<T>{
  create<T>(c: { new (): T }): T {
    return new c()
  }
}

以上代码中,根据create方法的签名,我们可以知道该方法接收一个参数,其类型是构造函数类型,且构造函数不包括任何参数,调用该构造函数类型后,会返回类型T的实例。

如果构造函数含有参数的话,我们可以这样定义:

create<T>(c: { new(a: number): T; }, num: number): T{
  return new c(num);
}

更新完 GenericCreator 泛型类,我们就可以使用下面的方式来创建 FirstClass实例:

const creator1 = new GenericCreator<FirstClass>();
const firstClass: FirstClass = creator1.create(FirstClass);