浅谈TypeScript中的接口和泛型

390 阅读10分钟

一. 接口

接口是在面向对象编程中一种规范的定义,它定义了行为和动作的规范,起一种限制的作用。TypeScript 也有接口的概念,它被用来校验数据类型是否符合要求,就像是一份具有名称的契约或者规则,契约的内容规定了某个数据结构里面的数据组成和类型,只要有某处通过名称调用了这份契约,那就意味着此处的数据必须要接受并通过契约内容的检查,否则会报错。

在typescript中,接口通过interface来定义,只用来定义(数据)结构,不要去实现,当遇到一种复杂的数据的时候,我们可以通过接口来描述它的结构,下面我们将介绍四种接口:属性类接口,函数类型接口,可索引接口,类类型接口

属性类接口

属性类接口一般用作对于json对象的约束,对象也是一个复杂的数据,为了说明它的接口,我们要定义对象接口。

interface IFullName{
    firstName:string;
    secondName:string;
    englishName?:string
}
function printName(name:IFullName){
    console.log(name.firstName+'-----'+name.secondName)
}
 printName({firstName:'LLL',secondName:'RRRR'})

注意

  1. 如果属性是可选的,直接在属性名称后面加? 如下
interface IFullName{
    firstName:string;
    secondName:string;
    englishName?:string
}
  1. 如果我们想多传几个参数,但是想绕过接口中对于多于属性的检查,可以有以下三种方案

    1. 使用类型断言
    printName({firstName:'LLL',secondName:'RRRR',age:12} as IFullName)
    
    1. 使用索引签名
    interface IFullName{
       firstName:string,
       secondName:string,
       englishName?:string,
       [prop:string]:any
    }
    
    1. 使用类型兼容性
    let info={
        firstName:'LLL',
        secondName:'RRRR',
        age:12
    }
     printName(info)
    

函数类型接口

对方法传入参数以及返回值进行约束,我们要定义它们的类型结构,定义参数以及返回值的类型,不要定义函数体,因为函数体是函数的实现。

如果参数可有可无,后面添加? 如果函数有返回值,定义返回值类型 如果函数没有返回值,就是void。

interface CalcFn {
  (n1: number, n2: number): number
}

function calc(num1: number, num2: number, calcFn: CalcFn) {
  return calcFn(num1, num2)
} 

const add: CalcFn = (num1, num2) => {
  return num1 + num2
}

calc(20, 30, add)

当然,除非特别的情况,还是推荐大家使用类型别名来定义函数

type CalcFn = (n1: number, n2: number) => number

可索引接口

我们使用Interface来定义对象类型,这个时候其中的属性名、类型、方法都是确定的,但是有时候我们会遇到属性名都不确定的对象,此时可用可索引接口,如下:

interface IndexLanguage {
  [index: number]: string
}
const frontLanguage: IndexLanguage = {
  0"HTML",
  1"CSS",
  2"JavaScript",
  3"Vue"
}

类类型接口

有时候,为了说明类的结构,我们也要定义类的接口,定义类的接口跟定义属性类接口一样

只定义属性和方法的结构,属性或者方法可有可无,后面添加?

interface Animal{
    name:string;
    eat(str:string):void;
  }
  
  class Cat implements Animal{
    name:string
    constructor(name:string){
      this.name = name
    }
    eat(str:string):void{
      console.log(`${name}${str}`)
    }
  }

  let cat = new Cat('花猫')
  cat.eat("鱼")

让一个class去实现一个interface,但是需要注意的是,接口描述的是类的公共部分,class还可以有自己单独的属性和方法 类接口通常首字母大写,并且使用类接口的时候,要让类使用implements关键字,来实现这个接口。

实现

声明类时,可以使用implements关键字指明该类满足某个接口。与其他显式类型注解一样,这是为类添加类型层面约束的一种便利方式。这么做能尽量保证类在实现上的正确性,防止错误出现不知具体原因。

interface Animal{
    eat(food:string):void
    sleep(hours:number):void
}

class Cat implements Animal{
    eat(food:string){
        console.log('cat is eating')
    }
    sleep(hours:number){
        console.log('cat is sleeping')
    }
}

注意:Cat 必须实现Animal声明的每个方法。一个类不限于只能实现一个接口,而是想实现多少都可以,如果忘记实现某个方法或属性,或者实现方式有误,Typescript会提醒你(不确定对不对)

