TS中的泛型怎么用?五分钟拿捏泛型!!!

99 阅读11分钟

前言

  • 昨天因为要做一次分享,所以对泛型常用知识点做了个整理,欢迎各位大佬指出问题
  • 泛型在TS中一向都是非常重要的
    • 一个人是否入门TS,首先要看他会不会泛型
    • 一个人是不是熟悉TS,要看他能不能熟练的用类型进行编程
    • 基本上所有的类型体操题目都要用到泛型
  • 所以今天就由浅到深的来聊一聊泛型,再掌握一下做类型体操题目所必需的一些知识点

1. 泛型的基本使用

  • 在使用TS开发的过程中,代码的可重用性也是非常重要的

    • 比如我们可以封装一些工具函数,通过传入不同的参数,来让函数帮我们完成不同的操作
  • 但是目前我们在开发的过程中遇到了一个问题

    • 我们在封装好一个函数之后,如果明确的写了这个函数的参数类型和返回值类型都是number的话,那么这个函数就不能用来处理string或者其他类型的数据了

      • 如果使用联合类型,会导致类型过长
    • 虽然也可以定义为any类型,但是这样就没什么意义了,我们已经丢失类型信息了

  • 我们想要做到的是:

    • 比如我们传入的是一个number,那么我们希望返回的可不是any类型,而是number类型
    • 所以,我们需要在函数中可以获取到参数的类型是number,并且同时可以使用它来作为返回值的类型
  • 这个时候,我们就可以使用泛型来完成这一需求:

    • 泛型其实说白了就是类型参数化,它可以使我们在使用TS时,类型变得非常灵活

    • 泛型的使用方式就是,在定义函数的时候,给函数名后面加一个<>,里面写一个类型的参数,后续这个函数中,如果也想要返回和参数相同类型的数据的时候,就可以使用这个类型的参数

      function foo<T>(param: T): T {
          return param
      }
      
    • 那么在调用的时候,就需要我们将这个类型的参数传给函数,有两种方式可以给这个函数传递类型的参数

      • 方式一:通过函数名<类型>() 的方式传递

      • 方式二:通过类型推导,自动推导出来我们传给函数的参数类型

        • 如果我们是通过const这个关键字定义的变量接收返回值的话,那推导出来的数据类型就直接是一个字面量类型
        • 但是如果是通过let定义的变量接收返回值,那么推导出来的数据类型毫无疑问就是一个普通数据类型了(number)
      • 不要太依赖于TS自动推断,如果发现它推导的不正确的话,还是要我们自己指定的

      function foo<T>(param: T): T {
          return param
      }
      ​
      const res1 = foo<string>('Judy')
      const res2 = foo(123)
      let res3 = foo(456)
      
  • 使用泛型实现useState方法

    function useState<T>(state: T): [T, (currentState: T) => void] {
      let newState = state
      
      function setState(currentState: T) {
        newState = currentState
      }
    ​
      return [ newState, setState ]
    }
    ​
    const [ count, setCount ] = useState(100)
    

2. 泛型的额外补充

  • 我们在使用泛型的时候,也可以传入多个类型的参数

    function foo<T, O>(name: T, info: O): {name: T, info: O} {
      return { name, info }
    }
    ​
    const { name, info } = foo('Judy', { age: 18, height: 1.88 })
    
  • 平时开发中我们经常看到的一些参数类型是什么T、O、K、V之类的

    • 其实它们都是缩写:

      • K、V:key和value的缩写,键值对
      • T:Type的缩写,类型
      • O:Object的缩写,对象
      • E:Element的缩写,元素
      • R:ReturnType的缩写,返回值的类型

3. 泛型类、泛型接口

3.1. 泛型类

  • 泛型也是可以应用到类上面的

  • 如果我们在定义一个类时,想要这个类的构造函数接收的参数类型,也由调用者决定的话,就可以使用泛型

    class Direction<T> {
        constructor(public x: T, public y: T) {}
    }
    ​
    const d1 = new Direction(111, 222)
    const d2 = new Direction('aaa', 'bbb')
    

