TypeScript精髓解读:接口

本文的价值在于,基于官方文档,作一种更为通俗和易于理解的解读,使得读者能够用最小的时间和精力成本,把握TypeScript接口的精髓

第一章 什么是接口

我们知道在TypeScript里, 需要对各种值进行类型检查。为了实现对类型的检查,我们引入了接口。

也就是说,接口实际上是一种约定,它约定了一种类型

用官方文档的说法,即

接口的作用就是为这些类型命名和为你的代码或第三方代码定义契约。

第二章 不用接口可以做到吗

我们很自然的想到,接口可以实现对类型约束和检查,那我们直接约束变量的类型也可以啊?为啥还要用它呢?

function printLabel(labelledObj: { label: string }) {
  console.log(labelledObj.label);
}

let myObj = { size: 10, label: "Size 10 Object" };
printLabel(myObj);

上面的案例中,我们通过直接约束labelledObj的类型,也实现了同样的目的。那接口的作用是什么呢?

interface LabelledValue {
  label: string;
}

function printLabel(labelledObj: LabelledValue) {
  console.log(labelledObj.label);
}

let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);

事实上,接口的作用是规定了一种类型,可以视作某种类型的抽取,以实现复用。在上面代码中,我们规定了LabelledValue 这种接口,那么这种接口就可以用于约束多个变量。假设不这么做,那么如果还有更多的变量也要接受同样的约束条件,那么我们就需要一个一个的反复定义,相当繁琐

第三章 接口的写法

接口的定义非常简单,只需要使用interface关键字,后面跟上接口名字,以及花括号,花括号中是

属性名:属性类型;

这样的集合。如下:

interface LabelledValue {
  label: string;
}

注意的是,这里属性类型后面跟的是分号。如果写作逗号或者直接换行也是可以的,但是为了规范,推荐使用分号。

第四章 可选属性

一旦我们使用了接口,那么接口对值具有比较强的约束,譬如当规定某个值是某种接口类型,那么它就必须具有接口规定的属性。假设某些属性并非是一定要有的,那可以使用可选属性:

interface SquareConfig {
  color?: string;
  width?: number;
}

这时,SquareConfig类型的值,就可以不必一定要有color和width属性了:

let a: SquareConfig = {};

即便我们这样写也是可以的。

你可能会有一个疑问,这不是和没有约束一样了吗?

并不是,首先这里a仍然需要是一个对象;其次,这里可能还有其他约束的属性并非可选的,而是必须的;第三,当a里面有color属性时,这个color就必须是string类型而不能是其他类型。因此,约束仍然是存在的。

第五章 只读属性

我们可能需要某个对象的某个属性是只读的,这时可以使用接口的只读属性来进行约束:

interface Point {
    readonly x: number;
    readonly y: number;
}

let p1: Point = { x: 10, y: 20 };
p1.x = 5; // error!

这样,尝试为p1的x或y属性进行再次赋值,就会报错。

补充:

特别说明的是,通过只读属性,TypeScript还可以对数组进行很强的约束:

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;
ro[0] = 12; // error!
ro.push(5); // error!
ro.length = 100; // error!
a = ro; // error!

上面,我们看到ReadonlyArray<number>实际上就是数组类型Array<number>的一点变体而已。这样约束之后,可以确保所有对数组进行修改的操作都将报错。

最后一行a = ro一句,由于ro是只读数组,即便a的约束比ro弱,也不能将ro赋值给其他值,因此也会报错。

想要实现这一操作,可以使用我上一篇博客基础类型中讲到的类型断言实现:

a = ro as number[];

这里,我们断言ro是number[]类型,而number[]是可以赋值的,因此可以赋值给a。

第六章 额外的属性检查

何谓额外的属性检查?

也就是说,当我们给函数传递参数,并且参数是一个对象字面量时,会经过额外的属性检查:

interface SquareConfig {
    color?: string;
    width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
    // ...
}

// error: 'colour' not expected in type 'SquareConfig'
let mySquare = createSquare({ colour: "red", width: 100 });

上面的案例中,传递给createSquare的参数是一个对象字面量{ colour: "red", width: 100 },这时编译器会进行额外的检查,会发现{ colour: "red", width: 100 }这个对象字面量,有SquareConfig类型不具有的属性colour,因此会报错。

对象字面量是最为关键的几个字,这意味着如果我们不采用对象字面量方式,而采用变量方式,就会绕开这种检查:

let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

这时,传递给createSquare的参数变成了一个变量,即便值和之前的写法几乎相同,但是却不会进行额外类型检查。

第七章 函数类型

接口也可以描述函数类型:

interface SearchFunc {
  (source: string, subString: string): boolean;
}

即SearchFunc类型的函数,具有的特征是:具有两个参数,第一个参数是string类型,第二个参数也是string类型,并且返回一个布尔值。

现在,我们可以这样使用上面的接口:

let mySearch: SearchFunc;
mySearch = function(src: string, sub: string): boolean {
  let result = src.search(sub);
  return result > -1;
}

我们看到,参数的名字,我们并没有和上面接口的定义完全一致,这在函数类型中是完全允许的。

另外,已经用接口约束了函数类型之后,我们还可以省略函数中的类型:

let mySearch: SearchFunc;
mySearch = function(src, sub) {
    let result = src.search(sub);
    return result > -1;
}

上面这样也是允许的。但是,TypeScript会自动进行推断,如果返回值的类型不对,将会报错:

let mySearch: SearchFunc;
mySearch = function (src, sub) {
  let result = src.search(sub);
  return result;
};

这里,result是number类型(字符串调用search方法的返回值),不符合接口的定义,因此会报错。

第八章 可索引类型

什么是可索引类型呢?即我们可以通过索引取值的类型,如a[10]ageMap["daniel"],即接口可以定义这样的类型。有点类似于JavaScript中的对象,只不过这种类型不是单纯的对象,而是接口定义的。

interface StringArray {
  [index: number]: string;
}

let myArray: StringArray;
myArray = ["Bob", "Fred"];

let myStr: string = myArray[0];

在StringArray这个接口定义中,[index:number]:string表明通过数字类型的索引取值时,将返回string类型。如,上面案例中,myArray[0],就是通过数字类型的索引取值,最终得到的是就是'Blob'这个string类型。

索引可以是数字类型(如a[0]),或是字符串类型(如a['name']),两者可以同时使用,但数字索引的返回值必须是字符串索引返回值类型的子类型,试看下面的例子:

class Animal {
    name: string;
}
class Dog extends Animal {
    breed: string;
}

// 错误:使用数值型的字符串索引,有时会得到完全不同的Animal!
interface NotOkay {
    [x: number]: Animal;
    [x: string]: Dog;
}

上面的代码编译会报错,因为在NotOkay这个可索引类型的定义中,number类型索引的返回值是Animal类型,string类型索引的返回值是Dog类型,字符串索引的返回值是数字索引的返回值的子类型了,恰好相反。

另外,一旦我们定义了索引类型的返回值,那么就不能与之冲突,否则会报错:

interface NumberDictionary {
  [index: string]: number;
  length: number;    // 可以,length是number类型
  name: string       // 错误,`name`的类型与索引类型返回值的类型不匹配
}

这里,第二行我们已经定义了string类型的索引返回值是number类型,而在第四行,我们定义NumberDictionary类型有一个name属性,并且为string类型。两者就冲突了,原因在于,假设有一个NumberDictionary类型的变量a,那么a['name']按照第二行属于按string索引,应该返回number类型,而第四行又规定是string类型,这是不允许的。

索引还可以规定为只读,以禁止赋值:

interface ReadonlyStringArray {
    readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // error!

我们增加了readonly关键字,myArray[2]这种试图给签名赋值的做法将会报错。

第九章 类类型

在其他语言中,类实现接口是非常常规的事情。我们前面花了大量篇幅来讲解,TypeScript中的接口实际上是一种类型约束,这与其他语言是不一样的。前面的内容,我们不能说某个变量实现了某个接口。因为接口没有与类结合起来,不能说实现。

接口与类结合时,我们就可以像其他语言那样,说类实现接口了:

interface ClockInterface {
  currentTime: Date;
  setTime(d: Date);
}

class Clock implements ClockInterface {
  currentTime: Date;
  setTime(d: Date) {
    this.currentTime = d;
  }
  constructor(h: number, m: number) {}
}

clock类实现了ClockInterface接口,那么Clock类就必须有Date类型的currentTime 属性,并且还必须实现setTime方法,setTime方法必须接收一个Date类型的参数。

需要注意的是,接口只描述类的公共部分,而不会描述私有部分。

第十章 类静态部分与实例部分

对于一个类而言,它有两个部分:静态部分的类型和实例的类型。看下面的案例:

interface ClockConstructor {
    new (hour: number, minute: number);
}

class Clock implements ClockConstructor {
    currentTime: Date;
    constructor(h: number, m: number) { }
}

Clock类的constructor属于静态部分的类型,而其他成员则属于实例的类型。在接口ClockConstructor的定义中,new关键字表明,实现ClockConstructor的类,应该具有一个接受两个参数hour和minute的constructor。但是,Typescript在检查时,只会检查Clock的实例类型部分,不会检查静态部分,会认为Clock并没有实现ClockConstructor 规定的constructor,因此上面的代码会报错。

那么一个疑问是,既然检查不到,我们如何通过接口定义类的静态部分呢?

答案是我们可以通过增加接口,将其作为“中介”的方式来模拟实现。(下面这个案例比较复杂,可能不那么容易理解,可以先跳过):

interface ClockConstructor {
    new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
    tick();
}

我们定义了两个接口,ClockInterface定义了tick方法,实现它的类必须有tick方法。ClockConstructor规定了构造函数的行为,实现它的类,在创造类实例时,得到的实例是ClockInterface类型。

class DigitalClock implements ClockInterface {
    constructor(h: number, m: number) { }
    tick() {
        console.log("beep beep");
    }
}
class AnalogClock implements ClockInterface {
    constructor(h: number, m: number) { }
    tick() {
        console.log("tick tock");
    }
}

这个两个类都实现了ClockInterface。

function createClock(ctor: ClockConstructor, hour: number, minute: number):
 ClockInterface {
    return new ctor(hour, minute);
}

在函数体内,创建了第一个参数ctor的实例,并返回。又由于ctor是ClockConstructor类型,由前面知道ClockConstructor构造实例时返回ClockInterface类型,因此ctor的实例必然返回ClockInterface类型。

let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);

上面的代码,将能够正常工作,原因是DigitalClock 和AnalogClock 满足ClockConstructor的形式,可以认为是ClockConstructor类型。

现在来总结一下上面的案例:

(1)我们定义了一个函数,用于生成对应的类,这比较像工厂模式。这个函数的作用是,根据传入的类,生成一个新的类。

(2)我们定义了两个接口,用于对类进行约束。其中一个接口约束类的实例部分(ClockInterface),我们最终得到的类必须满足该接口的形式;另一个接口约束构造函数(ClockConstructor ),即约束类的静态部分,我们传入函数的第一个参数必须是该接口类型。后一个类型的实例将是前者类型,因此巧妙的实现了对两部分的约束。

第十一章 继承接口

interface Shape {
    color: string;
}

interface Square extends Shape {
    sideLength: number;
}

接口继承另一个接口时,只需要使用extends关键字即可。也可以继承多个接口:

interface Shape {
    color: string;
}

interface PenStroke {
    penWidth: number;
}

interface Square extends Shape, PenStroke {
    sideLength: number;
}

现在声明一个变量:

let square = <Square>{};

那么这个变量的形式,以及color、sideLength、penWidth属性都将收到约束 。

第十二章 混合类型

我们知道在JavaScript中,函数同时也是对象,我们把函数当做对象来做一些操作也是可以的。同样的,TypeScript也可以这样:

interface Counter {
    (start: number): string;
    interval: number;
    reset(): void;
}

function getCounter(): Counter {
    let counter = <Counter>function (start: number) { };
    counter.interval = 123;
    counter.reset = function () { };
    return counter;
}

let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;

在上面这段代码中,函数getCounter调用后的结果是Counter类型,而在Counter类型的定义中看到,即可以作为函数调用,又拥有一些属性。变量c即是Counter类型,c(10)是把它作为函数调用,将得到一个string类型,c.reset和c.interval则是将c按对象进行操作。这便是接口的混合类型。

第十三章 接口继承类 

在第九章,我们已经知道了类和接口的一种最常见关系:类实现接口,现在反过来,接口也可以继承类,这便是另一种关系。如下:

class Control {
    private state: any;
}

interface SelectableControl extends Control {
    select(): void;
}

 继承我们用的是extends关键字,实现我们用的是implements关键字。接口继承类的含义是,就好像接口声明了所有类中存在的成员,但并没有提供具体实现

需要注意的是,接口同样会继承到类的private和protected成员。 这意味着当你创建了一个接口继承了一个拥有私有或受保护的成员的类时,这个接口类型只能被这个类或其子类所实现。例如:

class Control {
  private state: any;
}

interface SelectableControl extends Control {
  select(): void;
}

// 错误:“Image”类型缺少“state”属性。
class Image implements SelectableControl {
  select() {}
}

这里SelectableControl包含了Control的所有成员,包括私有成员state。 因为 state是私有成员,所以只能够是Control的子类们才能实现SelectableControl接口。 因为只有 Control的子类才能够拥有一个声明于Control的私有成员state,这对私有成员的兼容性是必需的。所以上面的Image由于不是Control的子类,故而会报错。

class Control {
  private state: any;
}

interface SelectableControl extends Control {
  select(): void;
}

class Button extends Control implements SelectableControl {
  select() {}
}

class TextBox extends Control {
  select() {}
}

Control类内部,是允许通过SelectableControl的实例来访问私有成员state的。 实际上, SelectableControl接口和拥有select方法的Control类是一样的。 ButtonTextBox类是SelectableControl的子类(因为它们都继承自Control并有select方法)。

全文完。

猜你喜欢

转载自blog.csdn.net/u012443286/article/details/119979341