状态: 初稿

介绍

传统 JavaScript 开发者用函数和基于原型的继承构建可重用组件, 但有些程序员更喜欢面向对象方法 — 类多数功能由继承而来, 对象又参照类实例化.
ECMAScript 2015 (也称 ECMAScript 6), 开始允许开发者按照面向对象的, 基于类的编程方法构建应用程序.
TypeScript 用户无需等待新版本 JavaScript, 现在就可以使用这些技术, 编译器生成的 JavaScript 可在各主流浏览器和平台运行.

我们以一个基于类的例子开始:

1
2
3
4
5
6
7
8
9
10
11
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}

let greeter = new Greeter("world");

假如你有 Java 或 C# 经验, 这语法对你并不陌生.
我们创建了一个新类 Greeter. 这个新类有三个成员, 分别是: 一个叫 greeting 的属性, 一个构造器, 一个成员函数(方法) greet.

你也注意到在类定义中, 我们引用一些标识符要加 this. 前缀.
这表示一个对类成员的访问.

在最后一行, new 关键字创建了一个类的对象(实例).
new 语句首先创建一个形体跟 Greeter 一致的对象, 然后调用类构造器方法初始化该对象.

继承

在 TypeScript 中, 我们可以运用许多常见面向对象模式.
继承是基于类编程最重要的模式之一, 其基本思想是: 创建新类, 继承旧类, 完善功能.

我们看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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();

这例子展现了继承最基本的能力: 子类继承父类的属性和方法.
这里, Dog 是一个用 extends 关键字继承基类 Animal派生类.
派生类也叫子类, 基类也叫父类

因为 Dog 完善了 Animal 的功能, 我们现在可以创建一个既能 move, 又能 bark(吠叫)的 Dog 实例.

再看个稍复杂点的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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().
第二, 在构造函数中, 要先调用 super(), 再访问其他类成员.
以上规则很重要, 是 TypeScript 语法检查的一部分.

最后, 这个例子还展示了如何覆写父类方法, 以创建子类特有版本.
这里不论是 Snake, 还是 Horse, 都根据自身特点覆写了父类 Animalmove 方法.
注意虽然 tom 的类型是 Animal, 由于它的值是一个 Horse, 调用 tom.move(34) 调用的是 Hosre 的覆写方法.

例子输出如下:

1
2
3
4
Slithering...
Sammy the Python moved 5m.
Galloping...
Tommy the Palomino moved 34m.

访问修饰符

公有是默认状态

纵观所有例子, 我们在程序生命期间都不受限制地访问一个对象的任何成员.
如果熟悉其他语言中的类, 就知道我们需要 public 关键字; 比如说 C#, C# 要求你用 public 修饰想对外部可见的每个成员.
而在 TypeScript 中, 公有(public)是成员默认状态.

你还是可以用 public 显式修饰一个成员.
Animal 类就像这样:

1
2
3
4
5
6
7
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 的, 你将不能在所属类外部访问它. 例如:

1
2
3
4
5
6
class Animal {
private name: string;
constructor(theName: string) { this.name = theName; }
}

new Animal("Cat").name; // Error: 'name' is private;

TypeScript 是一个结构化类型系统.
它在比较两个不同类型的时候, 不管两者出身, 只要它们所有成员是相容的, 它们自身就是相容的.

然而, 当参与比较的类型有 protectedprivate 成员时, 标准稍有不同.
要兼容一个含有 protected, 或 private 成员的类, 要求另一个类也有对应 protectedprivate 成员, 而且两个 private 成员同源(即来自同一个父类).

来看实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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; // Error: 'Animal' and 'Employee' are not compatible

上例, 我们有一个 Animal 和一个 Rhino(犀牛), RhinoAnimal 的子类.
我们还有一个新类 Employee, 单论形体, EmployeeAnimal 是相容的.
现在, 创建这些类的一些变量, 然后试试互相赋值会发生什么.
由于 AnimalRhino 共享在 Animal 中定义的 private name: string, 所以它们的 private 成员是同源的. 而对 Employee, 则并非如此.
试图把 Employee 赋值给 Animal 时出错, 说两个类型不相容.
虽然 Employee 也有名为 nameprivate 成员, 但它不来自 Animal.

理解 protected

protectedprivate 宽松, 你可以在所属类和派生类访问 protected 成员. 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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); // error

可以看到的确不能在外部访问 Person 类的 name 成员, 而 Employee 派生自 Person, 在 Employee 的成员方法访问就没问题,

你可以用 protected 修饰构造函数.
类的 protected 构造函数表示不能在类外创建类的实例, 但可以继承. 举例,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Person {
protected name: string;
protected constructor(theName: string) { this.name = theName; }
}

// Employee can extend 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"); // Error: The 'Person' constructor is protected

readonly 关键字

你可以用 readonly 关键字定义”只读”属性.
只读属性必须在定义时, 或在构造函数内获得初值.

1
2
3
4
5
6
7
8
9
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"; // error! name is readonly.

参数属性

上一个例子, 我们为类 Octopus (章鱼) 定义了一个只读成员 name, 然后给构造函数添加参数 theName. 在构造函数内, 把 theName 赋值给 this.name, 这是为了在构造函数结束后继续使用 theName.
构造属性语法让你一次性创建, 初始化一个属性.
以下是上例简化后的 Octopus 类定义:

1
2
3
4
5
class Octopus {
readonly numberOfLegs: number = 8;
constructor(readonly name: string) {
}
}

