TS-泛型学习

223 阅读15分钟

TS泛型学习

函数泛型的初登场

在揭开函数泛型的面纱之前,首先粗略的关注一下在函数中使用类型:

function returnValue(value: string): string{
  return value
}

定义一个函数,对其传入的参数和返回值的类型进行约束。在调用该函数时,传入的参数必须为string类型。

returnValue('111')
returnValue(111)//传入非string类型,TS编译时报错。

初步了解在TS中函数的定义之后,我们来会会函数泛型。我先写一个泛型函数的例子:

function returnValue<T>(value: T): T{
  return value
}

此时returnValue就是一个泛型类型的函数,这里使用T表示泛型,泛型也是一种类型,我们可以认为是任意类型。其实更准确的说,T是一个泛型变量,他的值,由调用该函数时传入的类型决定。该函数的意义就是returnValue的参数value可以时任意类型,并且返回的的值的类型也是任意类型。 上述是函数泛型的声明,那么如何调用呢?调用该函数的方式有两种:

//方法一:
returnValue<string>('1111') //此时泛型变量的T就是string类型,因此需要传入字符串,如果传入数字,TS编译时报错,因为类型不匹配。

//方法二:
returnValue('1111')//这中方式是后续要讲到的类型推导,我们并没有直接给泛型变量T赋值,但是TS通过我们传入的值来推导出T的类型为string。

没错,有一个泛型变量就一定会有两个甚至多个。我们看两个泛型变量的使用:

function returnValue<T,U>(val_0: T, val_1: U):[T,U]{
  return [val_0, val_1];
}

//方法调用
returnValue<string, number>('1',1)

当然也可以使用类型推导。

这里补充一个小姬希。如果定义一个函数类型的值呢?

type funcType = (value: string) => void

这里稍微讲解一下,通过type关键字来为(value: string)=> void类型取一个别名。这个类型是一个函数,该函数返回空,并且有一个value参数类型为string。这里没有参数也可以,函数类型的定义多种多样。主要是入参和返回值的类型需要定义。内部逻辑无需care。

接口泛型

在接口中,我们仍然可以使用泛型:

interface Person<T>{
  name: string;
  action: T
}

let hero: Person<string> = {
  name: '中国人民解放军',
  action:'保家卫国'
}

在声明Person接口时使用了T这个泛型变量,此时并不能确定Personaction的类型。只有在使用时传入了string,此时Person的类型其实才算确定下来。 当然,这只是简单的接口泛型,还有一些削微复杂一点的,例如:

interface typeA{
  name: string;
}

interface typeB<T>{
  name: string;
  extends: T;
}

let type: typeB<typeA> = {
  name: 'hahha',
  extends: {
    name:'xixi'
  }
}
//当执行typeB<typeA>时,其实typeB的类型就是:
interface typeB{
  name: string;
  extends: {
  name: string;
}
}

又来一个栗子加深映像:

interface GenericIdentityFn<T> {
    (arg: T): T;
}

function identity<T>(arg: T): T { //定义一个使用泛型的函数
    return arg;
}

let myIdentity: GenericIdentityFn<string> = identity; //具体使用

类泛型

其实类泛型和接口泛型有几分相似,彼时彼刻恰如此时此刻:

 class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();//具体的使用

let stringNumeric = new GenericNumber<string>();//这里将string传入

在使用类时,为泛型变量赋值。

泛型约束

泛型约束的本质是对任意类型进行约束,让它变成一个并非真正意义上的任意类型,泛型约束主要使用关键字extendsextends虽有继承作用,但和泛型搭配一起就不再是继承,而是约束,例如:

interface type1 {
  name: string;
}

function returnValue<T extends type1 >(value: T): T{
  return value;
}

我们首先定义接口type1。然后通过<T extends type1 >对泛型进行约束。它的意思是:我们可以向returnValue函数传入任意类型的值,但是这个值必须包含name属性,并且这个属性的类型是string。其实这变相的对T进行了约束。

在没有泛型约束之前,我们可以传入任意类型的值,例如string, number, boolean, Array<T>, object等等类型,但是一旦使用了type1进行约束,那么传入的值只能是一个对象,并且必须要有name属性。

extends的前世今生

刚才我们浅谈了extends在泛型上的作用,主要用于泛型约束。其实它的才能还不止这些。

  • 必杀技一: 作用于接口,表示继承。继承就相当于自己有了被继承着的字段。
 interface T1 {
    name: string,
  }
  
  interface T2 {
    sex: number,
  }
  
  /**
   * T3 = {name: string, sex: number, age: number}
   */
  interface T3 extends T1, T2 {
    age: number,
  }

