接口
简介
TypeScript 的核心原则之一是类型检查的重点是检查值的结构,这种类型有时被称为“鸭子类型”或“次结构类型”。在 TypeScript 中,接口充当为这些类型命名的角色,并且也是项目对内对外约定代码的有效方法。
第一个接口
要了解接口是如何工作的,最容易的方法就是从一个简单的例子开始。
function printLabel(labeledObj: { label: string }) {
console.log(labeledObj.label);
}
let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);
类型检查器检查对 printLabel 的调用,printLabel 函数只有一个参数并且要求调用时传入的对象必须具有 string 类型的属性 label,注意代码中定义的对象 myObj 实际上还有其他属性,但编译器只检查函数参数中声明的属性及其类型。有些情况下 TypeScript 语法检查不会如此宽松,我们很快将会介绍。
现在再次修改代码,这次使用接口来描述 label 属性必须是 string 类型的要求。
interface LabeledValue {
label: string;
}
function printLabel(labeledObj: LabeledValue) {
console.log(labeledObj.label);
}
let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);
现在我们可以使用 LabeledValue 来描述上面例子中的代码语义。这个接口仍然是说有一个类型为 string 的 label 属性。请注意我们不需要像其他语言那样要求传入的参数在语法上明确实现这个接口,结构才是最重要的。如果传入函数的参数满足接口列出的规则,那代码就可以正常执行。
必须要说明的是类型检查器不关心属性定义时的先后顺序,只检查接口声明的属性是否被定义且符合类型要求。
可选属性
接口中定义的属性并不都是必须要有的,一些属性在某些情况下存在或根本就不存在。当在创建类似具有动态属性的对象的时候,可选属性是非常适用的,传递到函数中的对象只有部分属性。
下面是一个这种用法的例子。
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): {color: string; area: number} {
let newSquare = {color: "white", area: 100};
if (config.color) {
newSquare.color = config.color;
}
if (config.width) {
newSquare.area = config.width * config.width;
}
return newSquare;
}
let mySquare = createSquare({color: "black"});
拥有可选属性的接口的定义方式和其他接口类似,只是在声明的时候需要在可选属性的属性名后面加一个 ?。
可选属性的好处在于可以定义可能存在的属性,同时又能防止使用不属于接口的属性。例如,如果我们在 createSquare 函数中输错了属性 color 的名字,将会返回错误。
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
let newSquare = {color: "white", area: 100};
if (config.clor) {
// Error: Property 'clor' does not exist on type 'SquareConfig'
newSquare.color = config.clor;
}
if (config.width) {
newSquare.area = config.width * config.width;
}
return newSquare;
}
let mySquare = createSquare({color: "black"});
只读属性
某些属性只能在对象首次创建的时候被修改,我们可以在属性名前面放上 readonly 关键字。
interface Point {
readonly x: number;
readonly y: number;
}
你可以使用创建对象的简单语法创建一个 Point 对象。在创建之后,x 和 y 的值就不能再修改。
let p1: Point = { x: 10, y: 20 };
p1.x = 5; // error!
TypeScript 有一个与 Array<T> 相同的 ReadonlyArray<T> 类型,只读数组类型移除了所有可修改数组本身的方法,所以你可以确保一旦数组被创建后这个数组就不会再被修改。
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!
在代码段的最后一行,可以看到,即使将整个只读数组重新赋值给普通数组也是非法的。但是你仍然可以使用类型断言来实现重新赋值。
a = ro as number[];
readonly vs const
究竟是使用 readonly 还是 const,最简单的方法就是看你是在属性上使用还是在变量上使用。变量使用 const 而属性使用 readonly。
额外属性检查
在之前的例子中我们使用了接口,TypeScript 允许我们的接口定义时的结构为 { label: string; },但是对象的实际结构为 { size: number; label: string; }。我们也了解了可选属性在创建具有动态属性的对象的时候非常有用。
但是如果将二者混用可能就会出些问题了,下面我们来看上一个有关 createSquare 函数的例子:
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
// ...
}
let mySquare = createSquare({ colour: "red", width: 100 });
注意传给 createSquare 函数的参数是 colour 而不是 color,在一般的 JavaScript 语法中,这样传值会失败并且不会报错。
你可能会说程序正确的,因为 width 属性与接口定义对应,只是 color 属性没有声明,并且 colour 属性可以看做额外属性。
但是 TypeScript 认为代码中可能有 BUG,在将对象字面量分配给其他变量或将其作为参数传递时,会对其进行特殊处理并进行额外的属性检查。如果对象字面量含有任何目标类型没有的属性,将会返回错误。
~这里着重介绍一下所谓的对象字面量(Object Literal),我们向 createSquare 函数传值有两种方法,一种是定义好对象后传入整个对象,一种是使用对象字面量,如下两种方式。
定义好对象传入整个对象:
let x = {
width: 1,
color: "red"
}
let mySquare = createSquare(x);
使用对象字面量:
let mySquare = createSquare({width: 1, color: "red"});
~这里所说的对象字面量就是指后一种传值方法。
// error: Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?
let mySquare = createSquare({ colour: "red", width: 100 });
绕过这些检查其实很简单,最简单的方法就是使用类型断言:
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);
~其实也可以先把
{ width: 100, opacity: 0.5 }提前定义好,使用一个变量来引用。
但是如果你确定对象有一些额外的属性,添加字符串索引标识符可能是更好的办法。如果 SquareConfig 定义了 color 和 width 属性,但是仍允许有一些其他属性,我们可以这样定义。
interface SquareConfig {
color?: string;
width?: number;
[propName: string]: any;
}
我们很快就会讨论索引标识符。这个例子的意思是 SquareConfig 可以有很多的属性,并且只要属性名不是 color 和 width,他们的类型无所谓。
解决这种语法检查的最后一种方法,大家可能有点惊讶。这个方法仅仅只需要把对象字面量指定给一个变量就可以了。由于 squareOptions 不会被施加额外属性检查,所以编译器不会报错:
let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);
只要在 squareOptions 和 SquareConfig 之间具有相同的属性,上面的方法就有效。在上面的例子中,width 就是二者都相同的属性。如果二者之间没有任何的相同属性,那么上面这种方法仍然会失效。
let squareOptions = { colour: "red" };
let mySquare = createSquare(squareOptions); // Error,两种类型没有相同的属性。
请记住,对于上述简单代码,或许不应该尝试去规避这些检查。对那些还含有方法和持有状态的复杂对象字面量,需要时刻注意这些技巧。大多数由额外属性检查出的错误实际上都是程序 BUG。这意味着如果在处理拥有可选属性的对象时遇到额外属性检查问题,你可能需要修改一下类型声明。在上述例子中,如果计划传递同时具有 color 和 colour 属性的对象到 createSquare 函数,那我们应该修正 SquareConfig 的定义。
函数类型
接口可以定义 JavaScript 绝大部分对象的结构。接口还可以定义函数类型。
使用接口定义函数类型,我们需要给接口加上一个函数的调用标识符。这有点像仅使用参数列表和返回类型来声明函数。参数列表中的每一个参数都需要声明名字和类型。
interface SearchFunc {
(source: string, subString: string): boolean;
}
一旦定义好了函数,我们可以像使用其他接口一样使用函数类型的接口。下面是如何创建一个函数类型的变量以及给它赋予相应类型的函数的方法。
let mySearch: SearchFunc;
mySearch = function(source: string, subString: string) {
let result = source.search(subString);
return result > -1;
}
函数类型的类型检查不要求参数名字一定要匹配,上面这个例子我们也可以改造成这样。
let mySearch: SearchFunc;
mySearch = function(src: string, sub: string): boolean {
let result = src.search(sub);
return result > -1;
}
函数的参数会被检查,但检查的是对应位置的参数的类型(~顺序一定要对应)。如果你不想指定参数的类型,TypeScript 会使用前后关系类型推断(Contextual Typing),根据函数 SearchFunc 定义的结构来推断参数的类型。同样函数的返回类型也是由返回值隐式声明的。
let mySearch: SearchFunc;
mySearch = function(src, sub) {
let result = src.search(sub);
return result > -1;
}
如果函数表达式返回数字或者字符串,类型检查器会提示错误并说明函数的返回值与接口 SearchFunc 中定义的返回类型不匹配。
let mySearch: SearchFunc;
// error: Type '(src: string, sub: string) => string' is not assignable to type 'SearchFunc'.
// Type 'string' is not assignable to type 'boolean'.
mySearch = function(src, sub) {
let result = src.search(sub);
return "string";
};
可索引类型
和我们使用接口定义函数类型一样,接口也可以定义可索引的类型,如:a[10] 或者 ageMap["daniel"]。可索引类型拥有一个索引标识符,该标识符定义了对象索引值的类型,同时也定义了索引返回值的类型。
interface StringArray {
[index: number]: string;
}
let myArray: StringArray;
myArray = ["Bob", "Fred"];
let myStr: string = myArray[0];
这里有一个具有索引标识符的 StringArray 接口。该索引标识符声明当 StringArray 使用一个 number 类型的值进行查找时将返回一个 string。
索引标识符支持两种类型:number 和 string。索引类型可以同时拥有两种类型的索引方式,但是数字索引方式返回的类型必须是字符串索引方式返回的类型的子类型。因为当使用数字索引方式的时候,JavaScript 在索引对象之前实际上是先把数字转换为字符,然后再执行索引查找的。也就是说,使用 100(数字)和 "100"(字符)执行索引查找是一样的。
class Animal {
name: string;
}
class Dog extends Animal {
breed: string;
}
// Error: 使用字符型数字("2")索引时可能会得到Animal类型
interface NotOkay {
[x: number]: Animal;
[x: string]: Dog;
}
字符索引标识符对于描述“字典”类的结构是一种好方法。字符索引标识符还强制规定所有属性的类型必须与索引标识符索引查找后返回的值的类型一致。因为使用字符索引标识符意味着 obj.property 和 obj["property"] 是具有同样效果。在下面的例子中,属性 name 的类型与字符索引标识符的类型不一致,类型检查器将返回错误。
interface NumberDictionary {
[index: string]: number;
length: number; // ok, length is a number
name: string; // error, the type of 'name' is not a subtype of the indexer
}
但是,如果索引标识符声明的类型是联合类型,那么类型不同的属性是可以存在的。
interface NumberOrStringDictionary {
[index: string]: number | string;
length: number; // ok, length is a number
name: string; // ok, name is a string
}
你也可以指定索引标识符为 readonly,这样可以避免对相应的索引重新赋值。
interface ReadonlyStringArray {
readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // error!
因为索引标识符声明为 readonly,所以你不能对 myArray[2] 重新指定值。
类类型(Class Types)
实现接口
在 C# 或 Java 中接口最常见的用法就是规定类必须实现特定接口中的约定。这种功能在 TypeScript 中也能实现。
interface ClockInterface {
currentTime: Date;
}
class Clock implements ClockInterface {
currentTime: Date = new Date();
constructor(h: number, m: number) { }
}
接口也可以定义在类里面要实现的方法,下面的代码中我们用 setTime 作演示。
interface ClockInterface {
currentTime: Date;
setTime(d: Date): void;
}
class Clock implements ClockInterface {
currentTime: Date = new Date();
setTime(d: Date) {
this.currentTime = d;
}
constructor(h: number, m: number) { }
}
接口描述的是类的公开部分功能或属性,不包括类的私有部分。不要在接口中声明类的私有部分功能或属性。
静态(static)和类实例的区别
当在使用类和接口的时候,记住类是由两部分组成:静态部分和实例部分。你可能注意到了如果你在定义接口的时候使用了构造标识符 new,当你在创建一个类并实现该接口时会返回错误。
interface ClockConstructor {
new (hour: number, minute: number);
}
class Clock implements ClockConstructor {
currentTime: Date;
constructor(h: number, m: number) { }
}
这是因为当类实现接口的时候,只有类的实例部分会被检查,由于构造函数属于类的静态部分,所以不会被检查。
你需要直接检查类的静态部分。在下面的例子中,我们定义了两个接口, ClockConstructor 接口定义构造函数,另一个接口 ClockInterface 定义实例方法。为了方便,我们定义了一个构造函数 createClock,这个函数使用参数来创建对应的实例。
interface ClockConstructor {
new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
tick(): void;
}
function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface {
return new ctor(hour, minute);
}
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");
}
}
let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);
因为 createClock 的第一个参数的类型是 ClockConstructor,在 createClock(AnalogClock, 7, 32) 中,需要检查 AnalogClock 是否有正确的构造方法标识符。
另一个更简单的方式是使用类表达式。
interface ClockConstructor {
new (hour: number, minute: number);
}
interface ClockInterface {
tick();
}
const Clock: ClockConstructor = class Clock implements ClockInterface {
constructor(h: number, m: number) {}
tick() {
console.log("beep beep");
}
}
接口继承
像类一样,接口也可以互相继承。接口继承可以复制一个接口的成员到另外一个接口中,你可以把接口拆得更简洁明了以便于组件的重复使用。
interface Shape {
color: string;
}
interface Square extends Shape {
sideLength: number;
}
let square = {} as Square;
square.color = "blue";
square.sideLength = 10;
一个接口可以继承多个接口,这样可以创建多个接口的组合。
interface Shape {
color: string;
}
interface PenStroke {
penWidth: number;
}
interface Square extends Shape, PenStroke {
sideLength: number;
}
let square = {} as Square;
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;
混合类型
我们之前说过,接口可以定义 JavaScript 中的各种类型。由于 JavaScript 具有动态和灵活的特性,有时可能会遇到是多个类型的组合的对象。
这个例子中的对象既是函数又是对象,该对象还具有一些额外的属性:
interface Counter {
(start: number): string;
interval: number;
reset(): void;
}
function getCounter(): Counter {
let counter = (function (start: number) { }) as Counter;
counter.interval = 123;
counter.reset = function () { };
return counter;
}
let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;
当与第三方 JavaScript 库交互时,你可以使用上面的方法。
继承自类的接口
当接口继承自类的时候,接口会继承类的成员,但不继承其实现。就有点类似接口声明了类的所有成员而没有提供实现。接口甚至会继承基类的私有成员和受保护成员。这意味着当创建一个继承自具有私有成员或受保护成员的类的接口时,该接口只能由该类或其子类实现。
当具有较深的继承层次结构,但要指定代码仅适用于具有某些属性的子类时,这很有用。子类必须从基类继承。
class Control {
private state: any;
}
interface SelectableControl extends Control {
select(): void;
}
class Button extends Control implements SelectableControl {
select() { }
}
class TextBox extends Control {
select() { }
}
// Error: Property 'state' is missing in type 'Image'.
class Image implements SelectableControl {
private state: any;
select() { }
}
class Location {
}
在上面的例子中,SelectableControl 继承了 Control 的所有成员,包括私有的 state 属性。由于 state 是私有成员,所以只有 Control 的子类才能实现 SelectableControl 接口,因为只有 Control 的子类才会有来自父类的 state 私有成员,这样私有成员才能兼容。
SelectableControl 的行为类似于有 select 方法的 Control,Button 和 TextBox 类是 SelectableControl 的子类(因为它们都继承自 Control 并且具有 select 方法),而 Image 和 Location 类则不是。