TypeScript 漫游记(六)|接口


theme: cyanosis

大家好,我是半虹,这篇文章来讲 TypeScript 中的接口


接口主要用于描述对象的结构,定义对象应该具有的属性和方法

但是本身不会提供对应的实现,可以理解成它是类型检查的工具


1、定义与使用

我们可以使用 interface  关键字定义接口,关键字后是接口的名称和内容

内容使用类似对象字面量的语法,对象字面量中可以声明对象的属性和方法

(1)声明对象属性

对象字面量中可以声明属性,属性名后使用冒号指定属性类型

如果字面量中存在多个属性,那么可以使用逗号或分号来隔开

// 定义
interface Test {
  // 声明属性及类型
  x: number; // 属性名是 x,对应的属性类型是 number
  y: string; // 属性名是 y,对应的属性类型是 string
}

// 使用
// 指定变量类型为上述接口,因此该变量要满足约束,赋值时对象的属性必须:不多、不少、不错
let test:Test = {
x: 123,
y: ‘0’,
}
test.x;
test.y;

属性中会有一些特殊的属性,对应真实对象类型有不同的作用

  • 可选属性:可选属性在实际赋值时可以忽略;只需在属性名后加 ?
  • 只读属性:只读属性在初始赋值后无法修改;只需在属性名前加 readonly
  • 索引签名:定义一组通用的对象属性;语法为 [name: T]: U
// 定义
interface Test {
  x : number;         // 普通属性
  y?: number;         // 可选属性,可以理解成:y: number|undefiend;
  readonly z: number; // 只读属性
  [property: string]: number|undefined; // 索引签名

// 索引签名语法为:[name: T]: U,要求类型为 T 的属性名,对应的属性值类型为 U,其中 name 可任意指定
// 对应约束具体为:
// 1. 接口中的所有属性都要满足索引签名
// 2. 所有满足索引签名的属性都允许存在
}

// 使用
let test:Test = {
x: 123, // 普通属性
// 可选属性在实际赋值时可以忽略(赋值时对象的属性可少)
z: 345, // 只读属性在初始赋值后无法修改
a: 456, // 所有满足索引签名的属性都允许(赋值时对象的属性可多)
}
test.x = 111; // 编译正常,普通属性可以修改
test.z = 333; // 编译错误,只读属性无法修改

(2)声明对象方法

此外字面量中还能声明方法,声明方法的方式可具体分为两种

  1. 第一种类似于函数声明,会在函数字面量中指定参数以及返回类型
  2. 第二种类似于函数表达式,用函数类型签名指定参数以及返回类型

函数表达式的类型签名分为两种,分别是简写版和完整版

  1. 简写版的语法类似箭头函数,且只可写一条声明

    在该声明中,箭头前是函数参数及其类型,箭头后是函数返回类型

  2. 完整版的语法类似对象形式,且可以写多条声明,即表示函数重载

    每条声明中,冒号前是函数参数及其类型,冒号后是函数返回类型

// 定义
interface Test {
  // 声明方法及类型
  f0(a:number, b:number):number;      // 函数声明
  f1: (a:number, b:number) => number; // 函数表达式 + 简写版类型签名
  f2: {                               // 函数表达式 + 完整版类型签名(可以理解成接口的嵌套)
    (a:number, b:number): number;
  }
}

// 使用
// 方法赋值也有三种写法,但这些写法与声明写法无关,可以自由选择,只需满足对于参数和返回类型的约束即可
let test:Test = {
f0(a:number, b:number):number { return a + b; }, // 函数声明
f1: function(a:number, b:number):number { return a + b; }, // 函数表达式 - 普通函数
f2: (a:number, b:number):number => { return a + b; }, // 函数表达式 - 箭头函数(注意 this 的指向)
}
test.f0(1, 2);
test.f1(2, 3);
test.f2(3, 4);

(3)声明普通函数

接口中除了能声明对象的属性和方法外,还能声明独立的函数

类似于对象属性,可以用冒号隔开函数的参数列表和返回类型

// 定义
interface Test {
  // 声明普通函数
  (a:number, b:number): number;
}