与类型别名之间的比较

1. 类型别名更为通用,右边可以是任何类型,包括类型表达式(类型,外加&或|等类型运算符);而在接口声明中,右边必须为结构。例如下述类型别名不能使用接口重写:

type A=number
type B=A | string

2. 扩展接口时,Typescript将检查扩展的接口是否可赋值给被扩展的接口。例如:

interface A{
    good(x:number):string
    bad(x:number):string
}

interface B extends A{
    good(x:string|number):string
    bad(x:string):string
}

这样会报错

而使用交集类型时则不会出现这种问题,如下:

type A={
    good(x:number):string
    bad(x:number):string
}

type B=A & {
    good(x:string|number):string
    bad(x:string):string
}

这样TypeScript将尽自己所能,把扩展和被扩展的类型组合在一起,最终的结果是重载bad的签名,而不会抛出编译时错误

  1. 同一作用域中的多个同名接口将自动合并,同一作用域中的多个同名类型别名将导致编译时错误。
interface IPerson{
    name:string
    running:()=>void
}

interface IPerson{
    age:number 
}

let u:IPerson={
  name:'llllll',
  age:18,
  running() {
      console.log('running') 
  }
}

Typescript将自动把二者组合成一个接口 而使用类型别名时,将出现如下错误

微信图片_20220725112005.png

与抽象类之间的关系

我们知道继承是多态的前提,所以在定义很多通用的调用接口时,我们通常会让调用者传入父类,通过多态来实现更加灵活的调用方式,但是父类本身可能并不需要对某些方法进行具体的实现,所以父类中定义的方法,我们可以定义为抽象方法

抽象类: 只能被继承,不能被实例化的类

1. 抽象类和抽象方法一般用来定义某种标准,可以用来实现多态

2. 抽象类中可以有实例方法,但是必须包含一个抽象方法,否则没有意义

3. 抽象方法只能存在于抽象类中

4. 继承抽象类的子类,必须实现抽象类中定义的抽象方法

5. 抽象类无法被实例化 下面我们用案例来展示


abstract class Shape {
  abstract getArea(): number
}

function makeArea(shape: Shape) {
  return shape.getArea()
}

class Rectangle extends Shape {
  private width: number
  private height: number
  constructor(width: number, height: number) {
    super()
    this.width = width
    this.height = height
  }
  getArea() {
    return this.width * this.height
  }
}

class Circle extends Shape {
  private r: number
  constructor(r: number) {
    super()
    this.r = r
  }
  getArea() {
    return this.r * this.r * 3.14
  }
}

防止别人new一个类,定义成抽象类就不可以了  抽象类中的方法必须被子类实现

实现接口还是扩展抽象类?

实现接口其实和扩展抽象类差不多。区别是,接口更通用、更轻量、而抽象类的作用更具体、功能更丰富。

接口是对结构建模的方式。在值层面可表示对象、数组、函数、类或类的实体。接口不生成JavaScript代码,只存在于编译时。

抽象类只能对类建模,而且生成运行时代码,即JavaScript类。抽象类可以有构造方法,可以提供默认实现,还能为属性和方法设置访问修饰符。这些在接口中都做不到。

  具体使用哪个,取决于实际用途。如果多个类共用同一个实现,使用抽象类。如果需要一种轻量的方式表示“这个类是T型”,使用接口

二. 泛型

泛型的本质是在类型层面施加约束的占位类型,这种类型可以用在类、接口和方法的创建中,是程序设计语言的一种风格和规范,在使用时作为参数指明类型。也就是说泛型在定义时并不知道具体是什么类型,而是在使用时由开发者来决定,这样定义一个泛型就可以支持任何类型了,这也是泛型的优点所在。

先看一个简单的例子:

function sum<T>(num: T): T {
  return num
}

// 1.调用方式一: 明确的传入类型
sum<number>(20)
sum<{name: string}>({name: "LLL"})
sum<any[]>(["abc"])

// 2.调用方式二: 类型推到
sum(50)
sum("abc")

T 是类型变量,它是一种特殊的变量,只用于表示类型而不是值,使用 <> 定义。定义了类型变量之后,你在函数中任何需要指定类型的地方使用 T 都代表这一种类型,这样也能保证返回值的类型与传入参数的类型是相同的了

什么时候绑定泛型

声明泛型的位置不仅限定泛型的作用域,还决定TypeScript什么时候为泛型绑定具体的类型