注意,接口支持多重继承,语法为逗号隔开。如果是type实现继承,则可以使用交叉类型type A = B & C & D

  • 必杀技二: 表示条件类型,可用于条件判断。表示条件判断,如果前面的条件满足,则返回问号后的第一个参数,否则第二个。类似于js的三元运算。我们煮个例子:
//type A1 = 1
type A1 = 'x' extends 'x' ? 1 : 2;

//type A2 = 2
type A2 = 'x' | 'y' extends 'x' ? 1 : 2; //这里是一个联合类型,因此前者不能分配给后者。

// type A3 = 1 | 2
type P<T> = T extends 'x' ? 1 : 2;
type A3 = P<'x' | 'y'>

以上板栗,啊呸。以上栗子中extends的作用是进行条件判断。

类型兼容性

TypeScript结构化类型系统的基本原则是:如果x要兼容y,那么至少y具有和x具有相同的属性。

interface Named {
  name: string;
}

let x: Named
let y = { name: 'alice', location: 'seattle' }

x = y

在上面例子中,在TS环境中y是否能赋值给xTS编译器会检查x中的每一个属性,看是否能在y中也找到对应属性。 在这个例子中,y必须包含名字是namestring类型成员。y满足条件,因此赋值正确。 这里注意,其实不止需要具有相同的属性,属性的类型也需要相同y有个额外的location属性,但这不会引发错误。 只有目标类型的成员会被一一检查是否兼容。这个比较过程是递归进行的,检查每个成员及子成员。

父子类型 (可赋值性 或者 可分配性)

在TS中,我们如何判断一个类型是父类类型还是子类型。使用的方式是赋值。 如果一个变量需要一个 A 类型的值,如果我们可以用 B 类型的值赋予该变量,那么类型 B 就是类型 A 的子类型,类型 A 就是类型 B 的父类型。而赋值的原则就是上面x和y赋值的原理。 我们假设A:{name: string}, B: { name: string; location: string } 很显然,B可以赋值给A,因为当B赋值给A时,此时TS会遍历A中的每一个属性,看B中是否都有,都有那就是赋值成功,很显然都有,所以可以赋值。 但是A不能赋值给B,因为A的属性只有一个name不满足两个,所以不能赋值成功。此时A就是父类型,B是子类型。

协变

对于协变,我们来看一个栗子:

interface animation {
  name: string;
}

interface dog1 extends animation {
  action: string;
}

let AAA: Array<animation> = [{name:'aa'}];
let BBB: Array<dog1> = [{name:'dog',action:'eat'}]

AAA = BBB

我们定义两个类型animation 和 dog1,通过代码运行,发现赋值是成功的。 因此类型Array<animation>是类型Array<dog1>的父类型。 由此可见,类型 Array<animation> 和类型 Array<dog1> 保留了类型 animation 和类型 dog1 之间的父子类型关系,对于这种保留了父子类型关系的变型我们称之为协变

逆变

简单说就是,具有父子关系的多个类型,在通过某种构造关系构造成的新的类型, 如果还具有父子关系则是协变的,而关系逆转了(子变父,父变子)就是逆变的 首先我们来看一个栗子:

interface animation {
  name: string;
}

interface dog extends animation {
  action: () => void;
}

type anim = (arg: animation) => void
type dogs = (arg: dog) => void

let Eg1: anim
let Eg2: dogs

Eg1 = Eg2 //这里赋值TS编译器报错,不允许赋值

Eg2 = Eg1 //赋值TS编译器允许

为什么Eg1 = Eg2会不允许?根本原因是因为它们对应的类型所造成的,我们根据两这的类型分别将两个变量赋值对应的函数,如下:

Eg1 = function (arg: animation) {
  console.log('is called');
}

Eg2 = function (arg: dog) {
  arg.action()
}

//与此同时,进行赋值:
Eg1 = Eg2

观察函数,会发现什么问题?当赋值之后,我们调用对应的函数:

let obj = {
  name:'hh'
}
Eg1(obj) //函数调用错误,无对应action属性

在声明Eg1函数时,对应的参数类型已经确认,因此只能传入对应animation类型,但是函数体已经发生改变,因此会报错。 正是基于这种原因,TS编译器在赋值时会报错。因此只能Eg2 = Eg1,这样一来animation就成为了dogs的子类型,而这种通过type Fn<T> = (arg: T) => void构造器构造后,父子关系逆转的现象叫做逆变

双向协变

