接口与类
接口(interface)
鸭子类型
编译器检查传入的实参是否满足函数的参数:
function printLabel(labelledObj: { label: string }) { console.log(labelledObj.label); } let myObj = { size: 10, label: "Size 10 Object" }; printLabel(myObj);
用接口来描述必须包含一个label
属性且类型为string
:
interface LabelledValue { label: string; } function printLabel(labelledObj: LabelledValue) { console.log(labelledObj.label); } let myObj = {size: 10, label: "Size 10 Object"}; printLabel(myObj);
LabelledValue
接口这里并不能像在其它语言里一样,
说传给printLabel
的对象实现了这个接口。
类型检查不检查属性的顺序,只要相应的属性存在并且类型也是对的就可以。
接口属性
可选属性
在属性名字定义的后面加一个?
符号表示可选。例子:
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.color) { // 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; } let p1: Point = { x: 10, y: 20 }; p1.x = 5; // error!
TypeScript具有ReadonlyArray<T>
类型,它与Array<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!
上面代码的最后一行,
可以看到就算把整个ReadonlyArray
赋值到一个普通数组也是不可以的。
但是你可以用类型断言重写:
a = ro as number[];
readonly vs const
最简单判断readonly
还是const
的方法是看要把它做为变量使用还是做为一个属性。
做为变量使用的话用const
,若做为属性则使用readonly
。
额外的属性检查
额外的属性检查「仅在」使用「函数字面量」作为实参时触发。 如果实参的函数字面量包含额外的属性,编译器会报错。
比如参数要求有可选的属性color
错写成了colour
:
interface SquareConfig { color?: string; width?: number; } function createSquare(config: SquareConfig): { color: string; area: number } { // ... } let mySquare = createSquare({ colour: "red", width: 100 });
你可能会争辩这个程序已经正确地类型化了,因为width
属性是兼容的,
不存在color
属性,而且额外的colour
属性是无意义的。
然而,TypeScript会认为这段代码可能存在bug。 对象字面量会被特殊对待而且会经过额外属性检查, 当将它们赋值给变量或作为参数传递的时候。 如果一个「对象字面量」存在任何「目标类型」不包含的属性时,你会得到一个错误。
// error: 'colour' not expected in type 'SquareConfig' let mySquare = createSquare({ colour: "red", width: 100 });
绕开这些检查非常简单。 最简便的方法是使用类型断言:
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);
然而,最佳的方式是能够添加一个字符串索引签名,
前提是你能够确定这个对象可能具有某些做为特殊用途使用的额外属性。
如果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);
要留意,在像上面一样的简单代码里,你可能不应该去绕开这些检查。 对于包含方法和内部状态的复杂对象字面量来讲,你可能需要使用这些技巧, 但是大部额外属性检查错误是真正的bug。 就是说你遇到了额外类型检查出的错误,比如「option bags」, 你应该去审查一下你的类型声明。
在这里,如果支持传入color
或colour
属性到createSquare
,
你应该修改SquareConfig
定义来体现出这一点。
可索引的类型
数组和映射类型能够使用中括号[]
操作,比如a[10]
或ageMap["daniel"]
。
这种操作被称为「通过索引取值」。
接口也可以定义中括号表示的索引取值。 共有支持两种索引签名:字符串和数字。
使用数字索引:
interface StringArray { [index: number]: string; } let myArray: StringArray; myArray = ["Bob", "Fred"]; let myStr: string = myArray[0];
使用字符串索引:
class Animal { name: string; } class Dog extends Animal { breed: string; } // 错误:使用'string'索引,有时会得到Animal! interface NotOkay { [x: number]: Animal; [x: string]: Dog; }
字符串的索引不仅能用obj["property"]
的形式取值,
也可以用obj.property
。
可以同时使用两种类型的索引, 但是数字索引的返回值必须是字符串索引返回值类型的子类型。
这是因为当使用number
来索引时,JavaScript会将它转换成string
然后再去索引对象。
也就是说用100
(一个number
)去索引等同于使用"100"
(一个string
)去索引,
因此两者需要保持一致。
如果字符串索引的类型与返回值类型不匹配会报错:
interface NumberDictionary { [index: string]: number; length: number; // 可以,length是number类型 name: string // 错误,`name`的类型与索引类型返回值的类型不匹配 }
最后,你可以将索引签名设置为只读,这样就防止了给索引赋值:
interface ReadonlyStringArray { readonly [index: number]: string; } let myArray: ReadonlyStringArray = ["Alice", "Bob"]; myArray[2] = "Mallory"; // error!
你不能设置myArray[2]
,因为索引签名是只读的。
函数类型接口
接口除了描述对象带有属性的普通对象外,也可以描述函数类型。
函数的接口就像是一个只有参数列表和返回值类型的函数定义:
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的类型系统会推断,
因为函数直接赋值给了SearchFunc
类型变量。
函数的返回值类型是通过其返回值推断出来的(此例是false
和true
)。
如果让这个函数返回数字或字符串,
类型检查器会警告我们函数的返回值类型与SearchFunc
接口中的定义不匹配。
let mySearch: SearchFunc; mySearch = function(src, sub) { let result = src.search(sub); return result > -1; }
类类型
实现接口
类的属性:
interface ClockInterface { currentTime: Date; } class Clock implements ClockInterface { currentTime: Date; constructor(h: number, m: number) { } }
类的方法:
interface ClockInterface { currentTime: Date; setTime(d: Date); } class Clock implements ClockInterface { currentTime: Date; setTime(d: Date) { this.currentTime = d; } constructor(h: number, m: number) { } }
接口描述了类的公共部分,而不是公共和私有两部分。 它不会帮你检查类是否具有某些私有成员。
类静态部分与实例部分的区别
当你操作类和接口的时候,你要知道类是具有两个类型的:静态部分的类型和实例的类型。
当用构造器签名去定义一个接口并试图定义一个类去实现这个接口时会得到一个错误:
interface ClockConstructor { new (hour: number, minute: number); } class Clock implements ClockConstructor { currentTime: Date; constructor(h: number, m: number) { } }
这里因为当一个类实现了一个接口时,只对其实例部分进行类型检查。
constructor
存在于类的静态部分,所以不在检查的范围内。
因此,我们应该直接操作类的静态部分。 看下面的例子,我们定义了两个接口,
ClockConstructor
为构造函数所用和ClockInterface
为实例方法所用。
为了方便我们定义一个构造函数createClock
,它用传入的类型创建实例。
interface ClockInterface { tick(); } 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"); } } interface ClockConstructor { new (hour: number, minute: number): ClockInterface; } function createClock( ctor: ClockConstructor, hour: number, minute: number): ClockInterface { return new ctor(hour, minute); } let digital = createClock(DigitalClock, 12, 17); let analog = createClock(AnalogClock , 7, 32);
因为createClock
的第一个参数是ClockConstructor
类型,
在createClock(AnalogClock, 7, 32)
里,会检查AnalogClock
是否符合构造函数签名。
继承接口
和类一样,接口也可以相互继承。 这让我们能够从一个接口里复制成员到另一个接口里, 可以更灵活地将接口分割到可重用的模块里。
interface Shape { color: string; } interface Square extends Shape { sideLength: number; } let square = <Square>{}; square.color = "blue"; square.sideLength = 10;
一个接口可以继承多个接口,创建出多个接口的合成接口。
interface Shape { color: string; } interface PenStroke { penWidth: number; } interface Square extends Shape, PenStroke { sideLength: number; } let square = <Square>{}; square.color = "blue"; square.sideLength = 10; square.penWidth = 5.0;
混合类型
因为JavaScript其动态灵活的特点, 有时你会希望一个对象可以同时具有上面提到的多种类型。
一个例子就是,一个对象可以同时做为函数和对象使用,并带有额外的属性。
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;
在使用JavaScript第三方库的时候,你可能需要像上面那样去完整地定义类型。
接口继承类
当接口继承了一个类类型时,它会继承类的成员但不包括其实现。
就好像接口声明了所有类中存在的成员,但并没有提供具体实现一样。
接口同样会继承到类的private
和protected
成员。
这意味着当你创建了一个接口继承了一个拥有私有或受保护的成员的类时,
这个接口类型只能被这个类或其子类所实现(implement)。
当你有一个庞大的继承结构时这很有用, 但要指出的是你的代码只在子类拥有特定属性时起作用。 这个子类除了继承至基类外与基类没有任何关系。 例:
class Control { private state: any; } interface SelectableControl extends Control { select(): void; } class Button extends Control implements SelectableControl { select() { } } class TextBox extends Control { } // Error: Property 'state' is missing in type 'Image'. class Image implements SelectableControl { select() { } } class Location { }
在上面的例子里,SelectableControl
包含了Control
的所有成员,包括私有成员state
。
因为state
是私有成员,所以只能够是Control
的子类们才能实现SelectableControl
接口。
因为只有Control
的子类才能够拥有一个声明于Control
的私有成员state
,
这对私有成员的兼容性是必需的。
在Control
类内部,是允许通过SelectableControl
的实例来访问私有成员state
的。
实际上,SelectableControl
就像Control
一样,并拥有一个select
方法。
Button
和TextBox
类是SelectableControl
的子类
(因为它们都继承自Control
并有select
方法),但Image
和Location
类并不是这样的。
类(class)
介绍
传统的JavaScript程序使用函数和基于原型的继承来创建可重用的组件, 但对于熟悉使用面向对象方式的程序员来讲就有些棘手, 因为他们用的是基于类的继承并且对象是由类构建出来的。 从ECMAScript 2015,也就是ECMAScript 6开始, JavaScript程序员将能够使用基于类的面向对象的方式。 使用TypeScript,我们允许开发者现在就使用这些特性, 并且编译后的JavaScript可以在所有主流浏览器和平台上运行, 而不需要等到下个JavaScript版本。
下面看一个使用类的例子:
class Greeter { greeting: string; constructor(message: string) { this.greeting = message; } greet() { return "Hello, " + this.greeting; } } let greeter = new Greeter("world");
继承
在TypeScript里,我们可以使用常用的面向对象模式。 基于类的程序设计中一种最基本的模式是允许使用继承来扩展现有的类。
看下面的例子:
class Animal { move(distanceInMeters: number = 0) { console.log(`Animal moved ${distanceInMeters}m.`); } } class Dog extends Animal { bark() { console.log('Woof! Woof!'); } } const dog = new Dog(); dog.bark(); dog.move(10); dog.bark();
下面我们来看个更加复杂的例子。
class Animal { name: string; constructor(theName: string) { this.name = theName; } move(distanceInMeters: number = 0) { console.log(`${this.name} moved ${distanceInMeters}m.`); } } class Snake extends Animal { constructor(name: string) { super(name); } move(distanceInMeters = 5) { console.log("Slithering..."); super.move(distanceInMeters); } } class Horse extends Animal { constructor(name: string) { super(name); } move(distanceInMeters = 45) { console.log("Galloping..."); super.move(distanceInMeters); } } let sam = new Snake("Sammy the Python"); let tom: Animal = new Horse("Tommy the Palomino"); sam.move(); tom.move(34);
派生类包含了一个构造函数,它必须调用super()
,它会执行基类的构造函数。
而且,在构造函数里访问this的属性之前,我们一定要调用super()
。
这个是TypeScript强制执行的一条重要规则。
这个例子演示了如何在子类里可以重写父类的方法。
Slithering... Sammy the Python moved 5m. Galloping... Tommy the Palomino moved 34m.
修饰符
默认为public
在TypeScript里,成员都默认为public。
你也可以明确的将一个成员标记成public
:
class Animal { public name: string; public constructor(theName: string) { this.name = theName; } public move(distanceInMeters: number) { console.log(`${this.name} moved ${distanceInMeters}m.`); } }
private
当成员被标记成private
时,它就不能在声明它的类的外部访问。比如:
class Animal { private name: string; constructor(theName: string) { this.name = theName; } } new Animal("Cat").name; // 错误: 'name' 是私有的.
TypeScript使用的是结构性类型系统。 当我们比较两种不同的类型时, 并不在乎它们从何处而来,如果所有成员的类型都是兼容的, 我们就认为它们的类型是兼容的。
然而,当我们比较带有private
或protected
成员的类型的时候,情况就不同了。
如果其中一个类型里包含一个private
成员,
那么只有当另外一个类型中也存在这样一个private
成员,
并且它们都是来自同一处声明时,我们才认为这两个类型是兼容的。
对于protected
成员也使用这个规则。
下面来看一个例子,更好地说明了这一点:
class Animal { private name: string; constructor(theName: string) { this.name = theName; } } class Rhino extends Animal { constructor() { super("Rhino"); } } class Employee { private name: string; constructor(theName: string) { this.name = theName; } } let animal = new Animal("Goat"); let rhino = new Rhino(); let employee = new Employee("Bob"); animal = rhino; animal = employee; // 错误: Animal 与 Employee 不兼容.
这个例子中有Animal
和Rhino
两个类,Rhino
是Animal
类的子类。
还有一个Employee
类,其类型看上去与Animal
是相同的。
我们创建了几个这些类的实例,并相互赋值来看看会发生什么。
-
因为
Animal
和Rhino
共享了来自Animal
里的私有成员定义private name: string
, 因此它们是兼容的。 -
然而
Employee
却不是这样。当把Employee
赋值给Animal
的时候,得到一个错误, 说它们的类型不兼容。 尽管Employee
里也有一个私有成员name
, 但它明显不是Animal
里面定义的那个。
protected
protected
修饰符与private
修饰符的行为很相似,但有一点不同,
protected
成员在派生类中仍然可以访问。例如:
class Person { protected name: string; constructor(name: string) { this.name = name; } } class Employee extends Person { private department: string; constructor(name: string, department: string) { super(name) this.department = department; } public getElevatorPitch() { return `Hello, my name is ${this.name} and I work in ${this.department}.`; } } let howard = new Employee("Howard", "Sales"); console.log(howard.getElevatorPitch()); console.log(howard.name); // 错误
注意,我们不能在Person
类外使用name
,
但是我们仍然可以通过Employee
类的实例方法访问,
因为Employee
是由Person
派生而来的。
构造函数也可以被标记成protected
。
这意味着这个类不能在包含它的类外被实例化,但是能被继承。比如,
class Person { protected name: string; protected constructor(theName: string) { this.name = theName; } } // Employee 能够继承 Person class Employee extends Person { private department: string; constructor(name: string, department: string) { super(name); this.department = department; } public getElevatorPitch() { return `Hello, my name is ${this.name} and I work in ${this.department}.`; } } let howard = new Employee("Howard", "Sales"); let john = new Person("John"); // 错误: 'Person' 的构造函数是被保护的.
readonly修饰符
readonly
关键字将属性设置为只读的。 只读属性必须在声明时或构造函数里被初始化。
class Octopus { readonly name: string; readonly numberOfLegs: number = 8; constructor (theName: string) { this.name = theName; } } let dad = new Octopus("Man with the 8 strong legs"); dad.name = "Man with the 3-piece suit"; // 错误! name 是只读的.
参数属性
在上面的例子中,不得不定义一个受保护的成员name
和一个构造函数参数
theName
在类里,并且立刻给name
和theName
赋值。
参数属性可以方便地让我们在一个地方定义并初始化一个成员。使用了参数属性:
class Animal { constructor(private name: string) { } move(distanceInMeters: number) { console.log(`${this.name} moved ${distanceInMeters}m.`); } }
仅在构造函数里使用private name: string
参数来创建和初始化name
成员。
我们把声明和赋值合并至一处。
使用private限定一个参数属性会声明并初始化一个私有成员; 对于public和protected来说也是一样。
存取器
TypeScript支持通过getters/setters来截取对对象成员的访问。 它能帮助你有效的控制对对象成员的访问。
let passcode = "secret passcode"; class Employee { private _fullName: string; get fullName(): string { return this._fullName; } set fullName(newName: string) { if (passcode && passcode == "secret passcode") { this._fullName = newName; } else { console.log("Error: Unauthorized update of employee!"); } } } let employee = new Employee(); employee.fullName = "Bob Smith"; if (employee.fullName) { alert(employee.fullName); }
对于存取器有下面几点需要注意的:
- 首先,存取器要求你将编译器设置为输出ECMAScript 5或更高。 不支持降级到ECMAScript 3。
-
其次,只带有get不带有set的存取器自动被推断为readonly。
这在从代码生成
.d.ts
文件时是有帮助的, 因为利用这个属性的用户会看到不允许够改变它的值。
静态属性
到目前为止,我们只讨论了类的实例成员, 那些仅当类被实例化的时候才会被初始化的属性。
我们也可以创建类的静态成员,这些属性存在于类本身上面而不是类的实例上。
在这个例子里,使用static
定义origin
,因为它是所有网格都会用到的属性。
每个实例想要访问这个属性的时候,都要在origin
前面加上类名。
如同在实例属性上使用this.
前缀来访问属性一样,
这里我们使用Grid.
来访问静态属性。
class Grid { static origin = {x: 0, y: 0}; calculateDistanceFromOrigin(point: {x: number; y: number;}) { let xDist = (point.x - Grid.origin.x); let yDist = (point.y - Grid.origin.y); return Math.sqrt(xDist * xDist + yDist * yDist) / this.scale; } constructor (public scale: number) { } } let grid1 = new Grid(1.0); // 1x scale let grid2 = new Grid(5.0); // 5x scale console.log(grid1.calculateDistanceFromOrigin({x: 10, y: 10})); console.log(grid2.calculateDistanceFromOrigin({x: 10, y: 10}));
抽象类
抽象类做为其它派生类的基类使用。 它们一般不会直接被实例化。 不同于接口,
抽象类可以包含成员的实现细节。
abstract
关键字是用于定义抽象类和在抽象类内部定义抽象方法。
abstract class Animal { abstract makeSound(): void; move(): void { console.log('roaming the earch...'); } }
抽象类中的抽象方法不包含具体实现并且必须在派生类中实现。
抽象方法的语法与接口方法相似。 两者都是定义方法签名但不包含方法体。
然而,抽象方法必须包含abstract
关键字并且可以包含访问修饰符。
abstract class Department { constructor(public name: string) { } printName(): void { console.log('Department name: ' + this.name); } abstract printMeeting(): void; // 必须在派生类中实现 } class AccountingDepartment extends Department { constructor() { // 在派生类的构造函数中必须调用 super() super('Accounting and Auditing'); } printMeeting(): void { console.log('The Accounting Department meets each Monday at 10am.'); } generateReports(): void { console.log('Generating accounting reports...'); } } let department: Department; // 允许创建一个对抽象类型的引用 department = new Department(); // 错误: 不能创建一个抽象类的实例 department = new AccountingDepartment(); // 允许对一个抽象子类进行实例化和赋值 department.printName(); department.printMeeting(); department.generateReports(); // 错误: 方法在声明的抽象类中不存在
构造函数
当你在TypeScript里声明了一个类的时候,实际上同时声明了很多东西。 首先就是类的实例的类型。
class Greeter { greeting: string; constructor(message: string) { this.greeting = message; } greet() { return "Hello, " + this.greeting; } } let greeter: Greeter; greeter = new Greeter("world"); console.log(greeter.greet());
我们也创建了一个叫做构造函数的值。 这个函数会在我们使用new创建类实例的时候被调用。 下面我们来看看,上面的代码被编译成JavaScript后是什么样子的:
let Greeter = (function () { function Greeter(message) { this.greeting = message; } Greeter.prototype.greet = function () { return "Hello, " + this.greeting; }; return Greeter; })(); let greeter; greeter = new Greeter("world"); console.log(greeter.greet());
上面的代码里,let Greeter
将被赋值为构造函数。
当我们调用new
并执行了这个函数后,便会得到一个类的实例。
这个构造函数也包含了类的所有静态属性。 换个角度说,
我们可以认为类具有实例部分与静态部分这两个部分。
让我们稍微改写一下这个例子,看看它们之前的区别:
class Greeter { static standardGreeting = "Hello, there"; greeting: string; greet() { if (this.greeting) { return "Hello, " + this.greeting; } else { return Greeter.standardGreeting; } } } let greeter1: Greeter; greeter1 = new Greeter(); console.log(greeter1.greet()); let greeterMaker: typeof Greeter = Greeter; greeterMaker.standardGreeting = "Hey there!"; let greeter2: Greeter = new greeterMaker(); console.log(greeter2.greet());
这个例子里,greeter1
与之前看到的一样。
-
我们实例化
Greeter
类, 并使用这个对象。 与我们之前看到的一样。 -
再之后,我们直接使用类。 我们创建了一个叫做
greeterMaker
的变量。 这个变量保存了这个类或者说保存了类构造函数。 -
然后我们使用
typeof Greeter
,意思是取Greeter
类的类型,而不是实例的类型。 或者更确切的说是Greeter
标识符的类型,也就是构造函数的类型。 这个类型包含了类的所有静态成员和构造函数。 -
之后,就和前面一样,我们在
greeterMaker
上使用new
,创建Greeter
的实例。
把类当做接口使用
如上一节里所讲的,类定义会创建两个东西:类的实例类型和一个构造函数。 因为类可以创建出类型,所以你能够在允许使用接口的地方使用类。
class Point { x: number; y: number; } interface Point3d extends Point { z: number; } let point3d: Point3d = {x: 1, y: 2, z: 3};