// 注意,普通函数和对象方法的区别:
// 声明是普通函数,则意味着实现该接口的变量是函数,要求函数本身要满足该签名
// 声明是对象方法,则意味着实现该接口的变量是对象,要求对象方法要满足该签名

// 使用
let test:Test = function(a, b) { return a + b; }
test(1, 2);

接口定义中,允许存在多个函数声明,此时表示的是函数重载

但是赋值时,要求一次实现所有声明,参数以及返回类型需要处理所有可能情况

函数调用时,参数需要满足任一声明,并且具体会按函数声明顺序逐一进行匹配

// 定义
interface Test {
  (a:number, b:number): number; // 声明 1:传入两个数字,返回一个数字
  (a:number[]): number;         // 声明 2:传入数字数组,返回一个数字
}

// 使用
let test:Test = function(
a : number|number, // 如果是声明 1,那么 a 是 number;如果是声明 2,那么 a 是 number
b?: number // 如果是声明 1,那么 b 是 number;如果是声明 2,那么 b 为 空,所以该参数为可选
):number { // 无论是声明 1,还是声明 2,返回类型都是 number
if (typeof a === ‘number’ && typeof b === ‘number’) { // 处理声明 1
return a + b;
}
if (Array.isArray(a)) { // 处理声明 2
return a.reduce(function(prev, curr) { return prev + curr; });
}
throw new Error(‘wrong parameters’); // 兜底处理
}
test( 1 ); // 编译错误,单看函数没有问题,x 是 number|number, y 是可选;但是该传参无法满足任一声明
test( 1, 2 ); // 编译正常,满足声明 1
test([1, 2]); // 编译正常,满足声明 2

需要注意,同时声明普通函数以及对象的属性和方法并不冲突(虽然不经常用)

因为函数是一种特殊的对象,所以函数中也能有属性以及方法

// 定义
interface Test {
  // 声明对象属性
  x: number;
  y: number;
  // 声明对象方法
  f0(a:number, b:number):number;
  // 声明普通函数
  (a:number, b:number):number;
}

// 使用
// 此时可以通过一个中间变量过渡赋值
function temp(a:number, b:number):number {
return a + b;
}
temp.x = 1;
temp.y = 2;
temp.f0 = function(a:number, b:number):number {
return a + b;
}

let test:Test = temp;

test(test.x, test.y);
test.f0(test.x, test.y);

(4)声明构造函数

函数中存在一个特殊的函数,称为构造函数,也是类的语法糖

相比于普通函数,构造函数只需在函数声明前加上  new  即可

class Point { // 类
  x:number;
  y:number;
  // 构造函数
  constructor(x:number, y:number) {
    this.x = x;
    this.y = y;
  }
}

// 定义
interface Test {
// 声明构造函数
new (a:number, b:number): Point;
}

// 使用
function createPoint(
PointClass: Test, // 构造函数可以代表类的类型
x:number,
y:number,
):Point {
return new PointClass(x, y);
}
let point1:Point = createPoint(Point, 1, 2);
let point2:Point = createPoint(Point, 3, 4);

最后再补充一个接口的特殊用法:接口可以作为类的检查条件

只需在定义类时,在类名称后带:implements 加上接口名称

// 定义
interface Test {
  x:number;
  y:number;
}

// 使用
// 需要注意的是,此时接口约束的是类中至少需要包含的属性和方法(不同于接口作为类型时:不多、不少、不错)
// 换句话说就是,类中可以包含不存在于接口定义里面的属性和方法
class C implements Test {
x:number;
y:number;
constructor(x:number, y:number) {
this.x = x;
this.y = y;
}
// 不存在于接口中的方法
distanceToOrigin() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
}



2、合并

(1)interface 合并 interface

多个同名接口会自动进行合并,如果这些接口存在同名的属性或方法,表现如下:

  • 同名属性的类型不能冲突,否则编译无法通过

  • 同名方法会自动进行重载,并且后定义的比前定义的优先级更高

    但是这里也有个特殊情况,就是参数有字面量类型的优先级更高

这里还要注意,如果想要重载,那么对象方法只能用函数声明的写法

let temp = function(a:any):any {
  return typeof a === 'string' ? Math.round(Math.random()) : -1;
}