所谓的双向协变指的是:如果类型 A 是类型 B 的子类型,经过变型后,如果类型 A 既是类型 B 的子类型,又是类型 B 的父类型(反之亦然),我们称这种变型为双向协变。我们在逆变小结中见过,在函数的参数中,只有逆变才能够保证类型安全。因此在TS的严格模式下,函数的参数类型时逆变的。

 interface BaseEvent {
  timestamp: number;
}

interface MyMouseEvent extends BaseEvent {
  x: number;
  y: number;
}

function addEventListener(handler: (n: BaseEvent) => void) {
}
addEventListener((e: MyMouseEvent) => {
}); // ts(2345) TS报错。

但类似上述事件处理的代码是我们经常遇到的场景,我们可以将TypeScript 设置为非严格模式(将 strictFunctionTypesstrict 设置为 false),此刻函数的参数类型便变成了双向协变, 但由于它不是类型安全的,故此不推荐使用,可通过泛型来保证类型安全,因此上诉代码中的 addEventListener 可修改为:

 function addEventListener<E extends BaseEvent>(handler: (n: E) => void) { }

集合类

其实集合类也是泛型的一种,我们举个栗子:

let numberArr: Array<number> = [1,2,3]; 

其实上面的栗子就是集合类,本质上数组就是一系列值的集合,这些值可以是任意类型。数组只是一个容器,数组中的元素本质上是相同的类型。 在使用Array泛型时,需要指定内部元素类型,不要单独使用:

let arr: Array = [] //泛型类型“Array<T>”需要 1 个类型参数。

类型推导

类型推导是TS的一个重要功能,类型推导可以根据赋值的类型对变量的类型进行定义,例如:

let name = 'xz'
//等价于:
let name: string = 'xz'
myName.toFixed() //toFixed并不存在于string类型上

TS环境下定义name并赋值'xz'TS会对我们的赋值进行类型推导。TS通过我们的赋值,它会推导出name的类型,即string。当再使用其他的属性时会TS报错。 需要注意的是,类型推导是仅仅在初始化的时候进行推导,一旦推导成功,那么该变量的类型就会被锁定。 除了基本类型,可以是复杂类型:

let obj = {name:'xz'}
obj.age = 18 //类型“{ name: string; }”上不存在属性“age”

对于函数类型的泛型同样也适用:

function id<T>(arg: T): T{
  return arg
}
id<string>("lucifer"); // 这是ok的,也是最完整的写法
id("lucifer"); // 基于类型推导,我们可以这样简写

默认参数

除了类型推导,默认参数也是TS系统中的一大特色。这里有几个栗子:

interface defaultType<T = string> {
  value: T;
  logName: (value: T ) => T //这里定义一个函数类型,和函数使用泛型或者类型区分开。
}

let logName: defaultType< number > = {
  value: 1,
  logName: (value: number) => value
}

当使用defaultType接口时,如不传入对应类型,TS会使用默认类型。 除了interface可以使用默认参数外,type关键字也可以使用默认类型,例如:

//定义一个函数类型,这个函数类型的入参和出参一致。
type func = (value: string) => string

如果使用泛型来对入参和返回值的类型不进行约束,可以写为:

type func<T> = (value: T) => T
//使用时传入什么类型结果T就为什么类型
let _func: func<number> = function (arg) {
  return arg
}
_func(1)

当然,也可以使用默认类型:

type func<T = number> = (value: T) => T
let _func: func = function (arg){
  return arg
}
//也可以传入参数:
let _func: func<string> = function (arg){
  return arg
}

前面讲了关于泛型的使用,那么我们什么时候使用泛型呢?这里谈谈我对使用泛型的理解,欢迎指正。

  • 第一:当接口,函数或者类需要支持很多类型时可以使用泛型
//例如:
interface cacheData<T> {
 data: T,
 cacheTime: Date;
 expirationTime: Date;
}

定义的数据存储接口并不能确定要存储的数据类型,可能会有很多种,因此可以使用泛型。

  • 第二:当接口,函数或者类需要被用到很多地方时可以使用泛型

泛型嵌套 和 泛型递归

泛型和函数一样,是支持嵌套的,举个例子:

interface car<T>{
    height: T;
}

interface bmw<T>{
  type: T
}

let myCar: bmw<car<number>> = {
  type:{
    height: 12,
  }
}
// 嵌套后bmw的类型为:
interface bmw {
  type: {
    height: number;
  }
}

除了泛型嵌套,同时还支持泛型的递归操作:

 type ListNode<T> = {
    data: T;
    next: ListNode<T> | null;
  };