以以下例子为例

type Filter={
    <T>(array:T[],f:(item:T)=>boolean):T[]
}

//使用
let filter:Filter=(array,f)=>

注:以下所说的调用签名为TypeScript中表示函数类型的句法,他的句法与箭头函数十分相似,为 (a:number,b:number)=>number

这种情况T在调用签名中声明(位于签名的开始圆括号前面),TypeScript将在调用Filter类型的函数时为T绑定具体类型 而如果这样写:TypeScript则要求在使用Filter时显式绑定类型

type Filter<T>={
    (array:T[],f:(item:T)=>boolean):T[]
}

let filter:Filter<number>=(array,f)=>

一般来说,TypeScript在使用泛型时为泛型绑定具体类型:对函数来说,在调用函数时,对类来说,在实例化类时;对类型别名来说和接口来说,在使用别名和实现接口时

泛型函数

我们可以定义一个泛型函数类型,泛型函数的类型与非泛型函数的类型没什么不同,只是有一个类型参数在最前面。

直接定义:

let getValue: <T>(arg: T) => T = function<T>(arg: T): T {
  return arg
}

使用类型别名定义

type GetValue = <T>(arg: T) => T
let getValue: GetValue = function<T>(arg: T): T {
  return arg
}

也可以使用完整型调用签名

type GetValue ={
   <T>(arg: T):T
} 

let getValue: GetValue = function<T>(arg: T): T {
  return arg
}

完整型调用签名也可以使用以下这种方式

type GetValue<T> ={
   (arg: T):T
} 

let getValue: GetValue<number> = function<T>(arg: T): T {
  return arg
}

注:将T的作用域限定在类型别名GetValue中,TypeScript则要求在使用GetValue时显式绑定类型

使用接口定义

interface GetValue{
  <T>(arg: T): T
}
let getValue: GetValue = function<T>(arg: T): T {
  return arg
}
// 可以使用不同的泛型参数名,只要在数量上和使用方式上能对应上就可以
let getValue2: GetValue = function<U>(arg: U): U {
  return arg
}

对于接口而言,我们可以把泛型参数当作整个接口的一个参数,这样我们就能清楚的知道使用的具体是哪个泛型类型。如下:

// 泛型变量作为接口的变量
interface GetValue<T>{
  (arg: T): T
}
let getValue: GetValue<string> = function<T>(arg: T): T {
  return arg
}

以上一共提供了六种定义泛型函数类型的方法,可视情况自行选择

泛型让函数的功能更具一般性,比接受具体类型的函数更强大。我们知道,把函数的参数注解为n:number,参数n的值就被约束为number类型。同样,泛型T把T所在位置的类型约束为T绑定的类型

泛型类

泛型类使用( <>)括起泛型类型,跟在类名后面。

class Sum<T> {
    value: T;
    add: (x: T, y: T) => T;
}

// T 为 number 类型
let test = new Sum<number>();
test.value = 0;
test.add = function(x, y) { return x + y; };

// T 为 string 类型
let test1 = new Sum<string>();
test1.value = "";
test1.add = function(x, y) { return x + y; };

 类有两部分:静态部分和实例部分, 泛型类指的是实例部分的类型,所以类的静态属性不能使用这个泛型类型。

泛型约束

我们有时在操作某值的属性时,是事先知道它具有此属性的,但是编译器不知道,就比如有个例子,我们访问 arg.length 是行不通的:

function getValue<T>(arg: T): T {
  console.log(arg.length) // 类型“T”上不存在属性“lengthreturn arg
}

现在我们可以通过泛型约束来对泛型变量进行约束,让它至少包含 length 这一属性,具体实现如下:

// 定义接口,接口规定必须有 length 这一属性
interface Lengthwise{
  length: number
}

// 使用接口和 extends 关键字实现约束,此时 T 类型就必须包含 length 这一属性
function getValue<T extends Lengthwise>(arg: T): T {
  console.log(arg.length) // 通过,因为被约束的 T 类型是包含 length 属性的
  return arg
}

现在这个泛型函数被定义了约束,因此它不再是适用于任意类型:

getValue(3) // 类型“3”的参数不能赋给类型“Lengthwise”的参数

getValue({value: 3, length:10}) // right

getValue([1, 2, 3]) // right

注意:不管是类型别名,还是接口 还是泛型,他们只和类型有关,与具体的值没有什么关系