我们丢掉了 theName, 构造函数参数 readonly name: string 同时定义并用实参初始化成员 name.
成员定义和初始化由此统一为一体.

访问修饰符readonly 或两者组合修饰构造函数的一个参数来定义参数属性.
一个 private 参数属性对应类中一个 private 成员, 一个 protected 参数属性对应类中一个 protected 成员, 以此类推.

存取方法

TypeScript 存/取方法可以拦截对类成员的存/取操作.
借助该特性, 你可以自定义存/取方法精细控制如何访问类成员.

为获得直观的理解, 我们来改造一个类, 为它添加存取方法.
先从没有存/取方法的类开始:

1
2
3
4
5
6
7
8
9
class Employee {
fullName: string;
}

let employee = new Employee();
employee.fullName = "Bob Smith";
if (employee.fullName) {
console.log(employee.fullName);
}

随意存取 fullName 的确很方便, 但为了数据的有效性, 你最好为存操作添加一些限制.

现在来改造 Employee 类, 首先 fullName 加入存方法, 确保它的长度符合后端数据库对应字段的长度要求. 如果过长就抛出异常通知客户程序.

为维持现有功能不变, 我们还添加了一个取方法, 未修改地读取出 fullName.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const fullNameMaxLength = 10;

class Employee {
private _fullName: string;

get fullName(): string {
return this._fullName;
}

set fullName(newName: string) {
if (newName && newName.length > fullNameMaxLength) {
throw new Error("fullName has a max length of " + fullNameMaxLength);
}

this._fullName = newName;
}
}

let employee = new Employee();
employee.fullName = "Bob Smith";
if (employee.fullName) {
console.log(employee.fullName);
}

现在把一个长度大于 10 的字符串赋值给 fullName, 观察是否有异常抛出, 以验证我们的存方法在发挥作用.

存取方法若干说明:

第一, 为使用存取方法, 编译器输出不得低于 ECMAScript 5.
降级到 ECMAScript 3 是不受支持的.
第二, 缺失存方法的属性等同于只读属性.
在导出 .d.ts 时尤其有用, 你的用户只能 “看到” 该属性, 而不能修改它.

静态属性

到目前为止, 我们只探讨了类的实例成员, 实例方法依附类实例而存在.
同样地, 你可以为一个类创建静态成员, 静态成员无需创建类实例就能使用.
下例, 我们用 static 修饰变量 origin, 所有 grid (Grid 的实例) 共享这个变量.
如同 this. 代表实例成员访问.
实例方法必须使用类名前缀访问静态成员, 这里我们前置 Grid..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 关键字用来声明抽象类, 或抽象类内部的抽象方法.

1
2
3
4
5
6
abstract class Animal {
abstract makeSound(): void;
move(): void {
console.log("roaming the earth...");
}
}

抽象类内部以 abstract 修饰的方法叫做抽象方法, 抽象类不实现抽象方法, 它的派生类要实现这些抽象方法.
抽象方法的定义语法与接口函数基本一致, 只声明函数的签名, 不提供实现(函数体).
然后用 abstract 关键字表示这是一个抽象方法, 最后添加可选的访问修饰符.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
abstract class Department {

constructor(public name: string) {
}

printName(): void {
console.log("Department name: " + this.name);
}

abstract printMeeting(): void; // must be implemented in derived classes
}

class AccountingDepartment extends Department {

constructor() {
super("Accounting and Auditing"); // constructors in derived classes must call super()
}

printMeeting(): void {
console.log("The Accounting Department meets each Monday at 10am.");
}

generateReports(): void {
console.log("Generating accounting reports...");
}
}

let department: Department; // ok to create a reference to an abstract type
department = new Department(); // error: cannot create an instance of an abstract class
department = new AccountingDepartment(); // ok to create and assign a non-abstract subclass
department.printName();
department.printMeeting();
department.generateReports(); // error: method doesn't exist on declared abstract type

高级话题

构造器函数

你在 TypeScript 创建一个类的同时实际上创建了许多定义.
其一, 类实例的类型.

1
2
3
4
5
6
7
8
9
10
11
12
13
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());

当我们写下 let greeter: Greeter, Greeter 将作为 Greeter 类的实例类型.
对其他面向对象程序员来说, 这几乎是第二性质.

其二, 我们还创建了一个为 new 操作符调用的构造器函数.
这个函数作为值赋给一个变量.
我们可以在生成的 JavaScript 中看到它:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 把构造器函数赋值给 Greeter.
当我们执行 new 操作调用构造器函数, 我们得到该类的一个实例.
构造器函数也包含所有类静态成员.
另一种看待类的方式是区分”实例面”和”静态面”.

对上例稍作修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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, typeof Greeter 即 “把类 Greeter 的类型给我”, 而不是类实例的类型.
更准确地说, “给我标识符 Greeter 的类型”, 即构造器函数的类型.
它包括 Greeter 所有静态成员, 还有能创建 Greeter 类实例的构造器.
我们最后演示把 new 运算符作用在 greeterMaker 身上创建一个 Greeter 实例, 调用它的 greet 方法.

类作为接口

如上节所述, 类定义创建两个事物: 一个类实例类型, 一个构造器函数.
因为类创建类型, 你可以在能应用接口的地方应用类.

1
2
3
4
5
6
7
8
9
10
class Point {
x: number;
y: number;
}

interface Point3d extends Point {
z: number;
}

let point3d: Point3d = {x: 1, y: 2, z: 3};
如果这篇文章对您有用,可以考虑打赏:)