3.2. 泛型接口

  • 同时,泛型也是支持在接口上使用的,我们在定义接口的时候,也可以使用泛型但是需要注意的是,在和接口搭配使用泛型的时候,TS是不会帮我们自动进行类型推导的

  • 所以为了方便起见,泛型也是可以有默认类型的

    interface IInfo<T = string> {
        name: T,
        age: number,
        slogan: T
    }
    
    const info1: IInfo = { name: 'Judy', age: 18, slogan: 'Hello' }
    const info2: IInfo<number> = { name: 123, age: 18, slogan: 456 }
    

4. 泛型约束

注意!!!当我们对一个类型参数使用extends这个关键字的时候,就可以理解为,这是要对这个类型参数做约束了

  • 有时候我们希望在封装一个工具函数的时候,函数的参数类型可以由调用者决定,这时就可以使用泛型

  • 但是同时,我们又希望对这个传入的参数加上一些约束,不能让调用者乱传。并且需要将这个传入的参数类型保留下来,最后返回值的类型就是传入的参数类型的话,这时就可以使用泛型约束

  • 因为传入的类型参数其实就相当于一个变量,在给它传了具体的类型之后,它就可以将这个类型保存下来,在整个函数的生命周期中都可以任意使用

  • 比如我们现在有个需求,我封装了一个工具函数,这个工具函数接收的参数,必须得有length,最终我会将这个参数又返回出去。但是我在获取到函数返回值之后,要求我传入的是什么类型,那么返回值的类型也不能丢失

    • 有一种比较容易犯的错误就是使用对象参数来实现这个需求
    • 虽然它成功限制了,没有length属性的对象无法作为参数传入
    • 但是我们会发现最终拿到的res1和res2的类型都是{ length: number }
    • 这丢失了它原本的类型,所以这是一种错误的做法
    interface ILength { 
        length: number
    }
    ​
    function getInfoHasLength(arg: { length: number }) {
        return arg
    }
    ​
    let res1 = getInfoHasLength('Judy')
    let res2 = getInfoHasLength({ length: 1 })
    let res3 = getInfoHasLength(123)
    
    • 那么正确的做法是:我可以定义一个类型的参数,并且同时,让它继承一个拥有length属性类型的接口
    • 那么在传入'Judy'时,它的string类型就会被保存在T中,最终返回的时候,返回值的类型就是正确的了
    • 最终我们在拿到res1和res2的时候,类型就不会丢失,分别是string和{ length: number }
    interface ILength { 
      length: number
    }
    ​
    function getInfoHasLength<T extends ILength>(arg: T): T {
      return arg
    }
    ​
    let res1 = getInfoHasLength('Judy')
    let res2 = getInfoHasLength({ length: 1 })
    let res3 = getInfoHasLength(123)   // 没有length属性,所以报错
    
  • 泛型约束练习:

    • 现在我们有一个需求,就是封装一个工具函数,这个函数接收两个参数,第一个参数是一个对象,第二个参数是一个字符串

    • 但是我们要保证,第二个传入的字符串,必须是传入的对象的keys中的某一个

    • 如果在调用的时候乱传,那么就直接报错

      • 要实现这个,首先要了解一个关键字keyof

        • keyof的作用就是获取到某个对象中的所有的key,并且将它们转换成一个联合类型
      • 那么我们就可以实现它了

        • 首先这是一个工具函数,别人可以传任意对象。并且key也是由函数调用者决定的,所以对象和key的类型都使用泛型
        • 其次,要保证key是对象中的某个属性名,所以就可以使用extends给其添加约束。并且这个约束的条件就是当前对象类型的其中的某个key
      function operateObj<O, K extends keyof O>(obj: O, key: K) {
          return obj[key]
      }
      ​
      const obj = {
          name: 'Judy',
          age: 18,
          height: 1.88
      }
      ​
      const res1 = operateObj(obj, 'height')
      

5. 映射类型

