探秘Typescript·泛型的奥秘

84 阅读6分钟

探秘Typescript·泛型的奥秘

概念

泛型是提取了一类事物共性特征的抽象,比如说:小猫、小狗、小猪、学习鸡都是动物。

那么,是不是说上面的示例都要用泛型表达呢?其实,在程序里通常有以下三种表示抽象方式:

  • Interface - 接口
  • Inheritance - 继承
  • Generics - 泛型

继承

继承是一种强表达,举个例子:

小猫是动物,同时他也是宠物,这样的关系表达,如果我们要用继承来表示,那么就需要多重继承(小猫继承与动物和宠物),即:小猫 <- 宠物,小猫 <- 动物,或者是可以让小猫继承于宠物,宠物再继承于动物,即:小猫 <- 宠物 <- 动物

无论是上述哪一种表达方式,都容易增加程序设计的复杂性,增加了继承关系的维护成本(换句话说,他们之间耦合太严重了)。从这个角度来看,关系太强,其实也不见得一定是好事。

接口

接口是一种方面(Aspect)的描述。比如:

小猫是可以移动的,那么小猫就是:movable,动植物都是可以生长的,所以他们都是:growable

一个类型可能拥有多方面的特性。

泛型

泛型是对于共性的提取,而不仅仅是描述,例如:

class AnimalMove<T> {
  move(target: T): void;
}

const catMove = new AnimalMove<小猫>();
const dogMove = new AnimalMove<小狗>();

// 上述代码就是对动物的移动的共性进行描述,非共性的如具体是哪种动物,我们通过泛型的形式传入,这样,我们这个类就可以接受不同的动物,让这些动物都拥有相同的移动特性了。

可能有些同学会问了,直接在小猫、小狗中实现 move 方法,或者是让他们实现 IMovable 接口不可以么? 可以是可以,但我们通常不这么写,因为一个动物,他有太多特性了,如果把所有特性都统一的收敛到一个类里面,会让我们的类变得极其臃肿与耦合,让我们后期维护起来非常麻烦。 因此,我们更推荐的是抽象出某个共性,使用泛型去描述,这样可以让我们的程序更加灵活,扩展与维护也变得更加容易。

我们在设计IMovable接口时的目的是为了拆分描述事物的不同方面(关注点分离原则),如果仅仅使用接口,而不用泛型的话,那么关注点并没有做到完全的解耦。

**关注点(Interest Point)**在前端领域是相当重要的,举个例子:

  • Vue3为什么要做Composition API,是为了实现关注点分离,让我们把更多的注意力集中在一些共性的逻辑上
  • React为什么要实现Hooks API,同样是为了实现关注点分离

总结

  • 本质泛型是一种抽象共性的手段
  • 表现形式泛型允许将类型作为其他类型的参数
  • 作用实现关注点分离的目的

现有程序中的泛型

  • Array<T>抽离了数据可以被线性访问和存储的特性
  • Steam<T>抽离了数据可以随着时间产生的特性
  • Promise<T>抽离了数据可以被异步计算的特性
  • ...

渐进式泛型学习

首先,我们从一个函数式的程序实现入手,慢慢学习泛型的表现形式、使用场景、起到的作用。

// 这个函数是将自己的参数值直接作为返回值返回的函数
// 我们可以这么设计函数,但这就意味着,如果你的 id 如果编程其他类型,如 string 类型的话,我们就得额外再定义一个用来描述参数和返回值都是 string 的方法嘞
function identity(id: number): number {
  return id;
}
// 那么,既然我们的参数值和返回值类型不确定,是否可以这样呢?
function identity(id: any): any {
  return id;
}
// 上面的程序,为了让 identity 支持更多的类型,使用了 any,但这样无疑便失去了后续 Typescript 所提供的所有校验能力
// 为了使 identity 支持更多的类型,同时又不至于失去 Typescript 提供的校验能力,我们使用泛型来改造一下
function identity<T>(id: T): T {
  return id;
}
// 下面的泛型参数也可以省略,Typescript 会根据我们传入的值自动推导出类型,如:
// const id1 = identity("name");// 此时 id1 的类型为 string,因为方法规定了参数类型和返回值类型是一样的,此处参数类型是 string,那么,返回值类型自然也是 string 了。
const id1 = identity<string>("name");
const id2 = identity<number>(3);
// 我们可以看到,使用泛型之后,我们可以在使用时才确定传入参数和返回值的类型,这就让我们的一个方法定义可以适应各种类型的场景,同时又不会丢失类型校验的能力
const id3 = identity<boolean>(2);// TS 直接报错,因为我们期待获得的是一个布尔类型的值,但传入的却是 number 类型
const id4: number = identity<boolean>(false);// TS 直接报错,因为我们指定了参数和返回值都是布尔类型,但我们却期待得到一个数字类型的返回值,这明显是不可能的

