泛型是 TS 中一个重要的概念,它可以创建可复用的组件,同时保持对类型信息的一致性。
泛型提供了一种方式使得类型可以被参数化,这样就可以创建可以适用于各种数据类型的函数或类,而不仅仅限于一个数据类型。
一、泛型
先来看一个需求:
我有一个 compiler 函数,我希望这个函数可以接收一个字符串或一个字符串数组,返回值也是一个字符串或一个字符串数组,有什么办法?
结合多种编程语言,我们很容易想到几种解决方法:
1. 使用函数重载
在传统的面向对象编程语言中,例如 Java 或 C++,函数重载允许我们使用相同的函数名定义多个函数,只要它们的参数列表(参数的数量或类型)不同即可。在编译过程中,编译器会通过函数调用时的参数列表判断调用哪个重载函数。
在 JavaScript 中,如果尝试使用相同的函数名定义多个函数,后定义的函数将覆盖先定义的函数。
在 TS 中可以函数重载,如我们可以这样做:
function compliler(template: string): string;
function compliler(template: string[]): string[];
function compliler(template: any) {
return template;
}
console.log(compliler('abc')); // "abc"
console.log(compliler(['abc', 'efg'])); // ["abc", "efg"]
// console.log(compliler(123)); // 报错
在这个例子中,compiler 函数有两个重载签名和一个实现函数。第一个重载签名接受一个字符串类型的参数,返回一个 字符串。第二个重载签名接受一个字符串数组类型的参数,返回一个字符串数组。
实现函数接受任意类型的参数,返回参数本身。这个实现函数必须能兼容所有的重载签名,所有对 compiler 函数的调用都会执行这个实现函数。
需要注意的是,TS 在解析函数重载时,会按照重载签名从上到下的顺序进行匹配,所以在编写重载签名时,应该把最精确的定义放在最前面。
使用这个方法可以解决这个需求,但是这么一个简单的需求,写出的代码看起来很麻烦,让人很不舒服,不够简洁,因此此方案作废。
2. 使用联合类型
使用联合类型也可以解决这个需求。
function compiler(template: string | string[]): string | string[] {
return template;
}
我们定义了一个 compiler 函数,有一个字符串类型或字符串数组类型的参数,返回值可以是字符串,也可以是字符串数组。
这个方法和使用函数重载的方法相比,代码简洁了许多。但如果参数类型可以是很多种,比如 template 参数同时也可以是数字类型、布尔类型、数字数组等。虽然可以在参数的类型注解后面继续加上 | number | bool | number[],但这样代码的观感也不是很好。
在这个例子中,传入 compiler 函数的参数类型可以和返回值类型不一致。如果想让返回值的类型和参数的类型一致(如函数重载案例的效果),就可以使用类型变量。
3. 使用类型变量
类型变量是 TS 中的一种特殊的变量,只用于表示类型而不是值。通过使用类型变量,我们可以创建出在多种类型之间都可以共享的函数或组件。
// 类型变量
function compiler <T> (template: T): T {
// ...
return template;
}
尖括号中的 T 就是类型变量,它可以捕获用户传入的一些类型,允许我们跟踪函数中使用的这些类型的信息。这里我们定义了类型变量 T,就可以在函数定义中使用它。我们定义了参数 template 的类型为 T,返回值类型也为 T,这样就保证了参数类型和返回值类型一致。
在这个案例中,T 可以是任意类型,函数运行时也并不知道 T 是什么类型。、
为了解决这个问题,我们可以在调用函数时指定参数的类型。如下所示:
compiler<string>('123');
这里我们指定了传入的参数类型为字符串,后面传入的 '123' 就是一个字符串。
也可以直接传入 '123',TS 有类型推导机制,在编译时编译器会推导出传入的参数为字符串类型,T 这时也就是字符串类型,返回值的类型也自然就是字符串类型。
// 类型推导
compiler('123');
现在返回去看需求,传入的参数需要是字符串类型或字符串数组类型,我们就可以结合联合类型这样做:
compiler<string | string[]>('123');
这个案例中的 compiler 函数就是一个泛型函数。
二、泛型约束
来看下面的案例:
function compiler <T> (template: T): T {
console.log(template.length);
// ...
return template;
}
compiler<string>('123');
编辑器可以通过我们传入的参数 '123' 来推断出 T 为字符串类型,但是编译时仍然会报错:Property 'length' does not exist on type 'T'.,表示类型 T 中不存在 length 这个属性。
我们可以使用继承接口的方法来实现泛型约束,通过接口来指定一个泛型必须具有某些属性或方法。
interface Len {
length: number;
}
function compiler <T extends Len> (template: T): T {
console.log(template.length);
// ...
return template;
}
compiler<string>('123');
这里我们先定义一个接口 Len,接口中有一个属性 length,再让类型变量 T 继承这个接口,编译器知道了类型 T 下有 length 这个变量,即可编译成功。
这里需要注意,使用这种方法,我们传入的这个参数必须要有 length 这个属性,否则会报错。如:
compiler([]); // 0
compiler(false); // 报错
第一行我们传入一个数组,我们都知道,数组是有 length 属性的,因此可以正确运行,输出 0。
第二行我们传入一个 false,为布尔类型,布尔类型没有 length 属性,所以会报错:Argument of type 'boolean' is not assignable to parameter of type 'Len'.,表示布尔类型和接口 Len 不匹配。
如果我们修改 Len 接口,在接口中增加一个 split 属性,类型为 函数:
interface Len {
length: number;
split: Function;
}
修改 Len 接口后,传入 compiler 的参数必须同时具有 length 属性和 split 函数。因此 compiler([]) 会报错,因为数组不具备 split 方法。
三、总结
使用泛型的优势:
1. 函数和类可以轻松的支持多种类型,增强程序的扩展性。
2. 不必写多条函数重载、联合类型声明,增强代码的可读性。
3. 灵活控制类型之间的约束。