1. 對象的類型——接口
在 TypeScript 中,我們使用接口(Interfaces)來定義對象的類型。
1.1 什么是接口
在面向對象語言中,接口(Interfaces)是一個很重要的概念,它是對行為的抽象,而具體如何行動需要由類(classes)去實現(implement)。
TypeScript 中的接口是一個非常靈活的概念,除了可用於對類的一部分行為進行抽象以外,也常用於對「對象的形狀(Shape)」進行描述。
1.2 簡單的例子
interface Person {
name: string;
age: number;
}
let tom: Person = {
name: 'Tom',
age: 25
};
上面的例子中,我們定義了一個接口 Person
,接着定義了一個變量 tom
,它的類型是 Person
。這樣,我們就約束了 tom
的形狀必須和接口 Person
一致。
接口一般首字母大寫。有的編程語言中會建議接口的名稱加上 I
前綴。
定義的變量比接口少了一些屬性是不允許的:
interface Person {
name: string;
age: number;
}
let tom: Person = {
name: 'Tom'
};
// index.ts(6,5): error TS2322: Type '{ name: string; }' is not assignable to type 'Person'.
// Property 'age' is missing in type '{ name: string; }'.
多一些屬性也是不允許的:
interface Person {
name: string;
age: number;
}
let tom: Person = {
name: 'Tom',
age: 25,
gender: 'male'
};
// index.ts(9,5): error TS2322: Type '{ name: string; age: number; gender: string; }' is not assignable to type 'Person'.
// Object literal may only specify known properties, and 'gender' does not exist in type 'Person'.
可見,賦值的時候,變量的形狀必須和接口的形狀保持一致。
1.3 可選屬性
有時我們希望不要完全匹配一個形狀,那么可以用可選屬性:
interface Person {
name: string;
age?: number;
}
let tom: Person = {
name: 'Tom'
};
interface Person {
name: string;
age?: number;
}
let tom: Person = {
name: 'Tom',
age: 25
};
可選屬性的含義是該屬性可以不存在。
這時仍然不允許添加未定義的屬性:
interface Person {
name: string;
age?: number;
}
let tom: Person = {
name: 'Tom',
age: 25,
gender: 'male'
};
// examples/playground/index.ts(9,5): error TS2322: Type '{ name: string; age: number; gender: string; }' is not assignable to type 'Person'.
// Object literal may only specify known properties, and 'gender' does not exist in type 'Person'.
1.4 任意屬性
有時候我們希望一個接口允許有任意的屬性,可以使用如下方式:
interface Person {
name: string;
age?: number;
[propName: string]: any;
}
let tom: Person = {
name: 'Tom',
gender: 'male'
};
使用 [propName: string]
定義了任意屬性取 string
類型的值。
需要注意的是,一旦定義了任意屬性,那么確定屬性和可選屬性的類型都必須是它的類型的子集:
interface Person {
name: string;
age?: number;
[propName: string]: string;
}
let tom: Person = {
name: 'Tom',
age: 25,
gender: 'male'
};
// index.ts(3,5): error TS2411: Property 'age' of type 'number' is not assignable to string index type 'string'.
// index.ts(7,5): error TS2322: Type '{ [x: string]: string | number; name: string; age: number; gender: string; }' is not assignable to type 'Person'.
// Index signatures are incompatible.
// Type 'string | number' is not assignable to type 'string'.
// Type 'number' is not assignable to type 'string'.
上例中,任意屬性的值允許是 string
,但是可選屬性 age
的值卻是 number
,number
不是 string
的子屬性,所以報錯了。
另外,在報錯信息中可以看出,此時 { name: 'Tom', age: 25, gender: 'male' }
的類型被推斷成了 { [x: string]: string | number; name: string; age: number; gender: string; }
,這是聯合類型和接口的結合。
一個接口中只能定義一個任意屬性。如果接口中有多個類型的屬性,則可以在任意屬性中使用聯合類型:
interface Person {
name: string;
age?: number;
[propName: string]: string | number;
}
let tom: Person = {
name: 'Tom',
age: 25,
gender: 'male'
};
1.5 只讀屬性
有時候我們希望對象中的一些字段只能在創建的時候被賦值,那么可以用 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;
// index.ts(14,5): error TS2540: Cannot assign to 'id' because it is a constant or a read-only property.
上例中,使用 readonly
定義的屬性 id
初始化后,又被賦值了,所以報錯了。
注意,只讀的約束存在於第一次給對象賦值的時候,而不是第一次給只讀屬性賦值的時候:
interface Person {
readonly id: number;
name: string;
age?: number;
[propName: string]: any;
}
let tom: Person = {
name: 'Tom',
gender: 'male'
};
tom.id = 89757;
// index.ts(8,5): error TS2322: Type '{ name: string; gender: string; }' is not assignable to type 'Person'.
// Property 'id' is missing in type '{ name: string; gender: string; }'.
// index.ts(13,5): error TS2540: Cannot assign to 'id' because it is a constant or a read-only property.
上例中,報錯信息有兩處,第一處是在對 tom
進行賦值的時候,沒有給 id
賦值。
第二處是在給 tom.id
賦值的時候,由於它是只讀屬性,所以報錯了。
2. 類
傳統方法中,JavaScript 通過構造函數實現類的概念,通過原型鏈實現繼承。而在 ES6 中,我們終於迎來了 class
。
TypeScript 除了實現了所有 ES6 中的類的功能以外,還添加了一些新的用法。
這一節主要介紹類的用法,下一節再介紹如何定義類的類型。
2.1 類的概念
雖然 JavaScript 中有類的概念,但是可能大多數 JavaScript 程序員並不是非常熟悉類,這里對類相關的概念做一個簡單的介紹。
- 類(Class):定義了一件事物的抽象特點,包含它的屬性和方法
- 對象(Object):類的實例,通過
new
生成 - 面向對象(OOP)的三大特性:封裝、繼承、多態
- 封裝(Encapsulation):將對數據的操作細節隱藏起來,只暴露對外的接口。外界調用端不需要(也不可能)知道細節,就能通過對外提供的接口來訪問該對象,同時也保證了外界無法任意更改對象內部的數據
- 繼承(Inheritance):子類繼承父類,子類除了擁有父類的所有特性外,還有一些更具體的特性
- 多態(Polymorphism):由繼承而產生了相關的不同的類,對同一個方法可以有不同的響應。比如
Cat
和Dog
都繼承自Animal
,但是分別實現了自己的eat
方法。此時針對某一個實例,我們無需了解它是Cat
還是Dog
,就可以直接調用eat
方法,程序會自動判斷出來應該如何執行eat
- 存取器(getter & setter):用以改變屬性的讀取和賦值行為
- 修飾符(Modifiers):修飾符是一些關鍵字,用於限定成員或類型的性質。比如
public
表示公有屬性或方法 - 抽象類(Abstract Class):抽象類是供其他類繼承的基類,抽象類不允許被實例化。抽象類中的抽象方法必須在子類中被實現
- 接口(Interfaces):不同類之間公有的屬性或方法,可以抽象成一個接口。接口可以被類實現(implements)。一個類只能繼承自另一個類,但是可以實現多個接口
2.2 ES6 中類的用法
下面我們先回顧一下 ES6 中類的用法,更詳細的介紹可以參考 [ECMAScript 6 入門 - Class]。
屬性和方法
使用 class
定義類,使用 constructor
定義構造函數。
通過 new
生成新實例的時候,會自動調用構造函數。
class Animal {
public name;
constructor(name) {
this.name = name;
}
sayHi() {
return `My name is ${this.name}`;
}
}
let a = new Animal('Jack');
console.log(a.sayHi()); // My name is Jack
類的繼承
使用 extends
關鍵字實現繼承,子類中使用 super
關鍵字來調用父類的構造函數和方法。
class Cat extends Animal {
constructor(name) {
super(name); // 調用父類的 constructor(name)
console.log(this.name);
}
sayHi() {
return 'Meow, ' + super.sayHi(); // 調用父類的 sayHi()
}
}
let c = new Cat('Tom'); // Tom
console.log(c.sayHi()); // Meow, My name is Tom
存取器
使用 getter 和 setter 可以改變屬性的賦值和讀取行為:
class Animal {
constructor(name) {
this.name = name;
}
get name() {
return 'Jack';
}
set name(value) {
console.log('setter: ' + value);
}
}
let a = new Animal('Kitty'); // setter: Kitty
a.name = 'Tom'; // setter: Tom
console.log(a.name); // Jack
靜態方法
使用 static
修飾符修飾的方法稱為靜態方法,它們不需要實例化,而是直接通過類來調用:
class Animal {
static isAnimal(a) {
return a instanceof Animal;
}
}
let a = new Animal('Jack');
Animal.isAnimal(a); // true
a.isAnimal(a); // TypeError: a.isAnimal is not a function
2.3 ES7 中類的用法
ES7 中有一些關於類的提案,TypeScript 也實現了它們,這里做一個簡單的介紹。
實例屬性
ES6 中實例的屬性只能通過構造函數中的 this.xxx
來定義,ES7 提案中可以直接在類里面定義:
class Animal {
name = 'Jack';
constructor() {
// ...
}
}
let a = new Animal();
console.log(a.name); // Jack
靜態屬性
ES7 提案中,可以使用 static
定義一個靜態屬性:
class Animal {
static num = 42;
constructor() {
// ...
}
}
console.log(Animal.num); // 42
2.4 TypeScript 中類的用法
public private 和 protected
TypeScript 可以使用三種訪問修飾符(Access Modifiers),分別是 public
、private
和 protected
。
public
修飾的屬性或方法是公有的,可以在任何地方被訪問到,默認所有的屬性和方法都是public
的private
修飾的屬性或方法是私有的,不能在聲明它的類的外部訪問protected
修飾的屬性或方法是受保護的,它和private
類似,區別是它在子類中也是允許被訪問的
下面舉一些例子:
class Animal {
public name;
public constructor(name) {
this.name = name;
}
}
let a = new Animal('Jack');
console.log(a.name); // Jack
a.name = 'Tom';
console.log(a.name); // Tom
上面的例子中,name
被設置為了 public
,所以直接訪問實例的 name
屬性是允許的。
很多時候,我們希望有的屬性是無法直接存取的,這時候就可以用 private
了:
class Animal {
private name;
public constructor(name) {
this.name = name;
}
}
let a = new Animal('Jack');
console.log(a.name); // Jack
a.name = 'Tom';
// index.ts(9,13): error TS2341: Property 'name' is private and only accessible within class 'Animal'.
// index.ts(10,1): error TS2341: Property 'name' is private and only accessible within class 'Animal'.
需要注意的是,TypeScript 編譯之后的代碼中,並沒有限制 private
屬性在外部的可訪問性。
上面的例子編譯后的代碼是:
var Animal = (function () {
function Animal(name) {
this.name = name;
}
return Animal;
})();
var a = new Animal('Jack');
console.log(a.name);
a.name = 'Tom';
使用 private
修飾的屬性或方法,在子類中也是不允許訪問的:
class Animal {
private name;
public constructor(name) {
this.name = name;
}
}
class Cat extends Animal {
constructor(name) {
super(name);
console.log(this.name);
}
}
// index.ts(11,17): error TS2341: Property 'name' is private and only accessible within class 'Animal'.
而如果是用 protected
修飾,則允許在子類中訪問:
class Animal {
protected name;
public constructor(name) {
this.name = name;
}
}
class Cat extends Animal {
constructor(name) {
super(name);
console.log(this.name);
}
}
當構造函數修飾為 private
時,該類不允許被繼承或者實例化:
class Animal {
public name;
private constructor(name) {
this.name = name;
}
}
class Cat extends Animal {
constructor(name) {
super(name);
}
}
let a = new Animal('Jack');
// index.ts(7,19): TS2675: Cannot extend a class 'Animal'. Class constructor is marked as private.
// index.ts(13,9): TS2673: Constructor of class 'Animal' is private and only accessible within the class declaration.
當構造函數修飾為 protected
時,該類只允許被繼承:
class Animal {
public name;
protected constructor(name) {
this.name = name;
}
}
class Cat extends Animal {
constructor(name) {
super(name);
}
}
let a = new Animal('Jack');
// index.ts(13,9): TS2674: Constructor of class 'Animal' is protected and only accessible within the class declaration.
參數屬性
修飾符和readonly
還可以使用在構造函數參數中,等同於類中定義該屬性同時給該屬性賦值,使代碼更簡潔。
class Animal {
// public name: string;
public constructor(public name) {
// this.name = name;
}
}
readonly
只讀屬性關鍵字,只允許出現在屬性聲明或索引簽名或構造函數中。
class Animal {
readonly name;
public constructor(name) {
this.name = name;
}
}
let a = new Animal('Jack');
console.log(a.name); // Jack
a.name = 'Tom';
// index.ts(10,3): TS2540: Cannot assign to 'name' because it is a read-only property.
注意如果 readonly
和其他訪問修飾符同時存在的話,需要寫在其后面。
class Animal {
// public readonly name;
public constructor(public readonly name) {
// this.name = name;
}
}
抽象類
abstract
用於定義抽象類和其中的抽象方法。
什么是抽象類?
首先,抽象類是不允許被實例化的:
abstract class Animal {
public name;
public constructor(name) {
this.name = name;
}
public abstract sayHi();
}
let a = new Animal('Jack');
// index.ts(9,11): error TS2511: Cannot create an instance of the abstract class 'Animal'.
上面的例子中,我們定義了一個抽象類 Animal
,並且定義了一個抽象方法 sayHi
。在實例化抽象類的時候報錯了。
其次,抽象類中的抽象方法必須被子類實現:
abstract class Animal {
public name;
public constructor(name) {
this.name = name;
}
public abstract sayHi();
}
class Cat extends Animal {
public eat() {
console.log(`${this.name} is eating.`);
}
}
let cat = new Cat('Tom');
// index.ts(9,7): error TS2515: Non-abstract class 'Cat' does not implement inherited abstract member 'sayHi' from class 'Animal'.
上面的例子中,我們定義了一個類 Cat
繼承了抽象類 Animal
,但是沒有實現抽象方法 sayHi
,所以編譯報錯了。
下面是一個正確使用抽象類的例子:
abstract class Animal {
public name;
public constructor(name) {
this.name = name;
}
public abstract sayHi();
}
class Cat extends Animal {
public sayHi() {
console.log(`Meow, My name is ${this.name}`);
}
}
let cat = new Cat('Tom');
上面的例子中,我們實現了抽象方法 sayHi
,編譯通過了。
需要注意的是,即使是抽象方法,TypeScript 的編譯結果中,仍然會存在這個類,上面的代碼的編譯結果是:
var __extends =
(this && this.__extends) ||
function (d, b) {
for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
function __() {
this.constructor = d;
}
d.prototype = b === null ? Object.create(b) : ((__.prototype = b.prototype), new __());
};
var Animal = (function () {
function Animal(name) {
this.name = name;
}
return Animal;
})();
var Cat = (function (_super) {
__extends(Cat, _super);
function Cat() {
_super.apply(this, arguments);
}
Cat.prototype.sayHi = function () {
console.log('Meow, My name is ' + this.name);
};
return Cat;
})(Animal);
var cat = new Cat('Tom');
2.5 類的類型
給類加上 TypeScript 的類型很簡單,與接口類似:
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
sayHi(): string {
return `My name is ${this.name}`;
}
}
let a: Animal = new Animal('Jack');
console.log(a.sayHi()); // My name is Jack
3. 類與接口
之前學習過,接口(Interfaces)可以用於對「對象的形狀(Shape)」進行描述。
這一章主要介紹接口的另一個用途,對類的一部分行為進行抽象。
3.1 類實現接口
實現(implements)是面向對象中的一個重要概念。一般來講,一個類只能繼承自另一個類,有時候不同類之間可以有一些共有的特性,這時候就可以把特性提取成接口(interfaces),用 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
接口,既能報警,也能開關車燈。
3.2 接口繼承接口
接口與接口之間可以是繼承關系:
interface Alarm {
alert(): void;
}
interface LightableAlarm extends Alarm {
lightOn(): void;
lightOff(): void;
}
這很好理解,LightableAlarm
繼承了 Alarm
,除了擁有 alert
方法之外,還擁有兩個新方法 lightOn
和 lightOff
。
3.3 接口繼承類
常見的面向對象語言中,接口是不能繼承類的,但是在 TypeScript 中卻是可以的:
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};
為什么 TypeScript 會支持接口繼承類呢?
實際上,當我們在聲明 class Point
時,除了會創建一個名為 Point
的類之外,同時也創建了一個名為 Point
的類型(實例的類型)。
所以我們既可以將 Point
當做一個類來用(使用 new Point
創建它的實例):
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
const p = new Point(1, 2);
也可以將 Point
當做一個類型來用(使用 : Point
表示參數的類型):
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
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;
}
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
是等價的。
同樣的,在接口繼承類的時候,也只會繼承它的實例屬性和實例方法。