keyof属性

TS允许我们遍历某种类型的属性,并通过 keyof 操作符提取其属性的名称。该操作符可以用于获取某种类型的所有键,其返回类型是联合类型。举个栗子:

interface Person {
  name: string;
  age: number;
  location: string;
}

type keysPerson = keyof Person // keysPerson = "name" | "age" | "location"

其中"name" | "age" | "location"是联合类型。

联合类型

这里做一个联合类型的小插曲。所谓的联合类型本质上是一个类型,它和其他的基础类型不同,联合类型表示一个值可以是几种类型之一。 我们用竖线|分隔每个类型,所以 number | string | boolean表示一个值可以是 numberstring,或 boolean。例如:

type type1 = number | string | boolean

以上面为例,keyPerson是一个联合类型,如果某一个变量使用该联合类型,则赋值的内容必须是对应的类型。例如:

let person: keysPerson = 'name'
person = 123 //类型错误
person = 'alibaba' //类型错误

智能接口提示

我们可以通过TS来做一个智能接口提示,所谓的智能接口提示是指当我们访问一个接口的数据,那么它会有对应字段的提示。具体实现如下:

interface Seal {
  name: string;
  url: string;    
}
interface API {
  "/user": { name: string; age: number; phone: string };
  "/seals": { seal: Seal[] };
}

const api = <URL extends keyof API>(url: URL): Promise<API[URL]>=> {
  return fetch(url).then((res) => res.json());
}; 

接下来对api这个函数做一个解读,其他的不是很难。我们先忽略掉对应的类型,单纯看这个函数:

const api = (url) => {
  return fetch(url).then( (res) => res.json )
}

函数比较简单,主要针对get函数,这里并没有体现参数params,通过传入的url发起请求获取对应数据。 接着我们带上类型来解析这个函数,主要分为三部分:泛型,入参,返回类型。分别对应:<URL extends keyof API>(url: URL)以及Promise<API[URL]>

  • 泛型:这里并没有像往常一样使用T作为泛型变量,而是取URL作为了泛型变量,接着通过extends进行泛型约束。具体约束是通过keyof API来获取联合类型'/user' | '/seals'。然后通过联合类型对泛型变量URL进行约束。
<URL extends keyof API>
//等价于
<URL extends '/user' | '/seals'>

通过泛型约束,限定了我们传入到api中的参数只能是'/user' 或者 '/seals'。这里是路由提示。

  • 入参 因为有泛型约束的存在,传入的url不再是任意类型,只能是'/user' 或者 '/seals'

  • 返回类型 首先,返回类型是一个promise泛型,如果调用时传入的url/user, 将泛型变量代换之后可得API[/user]。 而API[/user]所对应的类型是:{ name: string; age: number; phone: string },那么返回类型可以等价为:

Promise<{ name: string; age: number; phone: string }>

这里暂时不对Promise类型进行讲解,在后面会有说到。至此api函数讲解完毕,我们可以通过只能接口根据传入的路径获取到对应的数据提示。

Promise<T>

Promise<T>类型是一个TS内置的类型,我们可以直接使用不需要定义,举个栗子:

let res: Promise<{name: string}>

使用时需要传入具体类型参数。那么Promise内部是如何工作的,接下来我们一探究竟。 Promise<T>类型在*/lib.es5.d.ts文件下,下面是对应的代码:

interface Promise<T> {
    then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;

    //...
}

因为Promise的方法不止then,因此TS中的Promise函数的其他参数在其他的.ts文件里。这里我只复制了then函数,其他函数和注释过滤掉了。 这里并不会对Promise的其他类型做讲解,只讲解和T有关的类型,其他类型有时间的话我会单独讲一讲PromiseTS中的类型。

interface Promise<T> {
  then <TResult1 = T, TResult2 = never> ( (value: T) => TResult1, (reason: any) )
}

过滤掉其他类型之后其实Promise<T>类型内部就比较明朗了。我们传入的Promise<{name: string}>本质上是对(value: T) => TResult1函数的入参和返回值类型的一个定义,因为在创建一个Promise之后回去调用对应then方法,而我们传入的参数就是then方法成功回调函数的入参和返回类型。

interface baseData {
  name: string;
  age: number;
}
let promise: Promise<baseData> = new Promise((resolve)=>{
  resolve({name: 'xz', age: 18})
})

promise.then((res) => {
  //然后对res进行操作
})

我们传入的baseData类型就是给res定义的。可以等价于:

promise.then((res: baseData) => {})

和上面智能接口结合,在使用res时就会有数据提示。

参考文献: