TS系列篇|接口(interface) 和 类型别名(type)

5,256 阅读10分钟

"不畏惧,不将就,未来的日子好好努力"——大家好!我是小芝麻😄

接口一方面可以在面向对象编程中表示为 行为的抽象, 另外也可以用来描述 对象的形状

一、接口

1、接口的使用

  • interface 中可以用分号或者逗号分割每一项,也可以什么都不加
interface Person {
    name: string;
    age: number;
}

let tom: Person = {
    name: 'Tom',
    age: 25
};

1.1 对象的形状

上面的例子中,我们定义了一个接口 Person,接着定义了一个变量 tom,它的类型是 Person。这样,我们就约束了 tom 的形状必须和接口 Person 一致。

  • 定义的变量比接口少了一些属性是不允许的:
interface Person {
    name: string;
    age: number;
}

let tom: Person = { // ERROR
    name: 'Tom'
};

image.png

  • 多一些属性也是不允许的:
interface Person {
    name: string;
    age: number;
}

let tom: Person = {
    name: 'Tom',
    age: 25,
    gender: 'male' // ERROR
};

image.png

可见,赋值的时候,变量的形状必须和接口的形状保持一致

1.2 行为的抽象

实现(implements)是面向对象中的一个重要概念。

一般来讲,一个类只能继承自另一个类,有时候不同类之间可以有一些共有的特性,这时候就可以把特性提取成接口(interface),用 implements 关键字来实现。这个特性大大提高了面向对象的灵活性。

  • 接口就是把一些类中共有的属性和方法抽象出来,可以用来约束实现此接口的类
  • 一个类可以继承另一个类并实现多个接口
  • 接口像插件一样是用来增强类的,而抽象类是具体类的抽象概念
  • 一个类可以实现多个接口,一个接口也可以被多个类实现,但一个类的可以有多个子类,但只能有一个父类

举例来说,门是一个类,防盗门是门的子类。

如果防盗门有一个报警器的功能,我们可以简单的给防盗门添加一个报警方法。

这时候如果有另一个类,车,也有报警器的功能,就可以考虑把报警器提取出来,作为一个接口,防盗门和车都去实现它:

interface Alarm {
    alert(): void;
}

class Door {
}

class SecurityDoor extends Door implements Alarm {
    alert() {
        console.log('SecurityDoor alert');
    }
}

class Car implements Alarm {
    alert() {
        console.log('Car alert');
    }
}

一个类可以实现多个接口:以逗号隔开

interface Alarm {
    alert(): void;
}

interface Light {
    lightOn(): void;
    lightOff(): void;
}

class Car implements Alarm, Light {
    alert() {
        console.log('Car alert');
    }
    lightOn() {
        console.log('Car light on');
    }
    lightOff() {
        console.log('Car light off');
    }
}

上例中,Car 实现了 Alarm 和 Light 接口,既能报警,也能开关车灯。

2、接口的属性

2.1 可选属性

带有可选属性的接口与普通的接口定义差不多,只是在可选属性名字定义的后面加一个?符号。

interface Person {
    name: string;
    age?: number;
}
// 可选属性的含义是该属性可以不存在。
let tom: Person = {
    name: 'Tom'
};

let tom: Person = {
    name: 'Tom',
    age: 25
};
// 这时**仍然不允许添加未定义的属性**:
let tom: Person = {
    name: 'Tom',
    age: 25,
    gender: 'male' // ERROR
};

可选属性的好处:

  • 一是可以对可能存在的属性进行预定义
  • 二是可以捕获引用了不存在的属性时的错误。

2.2 任意属性

无法预先知道有哪些新的属性的时候,可以使用[propName: string]: any, propName 名字是任意的

  • 一旦定义了任意属性,那么确定属性和可选属性的类型都必须是它的类型的子集
interface Person {
    name: string;
    age?: number; // ERROR 一旦定义了任意属性,那么确定属性和可选属性的类型都必须是它的类型的子集
    [propName: string]: string;
}

let tom: Person = {
    name: 'Tom',
    age: 25,
    gender: 'male'
};

上例中,任意属性的值允许是 string,但是可选属性 age 的值却是 numbernumber 不是 string 的子属性,所以报错了。

  • 一个接口中只能定义一个任意属性。如果接口中有多个类型的属性,则可以在任意属性中使用联合类型:
interface Person {
    name: string;
    age?: number;
    [propName: string]: string | number;
}

let tom: Person = {
    name: 'Tom',
    age: 25,
    gender: 'male'
};

2.3 只读属性

一些对象属性只能在对象刚刚创建的时候修改其值。 可以在属性名前用 readonly来指定只读属性:

interface Person {
    readonly id: number;
    name: string;
    age?: number;
    [propName: string]: any;
}

let tom: Person = {
    id: 89757,
    name: 'Tom',
    gender: 'male'
};

tom.id = 9527; // ERROR 

image.png 上例中,使用 readonly 定义的属性 id 初始化后,又被赋值了,所以报错了。

  • 注意,只读的约束存在于第一次给对象赋值的时候,而不是第一次给只读属性赋值的时候
interface Person {
    readonly id: number;
    name: string;
    age?: number;
    [propName: string]: any;
}

let tom: Person = { // ERROR 
    name: 'Tom',
    gender: 'male'
};

tom.id = 89757; // ERROR

上例中,报错信息有两处,第一处是在对 tom 进行赋值的时候,没有给 id 赋值。

第二处是在给 tom.id 赋值的时候,由于它是只读属性,所以报错了。

readonly vs const

最简单判断该用readonly还是const的方法是看要把它做为变量使用还是做为一个属性。 做为变量使用的话用 const,若做为属性则使用readonly

  • readonly 修饰的变量只能在 构造函数 中初始化
  • TS 中,const常量标志符,其值不能被重新分配
  • TS 的类型系统同样允许 interfacetypeclass 上的属性标识为 readonly
  • readonly 实际上只是在 编译阶段进行代码检查。而 const 则会在运行时检查(在支持 const 语法的 JS 运行时环境中)

3、接口的继承

3.1 接口继承接口

和类一样,接口也可以相互继承。 这让我们能够从一个接口里复制成员到另一个接口里,可以更灵活地将接口分割到可复用的模块里。

interface Alarm {
    alert(): void;
}

interface LightableAlarm extends Alarm {
    lightOn(): void;
    lightOff(): void;
}

这很好理解,LightableAlarm 继承了 Alarm,除了拥有 alert 方法之外,还拥有两个新方法 lightOn 和 lightOff

  • 一个接口可以继承多个接口,创建出多个接口的合成接口。
interface Alarm {
    alert(): void;
}
interface Wheel {
    go(): void;
}
interface LightableAlarmWheel extends Alarm, Wheel {
    lightOn(): void;
    lightOff(): void;
}

3.2 接口继承类

当接口继承了一个类类型时,它会继承类的成员但不包括其实现。

  • 就好像接口声明了所有类中存在的成员,但并没有提供具体实现一样。
  • 接口同样会继承到类的 privateprotected 成员。 这意味着当创建了一个接口继承了一个拥有私有或受保护的成员的类时,这个接口类型只能被这个类或其子类所实现(implement)。
class Control {
  private state: any;
}
interface SelectableControl extends Control {
  select(): void;
}
class Button extends Control implements SelectableControl {
  select() { }
}
class TextBox extends Control {
  select() { }
}
// 错误:“Image”类型缺少“state”属性。
class Image implements SelectableControl {
  select() { }
}
class Location {
}

在上面的例子里,

  • SelectableControl 接口包含了Control 类的所有成员,包括私有成员state。 因为state是私有成员,所以只能够是 Control 类 的子类们才能实现SelectableControl 接口。因为只有 Control 类 的子类才能够拥有一个声明于 Control 类 的私有成员state

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

为什么 TypeScript 会支持接口继承类呢? [2]

以下述为例:

class Point {
    x: number;
    y: number;
    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
}

interface Point3d extends Point {
    z: number;
}

let point3d: Point3d = {x: 1, y: 2, z: 3};

实际上,当我们在声明 class Point 时,除了会创建一个名为 Point 的类之外,同时也创建了一个名为 Point 的类型(实例的类型)。

所以我们既可以将 Point 当做一个类来用(使用 new Point 创建它的实例):

也可以将 Point 当做一个类型来用(使用 : Point 表示参数的类型):

class Point {
    x: number;
    y: number;
    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
}
const p = new Point(1, 2); // 当做一个类来用

function printPoint(p: Point) { // 当做一个类型来用
    console.log(p.x, p.y);
}

printPoint(new Point(1, 2));

这个例子实际上可以等价于:

class Point {
    x: number;
    y: number;
    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
}

interface PointInstanceType {
    x: number;
    y: number;
}

const p = new Point(1, 2);

function printPoint(p: PointInstanceType) {
    console.log(p.x, p.y);
}

printPoint(new Point(1, 2));

上例中我们新声明的 PointInstanceType 类型,与声明 class Point 时创建的 Point 类型是等价的。

所以回到 Point3d 的例子中,我们就能很容易的理解为什么 TypeScript 会支持接口继承类了:

class Point {
    x: number;
    y: number;
    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
}

interface PointInstanceType {
    x: number;
    y: number;
}

// 等价于 interface Point3d extends PointInstanceType
interface Point3d extends Point {
    z: number;
}

let point3d: Point3d = {x: 1, y: 2, z: 3};

当我们声明 interface Point3d extends Point 时,Point3d 继承的实际上是类 Point 的实例的类型。

换句话说,可以理解为定义了一个接口 Point3d 继承另一个接口 PointInstanceType

所以「接口继承类」和「接口继承接口」没有什么本质的区别。

值得注意的是,PointInstanceType 相比于 Point,缺少了 constructor 方法,这是因为声明 Point 类时创建的 Point 类型是不包含构造函数的。另外,除了构造函数是不包含的,静态属性或静态方法也是不包含的(实例的类型当然不应该包括构造函数、静态属性或静态方法)。

换句话说,声明 Point 类时创建的 Point 类型只包含其中的实例属性和实例方法:

class Point {
    /** 静态属性,坐标系原点 */
    static origin = new Point(0, 0);
    /** 静态方法,计算与原点距离 */
    static distanceToOrigin(p: Point) {
        return Math.sqrt(p.x * p.x + p.y * p.y);
    }
    /** 实例属性,x 轴的值 */
    x: number;
    /** 实例属性,y 轴的值 */
    y: number;
    /** 构造函数 */
    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
    /** 实例方法,打印此点 */
    printPoint() {
        console.log(this.x, this.y);
    }
}

interface PointInstanceType {
    x: number;
    y: number;
    printPoint(): void;
}

let p1: Point;
let p2: PointInstanceType;

上例中最后的类型 Point 和类型 PointInstanceType 是等价的。

同样的,在接口继承类的时候,也只会继承它的实例属性和实例方法。

4、函数类型接口

除了描述带有属性的普通对象外,接口也可以描述函数类型。

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

这样定义后,我们可以像使用其它接口一样使用这个函数类型的接口。

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

对于函数类型的类型检查来说,函数的参数名不需要与接口里定义的名字相匹配。

5、可索引类型接口

用来对数组和对象进行约束

与使用接口描述函数类型差不多,我们也可以描述那些能够“通过索引得到”的类型,可索引类型具有一个 索引签名,它描述了对象索引的类型,还有相应的索引返回值类型。

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

let myArray: StringArray;
myArray = ["Bob", "Fred"]; 
// 或者 myArray = { 0: "Bob", 1: "Fred" };

let myStr: string = myArray[0];

二、类型别名

类型别名用来给一个类型起个新名字。

type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
    if (typeof n === 'string') {
        return n;
    } else {
        return n();
    }
}

接口 VS 类型别名

  • 接口创建了一个新的名字,他可以在其他任意地方被调用。而类型别名并不创建新的名字,例如报错信息就不会使用别名
  • 类型别名不能被 extends 和 implements, 这时我们应该尽量使用接口代替类型别名
  • 当我们需要使用联合类型或者元组类型的时候,类型别名会更合适

参考文献

[1]. TypeScript中文网

[2]. 接口继承类