相信大家通过上述的示例,应该大概了解了泛型的表示方式,部分使用场景、作用等,接下来我们就来正式学习一下“泛型”。

表示形式

  • <>:我们把这个符号叫做钻石操作符💎,在其中传递的就是泛型参数

泛型类

class Pipeline<T = any> {
  /**
   * 将多节管道链接起来
   * e.g.
   * const app = new BaseApp();
   * app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
   * @param _next
   */
  pipe(_next: Pipeline<T>): Pipeline<T>;
};

const p1 = new Pipeline<number>();
p1.pipe(new Pipeline<number>());// 由于规定了管道中只能传递数字类型的数据,因此,在 pipe 链接时只能传递 number 类型的管道
// 打个比方,我们一条管道,在还没有使用之前,可以用于输水,也可以用于输油,但一旦开始使用,我们就不会改变他的用途了,也就是说,一旦我们决定了,这个管道是用来输送石油的,那么就不能把管道的下一节接到输送水的管道上,否则既浪费了水,也浪费了油。

泛型约束

我们也可以为泛型添加一定的约束,使我们的程序设计更加严谨。

function getArraySize<T>(arg: T): number {
  // 报错,因为参数 arg 的类型是泛型,在没有使用前,我们是无法确定他的实际类型的,也无法从泛型中明确获得 length 这个属性的定义
  return arg.length;
}

此时,我们可以为这个泛型增加一定的约束:

interface WithLength {
  length: number;
}
function getArraySize<T extends WithLength>(arg: T): number {
  // 这样就不会报错了,因为我们规定了,泛型 T 必须继承 WithLength 接口的特性,也就是,我们在使用时,传入的类型必须包含 length,且其类型是 number 的类型,否则就会报错
  return arg.length;
}

getArraySize([]);// 0
getArraySize("name");// 4
getArraySize(2);// 报错“类型“number”的参数不能赋给类型“WithLength”的参数。”,因为 number 类型的数据不存在 length 属性

小技巧

我们可以用keyof关键字作为泛型的约束,如:

type Point = {
	x: number,
	y: number
}
// 其中 keyof Point = x | y,也就是要么就是 x,要么就是 y
function getPointByXOrY<T extends keyof Point>(arg: T): Point {
  // ...
}

getPointByXOrY("z");// 报错“类型“"z"”的参数不能赋给类型“keyof Point”的参数”

// 再来看一个我们更常见和使用的场景
getProperty<T, K extends keyof T>(object: T, key: K) {
  return object[key];
}
const obj = {name: "kiner", age: 28};
getProperty(obj, "name");

那么,我们再来思考一下,为什么在 Typescript中,为什么可以这么做呢?

因为Typescript中,对象的key都是静态的,如:{x: 1, y:2}中,key就只能是"x" | "y",这不像是在Javascript中,我们可以肆意的添加和删除属性,这也为我们这个根据类型进行推导他的key提供了基础。

示例化泛型类型

Typescript当中,如果将一个类作为参数传入一个函数,并期望返回该类的实例化对象返回,我们应该如何定义类型呢?

function create<T>(cls: {new (): T}): T {
  return new cls();
}
const date = create(Date);
console.log(date.valueOf());
const date2 = create(new Date());// 报错:“类型“Date”的参数不能赋给类型“new () => unknown”的参数。类型“Date”提供的内容与签名“new (): unknown”不匹配。”