理解 TypeScript 泛型

2,906 阅读6分钟

什么是泛型(Generics)

定义:泛型允许我们延迟编写类或方法中的编程元素的数据类型的规范,直到实际在程序中使用它的时候。

解释定义

请看如下代码:

function identity<T>(arg: T) {
  return arg;
}

function main() {
  const result = identity<number>(1);
}

我们定义了一个泛型方法identity,定义了一个main方法,调用identity方法。

定义identity的时候,我们并不知道参数arg是个什么类型,T就是一个占位符,告诉我们arg是T类型的。

在调用identity的时候,才告诉它我要给你传递一个number类型的,然后传递真正的arg参数的值,是1。

这就是泛型定义中所说的,延迟编写方法(==identity==)中的编程元素(==arg参数==)的类型(==不写number,写T==),直到实际在程序中(==main方法==)使用它的时候。

类型参数化

上例中,将类型(number)变成了函数identity的一个参数,在调用的时候才传给函数identity,就叫做类型参数化。

泛型有什么好处

看完上面的解释,你多半会疑惑,这么写有什么好处啊,以前那种不也挺好吗,如下:

function identity(arg: number) {
    return arg;
}

function main() {
  const result = identity(1);
}

这不也实现了相同的逻辑吗?何必延迟到调用的时候,才确定参数的类型呢?

答案是 ==泛型可以让不同类型的数据,复用同样的方法或类。== 如果方法或类在定义的时候,就固定参数的类型,那么此方法或类只能处理这一种类型的数据。但等调用的时候才确定参数类型,就可以让多种参数类型的数据,复用同一个方法或类。

这里我们分析一个稍微复杂的例子,内置对象Array的声明:

interface Array<T> {
    length: number;
    
    toString(): string;
    
    pop(): T | undefined;
    reverse(): T[];
    sort(compareFn?: (a: T, b: T) => number): this;
    
    ...
}

这里只是列出小部分Array的声明。我还是按泛型的定义解释一边,在我们编写Arry的时候,延迟定义编程元素(这里指在Array类内可以使用的T)的数据类型。

使用:

function main() {
  const nums = new Array<number>(1, 2, 3);
  nums.push(4);

  const popNum = nums.pop();
}

直到实际在main函数中使用它的时候。我们才告诉Array,我们要一个数字类型的数组。

我们还可以

function main() {
  const dates = new Array<Date>();
  dates.push(new Date('2019-7-8'));
  dates.push(new Date('2019-7-9'));
  dates.push(new Date('2019-7-10'));

  const reverseDates = dates.reverse();
}

注意的问题的关键没,关键在于pop,reverse这样的方法的内在逻辑,是不关心被操纵数据的类型的,所以可以实现对于不同类型的的复用。

所以,Array类就实现类对于不同类型的数据列表的相关操作的复用。

约束依旧存在

如果您是由Javascript为基础,学习的TypeScript,或许会觉得上面说的有些莫名其妙,TypeScript搞这个泛型干啥,JS对此完全没有限制啊,没有类型,可以随意传参啊,如下:

function mainAboutNumber() {
    var nums = new Array(1, 2, 3);
    nums.push(4);
    var popNum = nums.pop();
}

function mainAboutDate() {
    var dates = new Array();
    dates.push(new Date('2019-7-8'));
    dates.push(new Date('2019-7-9'));
    dates.push(new Date('2019-7-10'));
    var reverseDates = dates.reverse();
}

确实如此,如果没有类型,就没有泛型这一说了,泛型是对类型的一种放宽政策,但约束仍旧存在。如果是用JS,这样的代码在编写时不会出错:

function mainAboutDate() {
    var dates = new Array();
    dates.push(new Date('2019-7-8'));
    dates.push('我不是Date类型');
    dates.push(new Date('2019-7-10'));
    var reverseDates = dates.reverse();
}

但如果你的业务中,dates是代表一个日期列表,显然还是TS更好:

image

会在我们编程期间及时报错,泛型的约束力依旧存在,当给定参数的类型后,我们就有了一个日期类型的数组了。

泛型的其他约束力

除了复用这条优点,泛型还可以帮助我们实现入参和返回值类型一致的校验,如下:

function identity<T>(arg: T): T {
    return arg;
}

如方法后面的冒号+T,就是限定返回值的类型也是T,要和参数arg的T保持一致,当我们使用它的时候:

image

将鼠标浮动到num变量上,发现它是个number类型,这是因为我们将T设置成了numnber。通过类型推断,num就成了number类型,后面就可以通过编辑器的智能提示,看到number类型的所有方法了,方便我们的编程。

泛型变量

在定义方法或类的时候,我们不光可以使用T,还可以为T增加更多修饰,使其变成一种有更多信息的泛型变量。

如下是Object.freeze方法的签名。

Object.freeze() 方法可以冻结一个对象。一个被冻结的对象再也不能被修改;冻结了一个对象则不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举性、可配置性、可写性,以及不能修改已有属性的值。此外,冻结一个对象后该对象的原型也不能被修改。freeze() 返回和传入的参数相同的对象。 --- MDN定义

interface ObjectConstructor {
    freeze<T>(a: T[]): ReadonlyArray<T>;
    
    ...
}

我们来看freeze(a: T[]): ReadonlyArray签名。 这个签名的意思是,如果你传递的是个数组(a:T[]),返回值会是一个ReadonlyArray。如下是使用时的代码:

function main() {
  const nums = new Array<number>(1, 2, 3); 
  const freezeNums = Object.freeze(nums);
}

我们就得到了一个readonly的数字数组。这里的T[]和ReadonlyArray。利用T进一步描述freeze方法的参数和返回值。如果单单用T,显然信息量不够多。

另外假设让我们来实现freeze方法。那内部我们就可以调用a.length这样的代码,因为签名已经明确告诉我们,入参a是某种数组类型。

泛型约束

泛型约束又是什么?我们在看freeze方法的另一个重载:

interface ObjectConstructor {
    freeze<T>(a: T[]): ReadonlyArray<T>;
    freeze<T extends Function>(f: T): T;
     
    ...
}

不同于泛型变量,这回我们约束尖括号<>里的T,freeze(f: T): T;的意思是,如果传递给freeze的是个方法,那返回值还是个方法。

再看一个例子:

interface SpecialType {
  specialProperty: string;
}

function identity<T extends SpecialType>(arg: T): T {
  console.log(arg.specialProperty);
  return arg;
}

这个例子中,我们让T继承SpecialType,这样我们在identity方法内部,就可以使用SpecialType规定的属性了。

泛型约束可以说增加了泛型的复用能力,可以让某些含有相同属性的数据,复用同样需要这些属性的方法。

再多分析一个包含泛型的签名

interface Array<T> {
    ...
    
    map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
    
    
    ...
}

我们来分析一下Array类中的map函数,也是我们经常使用的函数。

map函数签名

第一个参数是个回调函数,回调函数的第一个参数value是T类型,第二个参数index是数字类型,第三个参数是T类型的列表。回调函数的返回值是一个U类型的数据。map函数的返回值是U类型的列表,是个全新类型的列表。

结束语

学习泛型,可以让我们很好的理解第三方库的TS声明。也可以让我们自己写出优秀的TypeScript代码。