类
简介
传统的 JavaScript 使用函数和面向原型的继承方式构建可重用组件,但这种方式可能让已经适应了面向对象编程的程序员感到有些别扭。面向对象编程中的类扩展其他类的功能,对象实例则通过类创建。从 ES2015 开始,也就是ES6,JavaScript 程序员也可以使用基于类的面向对象方式构建应用程序。在 TypeScript 中,我们现在已经可以让开发者使用这种技术,然后将代码编译成在各种浏览器和平台上都适用的 JavaScript 代码,不需要等待 JavaScript 的后续版本。
类
让我们先看一下这段简单的代码。
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}
let greeter = new Greeter("world");
如果你使用过 C# 或者 Java,那你一定对这种语法比较熟悉。我们声明了一个新的类 Greeter,类有三个成员:greeting 属性,构造方法和 greet 方法。
在类的内部,当我们使用某个成员的时候在成员前面加 this.,这表明在使用类的成员方式进行访问。
在最后一行,我们使用 new 创建 Greeter 类的实例,创建时会调用构造方法初始化实例。
继承
在 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();
这个例子展示了继承的基本特性:类从基类继承属性和方法,Dog 是使用 extends 关键字从基类 Animal 扩展而来的派生类,派生类通常也叫做子类,基类通常又叫做父类。
因为 Dog 继承了 Animal 的功能,我们就可以创建一个既可以 bark() 又可以 move() 的 Dog的实例。
让我们看一个更复杂点的例子。
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);
这个例子演示了之前不曾提到的特性,我们使用 extends 关键字创建了 Animal 的两个子类:Horse 和 Snake。
和以前的例子不同的是,每个派生类在构造方法中都通过 super() 调用了父类的构造方法。另外,当我们在构造方法体内部访问 this 属性之前都调用了 super(),这是 TypeScript 强制要求的一个规则。
这个例子也展示了如何使用子类方法覆盖父类的方法,Snake 和 Horse 都创建了 move 方法覆盖了 Animal 的 move 方法,同时为方法加入了更多的功能。注意虽然 tom 被声明为 Animal,但是它的 value 是 Horse,调用 tom.move(34) 实际调用的是 Horse 中重写的方法。
Slithering...
Sammy the Python moved 5m.
Galloping...
Tommy the Palomino moved 34m.
Public、 private 和 protected修饰符
Public 是默认值
在例子中,我们可以随意地访问我们在程序中声明的成员,如果你熟悉其他语言中的类,你可能注意到了我们上面的例子中并没有对成员使用 public 修饰符。例如,C# 要求成员必须被显式地声明为 public,在 TypeScript 中,每个成员默认就是 public。
你也可以显式地声明一个成员为 public,我们可以用下面的方式来重写之前的 Animal。
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; // Error: 'name' is private;
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; // Error: 'Animal' and 'Employee' are not compatible
例子中有 Animal 和 Rhino 两个类,Rhino 是 Animal 的子类。例子中还有一个看上去结构和 Animal 一样的 Employee 类。我们创建这些类的实例,然后用这些实例彼此进行赋值,看看会发生什么。因为 Animal 和 Rhino 共有 private 声明,所以它们是兼容的。但是 Employee 并不是这样,当我们试图将 Employee 的实例赋值给由 Animal 声明的实例时,会返回类型不兼容的错误。虽然 Employee 也有一个 private 修饰的属性 name,但它不是我们在 Animal 中声明的那个 name。
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); // error
注意在 Person 类的外部是无法访问 name 属性的,我们可以在 Employee 的实例方法中使用 name 属性,因为 Employee 是 Person 的子类。
构造方法也可以使用 protected 修饰符,这就是说不能在类的外部实例化类的实例,但是在子类中可以。例如。
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 关键字,只读属性必须在声明时或者构造方法中初始化。
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 的值。参数属性可以做到这一点,下面的例子使用参数属性对 Octopus 类进行进一步的改进。
class Octopus {
readonly numberOfLegs: number = 8;
constructor(readonly name: string) {
}
}
现在我们移除了 theName 参数,同时在构造方法中使用 readonly name: string 方式的参数来初始化 name 成员。我们将声明和赋值合并到一起。
声明参数属性需要在构造方法上使用访问修饰符或者 readonly,或者二者一起使用。对参数属性使用 private 可以声明和初始化 private 成员,其他修饰符同理。
成员访问方法
TypeScript 支持对成员使用 getters/setters 方法,这使你可以更好地控制如何在每个对象上访问类的成员。
让我们看看使用 get 和 set 的一个简单例子。首先我们看看没有成员访问方法之前的代码。
class Employee {
fullName: string;
}
let employee = new Employee();
employee.fullName = "Bob Smith";
if (employee.fullName) {
console.log(employee.fullName);
}
有时允许使用者随意地设置 fullName 的值会很方便,但我们可能也想在对 fullName 设置值时加上一些限制。
在下面的版本中,我们加了 setter 方法,方法中会验证 newName 的长度以保证最大长度与后端数据库的字段长度兼容。如果不兼容,会在客户端提示错误。
为了保留现有功能,我们添加了简单的 getter 来获取未修改的 fullName 的值。
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 个字符的值。
有关成员访问方法的几点注意事项:
第一,要求你必须设置编译器的输出为 ES5 或者更高,ES3 是不支持的。第二,有 get 而没有 set 的成员将自动被识别为 readonly 成员。当通过代码生成 .d.ts 文件时这很有用。因为使用者无法对成员的值进行修改。
静态属性
到现在,我们都仅仅在讨论类的实例化成员,这些都是在类实例化时发生的。我们也可以为类创建静态成员,这些成员对类自身来说是可见的。在这个例子中,我们在 origin 上使用 static 修饰符。origin 是所有 grid 的属性。每个实例都通过类名加属性名访问静态属性,这看上去和前面加 this. 有点类似。在实例中访问可以使用 this,而访问静态属性,需要使用类名。
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 earth...");
}
}
抽象类中的抽象方法不能有任何实现,它必须在子类中实现。抽象方法和接口方法相似,二者都只定义方法的标记而没有任何方法体,但是抽象方法必须要使用 abstract 关键字,也可以包含访问修饰符。
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 中声明类的时候,你实际上是在创建很多个声明,第一个声明就是实例的类型。
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 代码。
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 的符号的类型”,这是构造函数的类型。该类型将包含 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};