类
4346字约14分钟
TypeScript
2023-01-30
类(class)是面向对象编程的基本构件,封装了属性和方法,TypeScript 给予了全面支持。
✨属性的类型
类的属性可以在顶层声明,也可以在构造方法内部声明。
对于顶层声明的属性,可以在声明时同时给出类型。
class Point {
x: number = 0;
y: number = 0;
}
TypeScript 有一个配置项strictPropertyInitialization
,只要打开,就会检查属性是否设置了初值,如果没有就报错。
如果你打开了这个设置,但是某些情况下,不是在声明时赋值或在构造方法里面赋值,为了防止这个设置报错,可以使用非空断言。
class Point {
x!: number;
y!: number;
}
✨readonly 修饰符
class A {
readonly id: string;
constructor() {
this.id = "bar"; // 正确
}
}
✨方法的类型
类的方法就是普通函数,类型声明方式与函数一致。
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
add(point: Point) {
return new Point(this.x + point.x, this.y + point.y);
}
}
类的函数重载:
class Point {
constructor(x: number, y: string);
constructor(s: string);
constructor(xs: number | string, y?: string) {
// ...
}
}
注意
构造方法不能声明返回值类型,否则报错,因为它总是返回实例对象
class B {
constructor(): object {
// 报错
// ...
}
}
✨存取器方法
存取器(accessor)是特殊的类方法,包括取值器(getter)和存值器(setter)两种方法。
它们用于读写某个属性,取值器用来读取属性,存值器用来写入属性。
class C {
_name = "";
get name() {
return this._name;
}
set name(value) {
this._name = value;
}
}
存取器有以下规则:
(1)如果某个属性只有get
方法,没有set
方法,那么该属性自动成为只读属性。
(2)set
方法的参数类型,必须兼容get
方法的返回值类型,否则报错。
(3)get
方法与set
方法的可访问性必须一致,要么都为公开方法,要么都为私有方法。
✨属性索引
类允许定义属性索引。
class MyClass {
[s: string]: boolean | ((s: string) => boolean);
get(s: string) {
return this[s] as boolean;
}
}
注意
注意,由于类的方法是一种特殊属性(属性值为函数的属性),所以属性索引的类型定义也涵盖了方法。如果一个对象同时定义了属性索引和方法,那么前者必须包含后者的类型。
// 错误的
class MyClass {
[s: string]: boolean;
f() {
// 报错
return true;
}
}
// 正确的,(() => boolean) 定义了 fn()
class MyClass {
[s: string]: boolean | (() => boolean);
f() {
return true;
}
}
属性存取器等同于方法,也必须包括在属性索引里面。
✨类的 interface 接口
interface
接口或 type
别名,可以用对象的形式,为 class
指定一组检查条件。然后,类使用 implements
关键字,表示当前类满足这些外部类型条件的限制。
interface Country {
name: string;
capital: string;
getName(): string;
}
// 或者
type Country = {
name: string;
capital: string;
getName(): string;
};
class MyCountry implements Country {
name = "";
capital = "";
getName() {
return this.name;
}
}
注意
注意,interface 描述的是类的对外接口,也就是实例的公开属性和公开方法,不能定义私有的属性和方法。这是因为 TypeScript 设计者认为,私有属性是类的内部实现,接口作为模板,不应该涉及类的内部代码写法。
interface Foo {
private member: {}; // 报错
}
implements
只是检查类是否可以被视为接口类型。 它根本不改变类的类型或它的方法。
interface Checkable {
check(name: string): boolean;
}
class NameChecker implements Checkable {
check(s) {
// 报错:Parameter 's' implicitly has an 'any' type.
// Notice no error here
return s.toLowerCase() === "ok";
}
}
在本例中,我们可能期望 s
的类型会受到 check
的 name: string
形参的影响。但 implements
不会改变检查类体或推断类类型的方式。
✨实现多个接口
类可以实现多个接口(其实是接受多重限制),每个接口之间使用逗号分隔。
class Car implements MotorVehicle, Flyable, Swimmable {
// ...
}
但是,同时实现多个接口并不是一个好的写法,容易使得代码难以管理,可以使用两种方法替代。
第一种方法是类的继承。
class Car implements MotorVehicle {}
class SecretCar extends Car implements Flyable, Swimmable {}
第二种方法是接口的继承。
interface A {
a: number;
}
interface B extends A {
b: number;
}
注意
注意,发生多重实现时(即一个接口同时实现多个接口),不同接口不能有互相冲突的属性。
interface Flyable {
foo: number;
}
interface Swimmable {
foo: string;
}
上面示例中,属性foo
在两个接口里面的类型不同,如果同时实现这两个接口,就会报错。
✨类与接口的合并
class A {
x: number = 1;
}
interface A {
y: number;
}
let a = new A();
a.y = 10;
a.x; // 1
a.y; // 10
上面示例中,类A
与接口A
同名,后者会被合并进前者的类型定义。
✨Class 类型
✨实例类型
TypeScript 的类本身就是一种类型,但是它代表该类的实例类型,而不是 class
的自身类型。
class Color {
name: string;
constructor(name: string) {
this.name = name;
}
}
const green: Color = new Color("green");
作为类型使用时,类名只能表示实例的类型,不能表示类的自身类型。
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
// 错误
function createPoint(PointClass: Point, x: number, y: number) {
return new PointClass(x, y);
}
上面示例中,函数 createPoint()
的第一个参数 PointClass
,需要传入 Point
这个类,但是如果把参数的类型写成 Point
就会报错,因为 Point
描述的是实例类型,而不是 Class
的自身类型。
由于类名作为类型使用,实际上代表一个对象,因此可以把类看作为对象类型起名。事实上,TypeScript 有三种方法可以为对象类型起名:type
、interface
和 class
。
✨类的自身类型
要获得一个类的自身类型,一个简便的方法就是使用 typeof
运算符。
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function createPoint(PointClass: typeof Point, x: number, y: number): Point {
return new PointClass(x, y);
}
JavaScript 语言中,类只是构造函数的一种语法糖,本质上是构造函数的另一种写法。所以,类的自身类型可以写成构造函数的形式。
type Point = new (x: number, y: number) => Point;
function createPoint(PointClass: Point, x: number, y: number): Point {
return new PointClass(x, y);
}
构造函数也可以写成对象形式,所以参数 PointClass
的类型还有另一种写法。
type Point = {
new (x: number, y: number): Point;
};
function createPoint(PointClass: Point, x: number, y: number): Point {
return new PointClass(x, y);
}
根据上面的写法,可以把构造函数提取出来,单独定义一个接口 (interface)
,这样可以大大提高代码的通用性。
interface PointConstructor {
new (x: number, y: number): Point;
}
function createPoint(
PointClass: PointConstructor,
x: number,
y: number
): Point {
return new PointClass(x, y);
}
✨结构类型原则
Class 也遵循“结构类型原则”。一个对象只要满足 Class 的实例结构,就跟该 Class 属于同一个类型。
class Foo {
id!: number;
}
function fn(arg: Foo) {
// ...
}
const bar = {
id: 10,
amount: 100,
};
fn(bar); // 正确
注意
注意,确定两个类的兼容关系时,只检查实例成员,不考虑静态成员和构造方法。
class Point {
x: number;
y: number;
static t: number;
constructor(x: number) {}
}
class Position {
x: number;
y: number;
z: number;
constructor(x: string) {}
}
const point: Point = new Position("");
如果类中存在私有成员(private)
或保护成员(protected)
,那么确定兼容关系时,TypeScript 要求私有成员和保护成员来自同一个类,这意味着两个类需要存在继承关系。
// 情况一
class A {
private name = "a";
}
class B extends A {}
const a: A = new B();
// 情况二
class A {
protected name = "a";
}
class B extends A {
protected name = "b";
}
const a: A = new B();
✨类的继承
类(这里又称“子类”)可以使用 extends 关键字继承另一个类(这里又称“基类”)的所有属性和方法。
class A {
greet() {
console.log("Hello, world!");
}
}
class B extends A {}
const b = new B();
b.greet(); // "Hello, world!"
根据结构类型原则,子类也可以用于类型为基类的场合。
const a: A = b;
a.greet();
子类可以覆盖基类的同名方法, 但是,子类的同名方法不能与基类的类型定义相冲突。
如果基类包括保护成员(protected修饰符)
,子类可以将该成员的可访问性设置为公开(public修饰符)
,也可以保持保护成员不变,但是不能改用私有成员(private修饰符)
,详见后文。
class A {
protected x: string = "";
protected y: string = "";
protected z: string = "";
}
class B extends A {
// 正确
public x: string = "";
// 正确
protected y: string = "";
// 报错
private z: string = "";
}
注意
注意,extends关键字后面不一定是类名,可以是一个表达式,只要它的类型是构造函数就可以了。
// 例一
class MyArray extends Array<number> {}
// 例二
class MyError extends Error {}
// 例三
class A {
greeting() {
return "Hello from A";
}
}
class B {
greeting() {
return "Hello from B";
}
}
interface Greeter {
greeting(): string;
}
interface GreeterConstructor {
new (): Greeter;
}
function getGreeterBase(): GreeterConstructor {
return Math.random() >= 0.5 ? A : B;
}
class Test extends getGreeterBase() {
sayHello() {
console.log(this.greeting());
}
}
对于那些只设置了类型、没有初值的顶层属性,有一个细节需要注意。
interface Animal {
animalStuff: any;
}
interface Dog extends Animal {
dogStuff: any;
}
class AnimalHouse {
resident: Animal;
constructor(animal: Animal) {
this.resident = animal;
}
}
class DogHouse extends AnimalHouse {
resident: Dog;
constructor(dog: Dog) {
super(dog);
}
}
上面示例中,类 DogHouse
的顶层成员 resident
只设置了类型 (Dog)
,没有设置初值。这段代码在不同的编译设置下,编译结果不一样。
如果编译设置的 target
设成大于等于 ES2022
,或者 useDefineForClassFields
设成 true
,那么下面代码的执行结果是不一样的。
const dog = {
animalStuff: "animal",
dogStuff: "dog",
};
const dogHouse = new DogHouse(dog);
console.log(dogHouse.resident); // undefined
上面示例中,DogHouse
实例的属性 resident
输出的是 undefined
,而不是预料的 dog
。原因在于 ES2022 标准的 Class Fields 部分,与早期的 TypeScript 实现不一致,导致子类的那些只设置类型、没有设置初值的顶层成员在基类中被赋值后,会在子类被重置为undefined,详细的解释参见《tsconfig.json》一章,以及官方 3.7 版本的发布说明。
解决方法就是使用 declare
命令,去声明顶层成员的类型,告诉 TypeScript 这些成员的赋值由基类实现。
class DogHouse extends AnimalHouse {
declare resident: Dog;
constructor(dog: Dog) {
super(dog);
}
}
上面示例中,resident
属性的类型声明前面用了 declare
命令,这样就能确保在编译目标大于等于 ES2022
时(或者打开 useDefineForClassFields
时),代码行为正确。
✨可访问性修饰符
类的内部成员的外部可访问性,由三个可访问性修饰符(access modifiers)控制:public
、private
和 protected
。
public: 默认的可访问性修饰符,表示该成员可以在类的内部和外部访问。
private: 私有成员,只能用在当前类的内部,类的实例和子类都不能使用该成员。
protected: protected 修饰符与 private 修饰符的行为很相似,但有一点不同:protected 修饰符成员在派生类中可被访问。
注意
严格地说,private
定义的私有成员,并不是真正意义的私有成员。一方面,编译成 JavaScript 后,private
关键字就被剥离了,这时外部访问该成员就不会报错。
另一方面,由于前一个原因,TypeScript 对于访问 private
成员没有严格禁止,使用方括号写法 ([])
或者 in
运算符,实例对象就能访问该成员。
由于 private
存在这些问题,加上它是 ES6
标准发布前出台的,而 ES6
引入了自己的私有成员写法 #propName
。因此建议不使用 private
,改用 ES6 的写法,获得真正意义的私有成员。
✨实例属性的简写形式
实际开发中,很多实例属性的值,是通过构造方法传入的。
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
这样的写法等于对同一个属性要声明两次类型,一次在类的头部,另一次在构造方法的参数里面。这有些累赘,TypeScript 就提供了一种简写形式。
class Point {
constructor(public x: number, public y: number) {}
}
const p = new Point(10, 10);
p.x; // 10
p.y; // 10
除了 public
修饰符,构造方法的参数名只要有 private
、protected
、readonly
修饰符,都会自动声明对应修饰符的实例属性。
readonly
还可以与其他三个可访问性修饰符,一起使用。
class A {
constructor(
public readonly x: number,
protected readonly y: number,
private readonly z: number
) {}
}
✨静态成员
类的内部可以使用 static
关键字,定义静态成员。
静态成员是只能通过类本身使用的成员,不能通过实例对象使用。
class MyClass {
static x = 0;
static printX() {
console.log(MyClass.x);
}
}
MyClass.x; // 0
MyClass.printX(); // 0
static
关键字前面可以使用 public
、private
、protected
修饰符。
class MyClass {
private static x = 0;
}
MyClass.x; // 报错
静态私有属性也可以用 ES6 语法的 #
前缀表示:
class MyClass {
static #x = 0;
}
public
和 protected
的静态成员可以被继承。
✨泛型类
类也可以写成泛型,使用类型参数。关于泛型的详细介绍,请看《泛型》一章。
class Box<Type> {
contents: Type;
constructor(value: Type) {
this.contents = value;
}
}
const b: Box<string> = new Box("hello!");
注意
注意,静态成员不能使用泛型的类型参数。
class Box<Type> {
static defaultContents: Type; // 报错
}
✨抽象类,抽象成员
TypeScript 允许在类的定义前面,加上关键字 abstract
,表示该类不能被实例化,只能当作其他类的模板。这种类就叫做“抽象类”(abastract class)。
abstract class A {
id = 1;
}
class B extends A {
amount = 100;
}
const b = new B();
b.id; // 1
b.amount; // 100
抽象类的内部可以有已经实现好的属性和方法,也可以有还未实现的属性和方法。后者就叫做“抽象成员”(abstract member),即属性名和方法名有 abstract
关键字,表示该方法需要子类实现。
如果子类没有实现抽象成员,就会报错。
abstract class A {
abstract foo: string;
bar: string = "";
}
class B extends A {
foo = "b";
}
这里有几个注意点。
(1)抽象成员只能存在于抽象类,不能存在于普通类。
(2)抽象成员不能有具体实现的代码。也就是说,已经实现好的成员前面不能加 abstract
关键字。
(3)抽象成员前也不能有 private
修饰符,否则无法在子类中实现该成员。
(4)一个子类最多只能继承一个抽象类。
总之,抽象类的作用是,确保各种相关的子类都拥有跟基类相同的接口,可以看作是模板。其中的抽象成员都是必须由子类实现的成员,非抽象成员则表示基类已经实现的、由所有子类共享的成员。
✨this 问题
类的方法经常用到 this
关键字,它表示该方法当前所在的对象。
有些场合需要给出this类型,但是 JavaScript 函数通常不带有 this
参数,这时 TypeScript 允许函数增加一个名为 this
的参数,放在参数列表的第一位,用来描述函数内部的 this
关键字的类型。
// 编译前
function fn(this: SomeType, x: number) {
/* ... */
}
// 编译后
function fn(x) {
/* ... */
}
TypeScript 一旦发现函数的第一个参数名为 this
,编译后则会去除这个参数,即编译结果不会带有该参数。
class A {
name = "A";
getName(this: A) {
return this.name;
}
}
const a = new A();
const b = a.getName;
b(); // 报错
上面示例中,类 A
的 getName()
添加了 this
参数,如果直接调用这个方法,this
的类型就会跟声明的类型不一致,从而报错。
但可以通过 bind
call
apply
等方法改变 this
的类型。
class A {
name = "A";
getName(this: A) {
return this.name;
}
}
const a = new A();
const b = a.getName.bind(a);
b(); // 不报错
this
参数的类型可以声明为各种对象。
function foo(this: { name: string }) {
this.name = "Jack";
this.name = 0; // 报错
}
foo.call({ name: 123 }); // 报错
上面示例中,参数 this
的类型是一个带有 name
属性的对象,不符合这个条件的 this
都会报错。
TypeScript 提供了一个 noImplicitThis
编译选项。如果打开了这个设置项,如果 this
的值推断为 any
类型,就会报错。
// noImplicitThis 打开
class Rectangle {
constructor(public width: number, public height: number) {}
getAreaFunction() {
// 封闭函数,未指向任何的对象,它的类型推断为 any,所以就报错了。
return function () {
return this.width * this.height; // 报错
};
}
}
在类的内部,this
本身也可以当作类型使用,表示当前类的实例对象。
class Box {
contents: string = "";
set(value: string): this {
this.contents = value;
return this;
}
}
上面示例中,set()
方法的返回值类型就是 this
,表示当前的实例对象。
注意
注意,this
类型不允许应用于静态成员。
class A {
static a: this; // 报错
}
上面示例中,静态属性 a
的返回值类型是 this
,就报错了。原因是 this
类型表示实例对象,但是静态成员拿不到实例对象。
有些方法返回一个布尔值,表示当前的this
是否属于某种类型。这时,这些方法的返回值类型可以写成this is Type
的形式,其中用到了 is
运算符。
class FileSystemObject {
isFile(): this is FileRep {
return this instanceof FileRep;
}
isDirectory(): this is Directory {
return this instanceof Directory;
}
// ...
}
上面示例中,两个方法的返回值类型都是布尔值,写成 this is Type
的形式,可以精确表示返回值。is
运算符的介绍详见《类型断言》一章。