// 1. 单个 interface 中的函数重载,优先级是正向声明顺序

interface Test1 {
(a:any):number;
(a:string):0|1;
}
interface Test2 {
(a:string):0|1;
(a:any):number;
}
let test1:Test1 = temp;
let test2:Test2 = temp;
let temp1:0|1 = test1(‘’); // 编译报错:‘’ 既能匹配 any,又能匹配 string,但是 any 声明在前,所以匹配 any ,对应返回类型是 number
let temp2:0|1 = test2(‘’); // 编译正常:‘’ 既能匹配 string,又能匹配 any,但是 string 声明在前,所以匹配 string,对应返回类型是 0|1

// 2. 多个 interface 中的函数重载,优先级是反向声明顺序

interface Test3 {
x:number;
f(a:string):0|1;
}
interface Test3 {
y:string;
f(a:any):number;
}
// 合并之后等价于:
// interface Test3 {
// x:number;
// y:string;
// // 注意顺序:
// f(a:any):number;
// f(a:string):0|1;
// }
let test3:Test3 = {
x: 123,
y: ‘0’,
f: temp,
}
let temp3:0|1 = test3.f(‘abcd’); // 编译报错

// 3. 多个 interface 中的函数重载,如果参数有字面量类型,那么优先级会更高

interface Test4 {
x:number;
f(a:‘abcd’):0|1;
}
interface Test4 {
y:string;
f(a:any):number;
}
// 合并之后等价于:
// interface Test4 {
// x:number;
// y:string;
// // 注意顺序:
// f(a:‘abcd’):0|1;
// f(a:any):number;
// }
let test4:Test4 = {
x: 123,
y: ‘0’,
f: temp,
}
let temp4:0|1 = test4.f(‘abcd’); // 编译正常

(2)interface 合并 class

除了同名接口能够合并,同名接口和类也可自动合并

其中同名属性不能冲突,同名方法自动重载,这点与同名接口的合并一致

但是合并之后接口和类的表现稍微有些不同:

  • 接口合并类后,只关注类中成员的类型,不关注类中成员的实现或值
  • 类合并接口后,因定义类时方法有要求必须实现,导致重载有些奇怪
// 类
class Test {
  x:number;
  f(a:string):0|1 { // 其中方法必须实现,否则编译器会报错
    return a.substring(0) === '' ? 0 : 1;
  };
}

// 接口
interface Test {
y:string;
f(a:any):number;
}

// 对于接口【还算正常】
// 合并之后,作为类型,需要实现接口和类中定义的所有属性和方法
let test1:Test = {
x: 123,
y: ‘0’,
// 对于其中方法来说,只管类中方法的类型,不管类中方法的实现
// 因此这里重新实现,如果方法是有重载的,那么就要实现重载的
f: function(a:any):any {
return typeof a === ‘string’ ? Math.round(Math.random()) : -1;
}
}
let temp1:0|1 = test1.f(‘’); // 编译错误,重载函数依然是有优先级的,此时参数 ‘’ 匹配的是 any【参考上一小节】

// 对于类【就会很奇怪】
// 合并之后,实例对象,其中包含接口和类中定义的所有属性和方法
let test2 = new Test();

test2.x = 123; // 未赋值前是 undefined
test2.y = ‘0’; // 未赋值前是 undefined

test2.f(1); // 编译正常,运行错误【重要】:TypeError: a.substring is not a function
// 奇怪的点:
// 编译检查时使用的是重载函数的定义,因此编译正常
// 实际运行时使用的是类中函数的实现,因此运行错误



3、继承

(1)interface 继承 interface

一个接口可以继承另外一个接口,前者称为子接口,后者称为父接口

就像类的继承,子接口可以继承父接口的成员,并且新增自己的成员

具体可以使用 extends  关键字来继承父接口,下面举个具体的例子:

interface Father {
  x:number;
  f(a:number, b:number): number;
}

interface Son extends Father {
y:string;
g(a:string, b:string): string;
}

// 子接口类型必须实现父接口原有成员和子接口新增成员
let son:Son = {
x: 123,
y: ‘0’,
f: function(a:number, b:number):number { return a + b; },
g: function(a:string, b:string):string { return a + b; },
}