5.1. 基本使用

  • 有的时候,我们想要创建一个基于另一个类型的新类型,但是又不想拷贝一份新的,也不能使用继承,这个时候就可以考虑使用映射类型

    • 大部分的内置工具和类型体操的题目都是通过映射类型来完成的

    • 不能使用继承的原因主要就是:

      • 原类型中的某些属性可能是readonly或者可选的。但是我新类型中的属性不需要这些
      • 如果使用继承的话,父类型是什么,子类型就是什么。无法修改是否readonly或者可选
      • 所以这就是某些时候不能使用继承的主要原因
  • 注意:映射类型只能通过type关键字定义,不能使用interface定义。并且不能对原有的类型做拓展

  • 映射类型是建立在索引签名的基础上的:

    • 其实很简单,就是定义一个新的type映射类型,通过泛型将想要映射的类型传入,然后在这个映射类型中写索引签名

      • 映射类型通过泛型接收一个类型

      • 拿到类型之后,新类型的key就是通过遍历这个接收到的类型的keys得到的

      • 而新类型key对应的value,就是接收到的类型[key]

        • 有点类似于这个:

          const obj = { ... }
          const newObj = {}
          ​
          Object.keys(obj).forEach(key => {
              newObj[key] = obj[key]
          })
          
    • 最后将映射类型调用一下映射类型就会返回一个新类型,再将其返回的类型赋值给新类型就完事了

      interface IPerson {
        name: string,
        age: number,
        height: number
      }
      ​
      // 定义映射类型
      type PersonMap<T> = {
        [property in keyof T]: T[property]
      }
      ​
      // 调用映射类型对IPerson映射,获取到新类型
      type NewPersonType = PersonMap<IPerson>
      ​
      // 此时鼠标放到p上,就可以发现这是一个和IPerson一模一样的类型
      const p: NewPersonType = {
      ​
      }
      

5.2. 映射修饰符(内置工具)

  • 刚才我们说到,不能使用extends继承是因为要使用一些修饰符,这和对象的修饰符是是一样的:readonly和 ?

  • 并且我们可以通过给这两个修饰符前面加上-和+,来控制映射出来的新类型是否需要这两个修饰符

    • 如果原类型有修饰符,新类型不想要,那就用减号

    • 如果原类型没有修饰符,新类型想要,那就用加号了

      interface IPerson {
        readonly name: string;
        age: number;
        height: number;
      }
      ​
      type PersonMap<T> = {
        -readonly [property in keyof T]?: T[property];
      };
      ​
      // 新类型上没有readonly,并且每个属性都是可选的
      type NewPersonType = PersonMap<IPerson>;
      ​
      const p: NewPersonType = {};
      

6. 条件类型

  • 在开发中,我们需要基于输入的值来决定输出的值,同样的道理,我们也可以基于输入的值的类型决定输出的值的类型

  • 条件类型就可以帮我们来做这件事

    • 它类似于三元运算符

    • type ResType = OriginType extends OtherType ? TrueType : FalseType

    • 如果OriginType符合OtherType的条件的话,那么就返回TrueType,否则返回FalseType

    • 我们来对以前的那个重载签名做个优化

      function sum<T extends number | string>(arg1: T, arg2: T): T extends string ? string : number
      function sum(arg1: any, arg2: any) {
        return arg1 + arg2
      }sum(10, 20)
      

6.1. 在条件类型中推断

  • 这个知识点的意思就是:条件类型提供了infer关键字,可以从正在使用条件比较的类型中,推断出来类型,然后在True分支中将推断出来的类型返回

    • 推断出来函数的返回值类型
    type FooFnType = (num1: number, num2: number) => number
    ​
    // TS提供的工具类型:ReturnType
    type FooReturnType = ReturnType<FooFnType>;
    ​
    // 自己实现:MyReturnType
    type MyReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never
    ​
    type MyFooReturnType = MyReturnType<FooFnType>
    
    • 推断出来函数的参数类型
    type FooFnType = (num1: number, num2: number) => number;
    ​
    // TS提供的工具类型:Parameters
    type FooReturnType = Parameters<FooFnType>;
    ​
    // 自己实现:MyParameters
    type MyParameters<T extends (...args: any[]) => any> = T extends ( ...args: infer P ) => any  ? P : never;
    ​
    type MyFooParametersType = MyParameters<FooFnType>;
    

6.2. 分发条件类型

  • 在泛型中使用条件类型的时候,如果传入了一个联合类型,那就会变成分发的

    • 分发是什么意思呢?

      • 比如下面这个例子,当我们给ToArrayType传入一个联合类型的时候,它就会自动遍历这个联合类型中的每一个类型
      • 相当于number extends any ? number[] : neverstring extends any ? string[] : never
      • 所以最后的结果就是:string[] | number[]
    type ToArrayType<T> = T extends any ? T[] : never// 得到的结果是:string[] | number[]
    type NewType = ToArrayType<number | string>