TS泛型和extends
<T>泛型对象应该是静态语言中比较难以理解的一个概念。但就其本质而言,它就像是一个占位符:通过告诉编译器这里未来会有某个类型——它既可以是已有的类型,也可以是自定义的类型。 总之这里需要有一个类型,该类型会按照我后续的形式进行操作和定义。说白了,就是将这里的声明指代过去,或者用一个保留字来指代。这里的 T 不一定非得写成 T,写成什么都行;但本质上,它就是一个在这里预定义的「类型变量」,可以指代任何类型。从而在调试或编写代码时获得更好的提示支持。因此,泛型的作用非常大,可以让代码在保持类型安全的前提下,获得极大的通用性和灵活性。
不过虽然可以随便写,但是还是有一个约定俗成的习惯,大概就是下面这些:
T= Type(通用类型)K/V= Key / Value(键值对场景)E= Element(集合元素)R= Return(返回类型)
除此之外,还有一个「泛型约束」很有意思:它可以指定该泛型必须具备某些属性,且规定这些属性的类型。它不强制要求其他内容,但必须存在指定的属性才能通过校验。也就是说,只有当该指定属性存在时,程序才会允许继续执行。
// T 可以是任何类型,但前提是它得有个 length 属性,且该属性的类型为 number
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
getLength("hello"); // ✓ string 有 length
getLength([1, 2, 3]); // ✓ array 有 length
getLength(123); // ✗ number 没有 length,编译报错
A extends B 可以理解为 A 需要满足 B,或者说 A 包含 B , A 是 B 的超集,或者说 B 是 A 的子集。
如果我后面指定的是一个集合,且该集合里面有一个属性,那么本质上就是说 A 既然包含了这个集合,因而也必然要有其属性。而这个地方如果写的是一个空集,那么它在JS里就是一个包含了任何非 null/undefined 值的集合。
比如 T extends {}和单独写个 T ,两者在除了 null/undefined 外没有其他区别。既然万物皆对象,那类型本质上也是一个集合,当该集合具备某个属性的时候,就和这里的约束条件 B 对上了,或者说是这个集合里面的属性对上了,因此 A 就包含了 B。
这样来理解的话,其实任何继承本质上都是在构建一个包含了父类的「超集」。因为继承的子类,必然是在具备父类所有属性和方法的前提之下,又拥有了自己的属性和方法,那么这其实就是一个超集。虽然「继承」这个词乍一听,好像子类应该是父类的子集,但实际上其实是反过来的,也就是父类是子类的子集。
之所以叫父类和子类,其实是从「实例」或者说是「值」的角度来看待的。
就拿 Animal 和 Dog 来说:
- 所有的 Dog 必然都是 Animal,但反过来说,Animal 却不一定是 Dog,因为 Animal 本身是一个更抽象的存在。
- 从这个角度讲,其实 Dog 是 Animal 的子集。
但是从属性的角度来看,Dog 就拥有比 Animal 更多的属性和方法,因此它们的关系又是反过来的。
- 如果以「抽象程度」来划分,那肯定是 Animal 更高层级,更抽象,因此是更「大」的。
- 但越往「具体」的方向走,子类所拥有的具体方法和具体属性肯定就越多,在这个方面,它更「大」。 这是两个不同的观察角度。
extends 的使用场景,一般是我需要在方法里面调用泛型的某个属性时,才必须要这样指定,否则会因为调用不了而导致编译器报错。但如果我不是为了调用某个属性,而只是单纯地使用...展开,或者说白了,是对它所有的类型进行一个遍历,那就不需要特别去指定它。
这种情况就是要么全有,要么全无;如果要用到具体的某一个属性,就必须在里面声明。这就跟我调用某个变量之前要先声明一样,因为泛型本身可以是任何类型,编译器又不知道它会包含哪些属性,如果你不对它进行最小属性的声明,那后面这里一定会出错。
// 这里就不需要特别地进行 extends 声明,因为里面没有调用到具体的属性
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
// 这里就必须得声明了,因为里面调用了其中一个泛型的属性 id,如果不声明就会报错
function merge<T extends { id: number }, U>(obj1: T, obj2: U): T & U {
console.log(obj1.id);
return { ...obj1, ...obj2 };
}
比较有意思的是,这里的 & 符号从属性的角度来讲,是一个并集,也就是最终的输出类型,必然拥有这两种类型的全部属性和方法;但是如果从值的角度上来讲,是一个交集,也就是说,要求最终的输出集合必须既属于T类型又属于U类型。
另外还有一个竖线(|),其实这个跟 if 语句里面的各种符号都一样:&(与)、|(或)、非(!)。这些符号的使用逻辑都是一致的。当然,在类型判断里面没有「非」这个逻辑。泛型的话,因为它本身就代表任何类型都可以,没有非很正常。但在具体类型上,也不存在「非某个类型」。因为这意味着除了该具体类型之外的任何一种类型都可以满足条件,那该集合内的类型就变成无限种,所以ts并没有提供这种对类型的声明方式。
另一方面,<>里面的类型其实就更像声明一个容器的内容是啥。它告诉我的是:返回的这个类型对象里面会是怎样的类型。
Array<number> // 数组,里面装的是 number ,也可以写成 number[]
Promise<Response> // Promise,最终产出的是 Response
Map<string, User> // Map,里面装着键-值元素,键是 string,值是 User