需要注意的是,子接口的新增成员可以覆盖父接口的原有成员

如果二者同名,子接口的新增成员需要兼容父接口的原有成员(这里特指类型)

interface Father {
  x:number|string;
  f():void;
}

interface Son extends Father {
x:number&string; // 其实就是 never
f():boolean;
}

// 类型兼容性是 TypeScript 中的重要概念
// 之后会写一篇文章单独介绍

一个接口可以同时继承多个接口,这些接口之间用逗号来隔开,称为多重继承

多个接口中的同名方法不能重载,其中同名属性和方法的类型都要求必须相同

interface Father1 {
  x:number;
  f(a:number):number;
}

interface Father2 {
y:any;
f(a:any):any;
}

interface Father3 {
y:string;
g(a:string):string;
}

interface Son extends Father1, Father2 {} // 编译报错
interface Son extends Father1, Father3 { // 编译正常
z:boolean;
h(a:boolean):boolean;
}

let son:Son = {
x: 123,
y: ‘0’,
z: true,
f: function(a:number):number { return a; },
g: function(a:string):string { return a; },
h: function(a:boolean):boolean { return a; },
}

(2)interface 继承 type

此外,接口也可以继承类型别名,此时要求类型别名定义的必须是对象类型

// 类型别名
type Father = {
  x:number;
  f(a:number, b:number): number;
}

// 接口继承类型别名
interface Son extends Father {
y:string;
g(a:string, b:string): string;
}

let son:Son = {
x: 123,
y: ‘0’,
f: function(a:number, b:number):number { return a + b; },
g: function(a:string, b:string):string { return a + b; },
}

(3)interface 继承 class

最后,接口还可以继承类,此时会继承类中的属性和方法

// 类
class Father {
  x:number;
  f(a:number, b:number): number { return a + b; }; // 类中方法必须实现,否则编译器会报错
}

// 接口继承类
interface Son extends Father {
y:string;
g(a:string, b:string): string;
}

let son:Son = {
x: 123,
y: ‘0’,
f: function(a:number, b:number):number { return a + b; }, // 只关注类型,不关注实现,这里需要重新定义函数
g: function(a:string, b:string):string { return a + b; },
}




接口和类、接口和类型别名,这三者存在着非常细微的联系和差别,最后来补充一下


(1)接口和类

接口和类都可以作为对象的模版,定义对象应该具备的属性和方法

但是实际上二者有着微妙的区别,具体如下:

  1. 接口只包含类型代码,不包含值代码,因此编译后就不再存在

    而类既包含类型代码,也包含值代码,因此编译后还依然存在

  2. 接口只声明对象成员的类型,而不具体实现方法

    而类既声明对象成员的类型,也会同时实现方法

  3. 接口可允许多重继承,即一个接口可以继承多个接口

    而类只支持单一继承,即一个子类只能继承一个父类

另外,接口和类之间也可以交互,具体如下:

  1. 接口可以继承类 ( extends )
  2. 接口可以作为类的检查条件 ( implements )
  3. 接口和类同名就会自动合并

(2)接口和类型别名

接口和类型别名也能声明对象的模版,定义对象具备的属性和方法

但是实际上二者也是有着微妙的区别,具体如下:

  1. 接口只能声明对象类型

    类型别名则可声明任何类型

  2. 接口可以使用 extends  关键字拓展接口、类型别名、类

    类型别名则能使用  &  运算符来合并接口、类型别名

  3. 同名接口将会自动进行合并

    同名类型别名则会导致编译错误

最后稍微总结一下,接口和类型别名其实很像,多数情况下都可以自由选择

关于这一点的说明,可以看一下官网上的介绍



好啦,本文到此结束,感谢您的阅读!

如果你觉得这篇文章有需要修改完善的地方,欢迎在评论区留下你宝贵的意见或者建议

如果你觉得这篇文章还不错的话,欢迎点赞、收藏、关注,你的支持是对我最大的鼓励 (/ω\)


这是一个从 https://juejin.cn/post/7369113568572571660 下的原始话题分离的讨论话题