JavaScript 是一门面向对象的编程语言,它允许通过对象来建模和解决实际问题。同时,JavaScript 也支持基于原型链的对象继承机制。虽然大多数的面向对象编程语言都支持类,但是 JavaScript 语言在很长一段时间内都没有支持它。在 JavaScript 程序中,需要使用函数来实现类的功能。

在 ECMAScript 2015 规范中正式地定义了类。同时,TypeScript 语言也对类进行了全面的支持。

类的定义

虽然 JavaScript 语言支持了类,但其本质上仍是函数,类是一种语法糖。TypeScript 语言对 JavaScript 中的类进行了扩展,为其添加了类型支持,如实现接口、泛型类等。

定义一个类需要使用 class 关键字。类似于函数定义,类的定义也有以下两种方式:

  • 类声明

  • 类表达式

类声明

类声明能够创建一个类,类声明的语法如下所示:

class ClassName {
    // ...
}

在该语法中,class 是关键字;ClassName 表示类的名字。在类声明中的类名是必选的。按照惯例,类名的首字母应该大写。示例如下:

class Circle {
    radius: number;
}

const c = new Circle();

此例中,我们声明了一个 Circle 类,它包含一个 number 类型的 radius 属性。使用 new 关键字能够创建类的实例。

与函数声明不同的是,类声明不会被提升,因此必须先声明后使用。示例如下:

const c0 = new Circle(); // 错误

class Circle {
    radius: number;
}

const c1 = new Circle(); // 正确

在使用类声明时,不允许声明同名的类,否则将产生错误。示例如下:

// 错误!重复的类声明

class Circle {
    radius: number;
}

class Circle {
    radius: number;
}

类表达式

类表达式是另一种定义类的方式,它的语法如下所示:

const Name = class ClassName {
    // ...
};

在该语法中,class 是关键字;Name 表示引用了该类的变量名;ClassName 表示类的名字。在类表达式中,类名 ClassName 是可选的。

例如,下例中使用类表达式定义了一个匿名类,同时使用常量 Circle 引用了该匿名类:

const Circle = class {
    radius: number;
};

const a = new Circle();

如果在类表达式中定义了类名,则该类名只能够在类内部使用,在类外不允许引用该类名。示例如下:

const A = class B {
    name = B.name;
};

const b = new B(); // 错误

成员变量

在类中定义成员变量的方法如下所示:

class Circle {
    radius: number = 1;
}

此例中,Circle 类只包含一个成员变量。其中,radius 是成员变量名,成员变量名之后的类型注解定义了该成员变量的类型。最后,我们将该成员变量的初始值设置为 1。除了在成员变量声明中设置初始值,我们还可以在类的构造函数中设置成员变量的初始值。示例如下:

class Circle {
    radius: number;

    constructor() {
        this.radius = 1;
    }
}

此例中,在构造函数里将 radius 成员变量的值初始化为 1。同时注意,在构造函数中引用成员变量时需要使用 this 关键字。

--strictPropertyInitialization

虽然为类的成员变量设置初始值是可选的,但是对成员变量进行初始化是一个好的编程实践,它能够有效避免使用未初始化的值而引发的错误。因此,TypeScript 提供了 --strictPropertyInitialization 编译选项来帮助严格检查未经初始化的成员变量。当启用了该编译选项时,成员变量必须在声明时进行初始化或者在构造函数中进行初始化,否则将产生编译错误。

需要注意的是,--strictPropertyInitialization 编译选项必须与 --strictNullChecks 编译选项同时启用,否则 --strictPropertyInitialization 编译选项将不起作用。示例如下:

/**
 * --strictNullChecks=true
 * --strictPropertyInitialization=true
 */
class A {
    // 正确
    a: number = 0;

    // 正确,在构造函数中初始化
    b: number;

    // 错误!未初始化
    c: number;

    constructor() {
        this.b = 0;
    }
}

在此例中,类 A 的成员变量 a 在声明时进行了初始化,成员变量 b 在构造函数中进行了初始化,只有成员变量 c 始终没有进行初始化,因此将产生未初始化的编译错误。

若启用了 --strictPropertyInitialization 编译选项并且仅在构造函数中对成员变量进行了初始化操作,那么需要在构造函数中直接进行赋值操作。如果通过在构造函数中调用某个方法,进而在该方法中间接地初始化成员变量,那么编译器将无法检测到该初始化操作,因此会产生编译错误。示例如下:

/**
 * --strictNullChecks=true
 * --strictPropertyInitialization=true
 */
class A {
    // 编译错误!未初始化
    a: number;

    init() {
        this.a = 0;
    }

    constructor() {
        this.init();
    }
}

此例中,我们在构造函数中调用了 init 方法对成员变量 a 进行了初始化,但是编译器却无法检测到成员变量 a 已经被初始化。

在一些场景中,我们确实想要通过调用某些方法来初始化类的成员变量。这时可以使用非空类型断言 ! 来通知编译器该成员变量已经进行初始化,以此来避免产生编译错误。示例如下:

/**
 * --strictNullChecks=true
 * --strictPropertyInitialization=true
 */
class A {
    a!: number;
//   ~
//   非空类型断言

    init() {
        this.a = 0;
    }

    constructor() {
        this.init();
    }
}

readonly属性

在声明类的成员变量时,在成员变量名之前添加 readonly 修饰符能够将该成员变量声明为只读的。只读成员变量必须在声明时初始化或在构造函数里初始化。示例如下:

class A {
    readonly a = 0;
    readonly b: number;
    readonly c: number; // 编译错误

    constructor() {
        this.b = 0;
    }
}

此例中,只读成员变量 a 在声明时进行了初始化,只读成员变量 b 在构造函数中进行了初始化,而只读成员变量 c 没有进行初始化,因此将产生编译错误。

不管是在类的内部还是外部,都不允许修改只读成员变量的值。例如,下例中对类 A 的成员变量 a 的修改将产生编译错误:

class A {
    readonly a = 0;

    m() {
        this.a = 1;
        //   ~
        //   编译错误!不能赋值给 'a',因为它是只读属性
    }
}

const obj = new A();
obj.a = 1;
//  ~
//  编译错误!不能赋值给 'a',因为它是只读属性

关于类只读成员变量的一个最佳实践是,若类的成员变量不应该被修改,那么应该为其添加 readonly 修饰符。就算不确定是否允许修改类的某个成员变量,也可以先将该成员变量声明为只读的,当发现需要对该成员变量进行修改时再将 readonly 修饰符去掉。

成员函数

成员函数也称作方法,声明成员函数与在对象字面量中声明方法是类似的。示例如下:

class Circle {
    radius: number = 1;

    area(): number {
        return Math.PI * this.radius * this.radius;
    }
}

此例中,area 是一个成员函数。在成员函数中,需要使用 this 关键字来引用类的其他成员。

成员存取器

成员存取器由 getset 方法构成,并且会在类中声明一个属性。成员存取器的定义方式与对象字面量中属性存取器的定义方式是完全相同的。关于属性存取器的详细介绍请参考 3.5.1 节。

如果一个类属性同时定义了 get 方法和 set 方法,那么 get 方法的返回值类型必须与 set 方法的参数类型一致,否则将产生错误。示例如下:

class C {
    /**
     * 正确
     */
    private _foo: number = 0;
    get foo(): number {
        return this._foo;
    }
    set foo(value: number) {}

    /**
     * 错误!'get' 和 'set' 存取器必须具有相同的类型
     */
    private _bar: string = '';
    get bar(): string {
        return this._bar;
    }
    set bar(value: number) {}
}

如果一个类属性同时定义了 get 方法和 set 方法,那么 get 方法和 set 方法必须具有相同的可访问性。例如,不允许将 get 方法定义为公有的,而将 set 方法定义为私有的。下例中,foo 属性的存取器方法均为私有的,bar 属性的存取器方法均为公有的,因此它们是正确的;但 baz 属性的 get 方法是公有的,set 方法是私有的,两者不一致会产生编译错误。关于成员可访问性的详细介绍请参考 5.15.6 节。示例如下:

class C {
    /**
     * 正确
     */
    private _foo: number = 0;
    private get foo(): number {
        return this._foo;
    }
    private set foo(value) {}

    /**
     * 正确
     */
    private _bar: number = 0;
    public get bar(): number {
        return this._bar;
    }
    public set bar(value) {}

    /**
     * 错误!'get' 和 'set' 存取器具有不同的可见性
     */
    private _baz: number = 0;
    public get baz(): number {
        return this._baz;
    }
    private set baz(value) {}
}

存取器是实现数据封装的一种方式,它提供了一层额外的访问控制。类可以将成员变量的访问权限制在类内部,在类外部通过存取器方法来间接地访问成员变量。在存取器方法中,还可以加入额外的访问控制等处理逻辑。示例如下:

class Circle {
    private _radius: number = 0;
    get radius(): number {
        return this._radius;
    }
    set radius(value: number) {
        if (value >= 0) {
            this._radius = value;
        }
    }
}

const circle = new Circle();
circle.radius; // 0

circle.radius = -1;
circle.radius; // 0

circle.radius = 10;
circle.radius; // 10

此例中,定义了 radius 存取器,它用来控制对私有成员变量 _radius 的访问。在一些编程语言中,_radius 成员变量也称为 backing field。第 3 行,在 get 方法中我们直接返回了 _radius 的值。第 6 行,在 set 方法中会检查传入的值大于 0 时才赋值给 _radius

索引成员

类的索引成员会在类的类型中引入索引签名。索引签名包含两种,分别为字符串索引签名和数值索引签名。在实际应用中,定义类的索引成员并不常见。类中所有的属性和方法必须符合字符串索引签名定义的类型。同时,只有当类具有类似数组的行为时,数值索引签名才有意义。

类的索引成员与接口中的索引签名类型成员具有完全相同的语法和语义,这里不再重复。关于索引签名的详细介绍请参考 5.13.6 节。示例如下:

class A {
    x: number = 0;

    [prop: string]: number;

    [prop: number]: number;
}

在类的索引成员上不允许定义可访问性修饰符,如 publicprivate 等。关于成员可访问性的详细介绍请参考 5.15.6 节。

成员可访问性

成员可访问性定义了类的成员允许在何处被访问。TypeScript 为类成员提供了以下三种可访问性修饰符:

  • public

  • protected

  • private

这三种可访问性修饰符是 TypeScript 语言对 JavaScript 语言的补充。在 JavaScript 语言中不支持这三种可访问性修饰符。本节会涉及与继承相关的部分内容,关于继承的详细介绍请参考 5.15.9 节。

public

类的公有成员没有访问限制,可以在当前类的内部、外部以及派生类的内部访问。类的公有成员使用 public 修饰符标识。示例如下:

class Base {
    public a: string = '';
}

class Derived extends Base {
    public b() {
        return this.a; // 允许访问
    }
}

const derived = new Derived();

derived.a;            // 允许访问
derived.b();          // 允许访问

在默认情况下,类的所有成员都是公有成员。因此,在定义公有成员时也可以省略 public 修饰符。例如,下例中的成员变量 a 和成员函数 b 都是公有成员:

class Base {
    a: string = '';
}

class Derived extends Base {
    b() {
        return this.a;
    }
}

protected

类的受保护成员允许在当前类的内部和派生类的内部访问,但是不允许在当前类的外部访问。类的受保护成员使用 protected 修饰符标识。

例如,下例中 Base 类的成员变量 x 是受保护成员,它允许在 Base 类的内部被访问,它也允许在 Base 类的派生类 Derived 内部被访问。但是,它不允许在类的外部被访问。示例如下:

class Base {
    protected x: string = '';

    a() {
        this.x; // 允许访问
    }
}

class Derived extends Base {
    b() {
        this.x; // 允许访问
    }
}

const base = new Base();
base.x;        // 不允许访问

private

类的私有成员只允许在当前类的内部被访问,在当前类的外部以及派生类的内部都不允许访问。类的私有成员使用 private 修饰符标识。

例如,下例中 Base 类的成员变量 x 是私有成员,它允许在 Base 类的内部被访问。但是,它既不允许在 Base 类的派生类 Derived 内部被访问,也不允许在 Base 类的外部被访问。示例如下:

class Base {
    private x: string = '';

    a() {
        this.x; // 允许访问
    }
}

class Derived extends Base {
    b() {
        this.x; // 不允许访问
    }
}

const base = new Base();
base.x;   // 不允许访问

const derived = new Derived();
derived.x;  // 不允许访问

私有字段

2020 年 1 月,ECMAScript 标准引入了一个新特性,那就是允许在类中定义私有字段。这意味着 JavaScript 语言将原生地支持类的私有成员。TypeScript 语言也从 3.8 版本开始支持该特性。在 ECMAScript 标准中,类的私有字段使用一种新的语法来定义,即在字段标识符前添加一个 # 符号。不论是在定义私有字段时还是在访问私有字段时,都需要在私有字段名前添加一个 # 符号。示例如下:

class Circle {
    #radius: number;

    constructor() {
        this.#radius = 1;
    }
}

const circle = new Circle();
circle.#radius; // 不允许访问

此例中,#radius 定义了一个私有字段 radius。不论是在定义私有字段时还是在访问私有字段时,都必须在字段标识符前添加一个 # 符号。

在写作本书时,该特性还处于早期实现版本,所以这里只作简单介绍。关于在未来 TypeScript 是否会弃用 private 修饰符,仅支持标准的私有字段语法这一话题还在讨论当中,感兴趣的读者可以继续关注。目前可以得出的结论是,在未来 TypeScript 一定会更好地支持标准的私有字段,因为这是 TypeScript 语言的设计原则之一。

构造函数

构造函数用于创建和初始化类的实例。当使用 new 运算符调用一个类时,类的构造函数就会被调用。构造函数以 constructor 作为函数名。示例如下:

class Circle {
    radius: number;

    constructor(r: number) {
        this.radius = r;
    }
}

const c = new Circle(1);

此例第 4 行,定义了 Circle 类的构造函数,它接受一个 number 类型的参数 r,并使用参数 r 的值来初始化 radius 成员变量。第 9 行,使用 new 运算符创建类的实例时构造函数就会被调用。

与普通函数相同,在构造函数中也可以定义可选参数、默认值参数和剩余参数。但是构造函数不允许定义返回值类型,因为构造函数的返回值类型永远为类的实例类型。示例如下:

class A {
    constructor(a: number = 0, b?: boolean, ...c: string[]) {}
}

class B {
    constructor(): object {}
    //             ~~~~~~~
    //             编译错误!不允许指定构造函数的返回值类型
}

在构造函数上也可以使用可访问性修饰符。它描述的是在何处允许使用该类来创建实例对象。在默认情况下,构造函数是公有的。如果将构造函数设置成私有的,则只允许在类的内部创建该类的对象。例如,下例中 Singleton 类的构造函数是私有的,因此只允许在 Singleton 类内部创建该类的实例对象。第 15 行,在 Singleton 类外部创建其实例对象时将产生编译错误。示例如下:

class Singleton {
    private static instance?: Singleton;

    private constructor() {}

    static getInstance() {
        if (!Singleton.instance) {
            // 允许访问
            Singleton.instance = new Singleton();
        }
        return Singleton.instance;
    }
}

new Singleton(); // 编译错误

与函数重载类似,构造函数也支持重载。我们将没有函数体的构造函数声明称为构造函数重载,同时将定义了函数体的构造函数声明称为构造函数实现。构造函数重载可以存在零个或多个,而构造函数实现只能存在一个。示例如下:

class A {
    constructor(x: number, y: number);
    constructor(s: string);
    constructor(xs: number | string, y?: number) {}
}

const a = new A(0, 0);
const b = new A('foo');

关于重载函数的详细介绍请参考 5.12.12 节。

参数成员

TypeScript 提供了一种简洁语法能够把构造函数的形式参数声明为类的成员变量,它叫作参数成员。在构造函数参数列表中,为形式参数添加任何一个可访问性修饰符或者 readonly 修饰符,该形式参数就成了参数成员,进而会被声明为类的成员变量。示例如下:

class A {
    constructor(public x: number) {}
}

const a = new A(0);
a.x; // 值为0

此例在类 A 的构造函数中,参数 x 是一个参数成员,因此会在类 A 中声明一个 public 的成员变量 x。第 5 行,使用实际参数 0 来实例化类 A 时会自动将成员变量 x 的值初始化为 0,因此第 6 行读取成员变量 x 的值时结果为 0。我们不需要在构造函数中使用 this.x = x 来设置成员变量 x 的值,TypeScript 能够自动处理。

上例中,我们使用的是 public 修饰符。类似地,我们也可以使用其他修饰符来定义参数成员。示例如下:

class A {
    constructor(
        public x: number,
        protected y: number,
        private z: number
    ) {}
}

class B {
    constructor(readonly x: number) {}
}

readonly 修饰符也可以和任意一个可访问性修饰符结合使用来定义只读的参数成员。示例如下:

class A {
    constructor(
        public readonly x: number,
        protected readonly y: number,
        private readonly z: number
    ) {}
}

继承

继承是面向对象程序设计的三个基本特征之一,TypeScript 中的类也支持继承。在定义类时可以使用 extends 关键字来指定要继承的类,具体语法如下所示:

class DerivedClass extends BaseClass { }

在该语法中,我们将 BaseClass 叫作基类,将 DerivedClass 叫作派生类,派生类继承了基类。有时候,我们也将基类称作父类,将派生类称作子类。

当派生类继承了基类后,就自动继承了基类的非私有成员。例如,下例中 Circle 类继承了 Shape 类。因此,Circle 类获得了 Shape 类的 colorswitchColor 公有成员。我们可以在 Circle 类的实例对象上访问 color 成员变量和调用 switchColor 成员函数。示例如下:

class Shape {
    color: string = 'black';

    switchColor() {
        this.color =
            this.color === 'black' ? 'white' : 'black';
    }
}

class Circle extends Shape {}

const circle = new Circle();

circle.color; // 'black'
circle.switchColor();
circle.color; // 'white'

重写基类成员

在派生类中可以重写基类的成员变量和成员函数。在重写成员变量和成员函数时,需要在派生类中定义与基类中同名的成员变量和成员函数。示例如下:

class Shape {
    color: string = 'black';

    switchColor() {
        this.color =
            this.color === 'black' ? 'white' : 'black';
    }
}

class Circle extends Shape {
    color: string = 'red';

    switchColor() {
        this.color = this.color === 'red' ? 'green' : 'red';
    }
}

const circle = new Circle();

circle.color; // 'red'
circle.switchColor();
circle.color; // 'green'

此例中,Circle 类重写了 Shape 类中的 color 属性和 switchColor 方法。第 20 行,读取 circle 对象上的 color 属性时,获取的是重写后的属性。第 21 行,调用 circle 对象上的 switchColor 方法时,调用的是重写后的方法。

在派生类中,可以通过 super 关键字来访问基类中的非私有成员。当派生类和基类中存在同名的非私有成员时,在派生类中只能通过 super 关键字来访问基类中的非私有成员,无法使用 this 关键字来引用基类中的非私有成员。示例如下:

class Shape {
    color: string = 'black';

    switchColor() {
        this.color =
            this.color === 'black' ? 'white' : 'black';
    }
}

class Circle extends Shape {
    switchColor() {
        super.switchColor();
        console.log(`Color is ${this.color}.`);
    }
}

const circle = new Circle();

circle.switchColor();
circle.switchColor();

// 打印:
// Color is white.
// Color is black.

此例第 11 行,Circle 类重写了 Shape 类的 switchColor 方法。第 12 行,使用了 super 关键字来调用 Shape 类中的 switchColor 方法。

若派生类重写了基类中的受保护成员,则可以将该成员的可访问性设置为受保护的或公有的。也就是说,在派生类中只允许放宽基类成员的可访问性。例如,下例中 Base 类的三个成员变量都是受保护成员。在派生类 Derived 中不允许将其重写为私有成员。示例如下:

class Base {
    protected x: string = '';
    protected y: string = '';
    protected z: string = '';
}

class Derived extends Base {
    // 正确
    public x: string = '';

    // 正确
    protected y: string = '';

    // 错误!派生类不能够将基类的受保护成员重写为更严格的可访问性
    private z: string = '';
}

由于派生类是基类的子类型,因此在重写基类的成员时需要保证子类型兼容性。示例如下:

class Shape {
    color: string = 'black';

    switchColor() {
        this.color =
            this.color === 'black' ? 'white' : 'black';
    }
}

class Circle extends Shape {
    // 编译错误
    // 类型'(color: string) => void'不能赋值给类型'() => void'
    switchColor(color: string) {}
}

关于子类型兼容性的详细介绍请参考7.1节。

派生类实例化

在派生类的构造函数中必须调用基类的构造函数,否则将不能正确地实例化派生类。在派生类的构造函数中使用 super() 语句就能够调用基类的构造函数。示例如下:

class Shape {
    color: string = 'black';

    constructor() {
        this.color = 'black';
    }

    switchColor() {
        this.color =
            this.color === 'black' ? 'white' : 'black';
    }
}

class Circle extends Shape {
    radius: number;

    constructor() {
        super();

        this.radius = 1;
    }
}

此例第 18 行,在 Circle 类的构造函数中调用了基类 Shape 的构造函数。这样能够保证正确地实例化基类 Shape 中的成员。若派生类中定义了构造函数,但没有添加 super() 语句,那么将产生编译错误。

在派生类的构造函数中,引用了 this 的语句必须放在 super() 调用的语句之后,否则将产生编译错误,因为在基类初始化之前访问类的成员可能会产生错误。示例如下:

class Shape {
    color: string = 'black';

    constructor() {
        this.color = 'black';
    }

    switchColor() {
        this.color =
            this.color === 'black' ? 'white' : 'black';
    }
}

class Circle extends Shape {
    radius: number;

    constructor() {
        this.radius = 1;
    //  ~~~~
    //  编译错误,必须先调用 'super' 再访问 'this'

        super();

        // 正确
        this.radius = 1;
    }
}

在实例化派生类时的初始化顺序如下:

  1. 初始化基类的属性。

  2. 调用基类的构造函数。

  3. 初始化派生类的属性。

  4. 调用派生类的构造函数。

例如,下例中的数字标识与上面的步骤序号是对应的:

class Shape {
    color: string = 'black';   // 1

    constructor() {            // 2
        console.log(this.color);
        this.color = 'white';
        console.log(this.color);
    }
}

class Circle extends Shape {
    radius: number = 1;        // 3

    constructor() {            // 4
        super();

        console.log(this.radius);
        this.radius = 2;
        console.log(this.radius);
    }
}

const circle = new Circle();

// 输出结果为:
// black
// white
// 1
// 2

单继承

TypeScript 中的类仅支持单继承,不支持多继承。也就是说,在 extends 语句中只能指定一个基类。示例如下:

class A {}
class B {}

class C extends A, B {}
//                 ~
//                 编译错误:类只能继承一个类

接口继承类

TypeScript 允许接口继承类。若接口继承了一个类,那么该接口会继承基类中所有成员的类型。例如,下例中接口 B 继承了类 A。因此,接口 B 中包含了 string 类型的成员 x 和方法类型 y。示例如下:

class A {
    x: string = '';

    y(): boolean {
        return true;
    }
}

interface B extends A {}

declare const b: B;

b.x;   // 类型为string
b.y(); // 类型为boolean

在接口继承类时,接口不但会继承基类的公有成员类型,还会继承基类的受保护成员类型和私有成员类型。如果接口从基类继承了非公有成员,那么该接口只能由基类或基类的子类来实现。示例如下:

// 正确,A 可以实现接口 I,因为私有属性和受保护属性源自同一个类 A
class A implements I {
    private x: string = '';
    protected y: string = '';
}

// 接口 I 能够继承 A 的私有属性和受保护属性
interface I extends A {}

// 正确,B 可以实现接口 I,因为私有属性和受保护属性源自同一个类 A
class B extends A implements I {}

// 错误!C 不是 A 的子类,无法实现 A 的有属性和受保护属性
class C implements I {}

实现接口

虽然一个类只允许继承一个基类,但是可以实现一个或多个接口。在定义类时,使用 implements 语句能够声明类所实现的接口。当实现多个接口时,接口名之间使用逗号 , 分隔。下例中,类 C 实现了接口 A 和接口 B

interface A {}
interface B {}

class C implements A, B {}

如果类的定义中声明了要实现的接口,那么这个类就需要实现接口中定义的类型成员。下例中,Circle 类声明了要实现 ShapeColor 两个接口。因此,在 Circle 类中需要实现两个接口中定义的类型成员 colorarea。示例如下:

interface Color {
    color: string;
}

interface Shape {
    area(): number;
}

class Circle implements Shape, Color {
    radius: number = 1;

    color: string = 'black';

    area(): number {
        return Math.PI * this.radius * this.radius;
    }
}

静态成员

类的定义中可以包含静态成员。类的静态成员不属于类的某个实例,而是属于类本身。类的静态成员使用 static 关键字定义,并且只允许通过类名来访问。

例如,下例中 Circle 类定义了静态成员变量 version,它只允许通过类名 Circle 进行访问:

class Circle {
    static version: string = '1.0';
}

// 正确,结果为 '1.0'
const version = Circle.version;

const circle = new Circle();
circle.version;
//     ~~~~~~~
//     编译错误!'version' 属性是 'Circle' 类的静态属性

静态成员可访问性

类的静态成员也可以定义不同的可访问性,如 publicprivateprotected

类的 public 静态成员对访问没有限制,可以在当前类的内部、外部以及派生类的内部访问。示例如下:

class Base {
    public static x: string = '';

    a() {
        // 正确,允许在类内部访问公有静态成员 x
        Base.x;
    }
}

class Derived extends Base {
    b() {
        // 正确,允许在派生类内部访问公有静态成员 x
        Base.x;
    }
}

// 正确,允许在类外部访问公有静态成员 x
Base.x;

类的 protected 静态成员允许在当前类的内部和派生类的内部访问,但是不允许在当前类的外部访问。示例如下:

class Base {
    protected static x: string = '';

    a() {
        // 正确,允许在类内部访问受保护的静态成员 x
        Base.x;
    }
}

class Derived extends Base {
    b() {
        // 正确,允许在派生类内部访问受保护的静态成员 x
        Base.x;
    }
}

// 错误!不允许在类外部访问受保护的静态成员 x
Base.x;

类的 private 静态成员只允许在当前类的内部访问。示例如下:

class Base {
    private static x: string = '';

    a() {
        // 正确,允许在类内部访问受保护的静态成员 x
        Base.x;
    }
}

class Derived extends Base {
    b() {
        // 错误!不允许在派生类内部访问受保护的静态成员 x
        Base.x;
    }
}

// 错误!不允许在类外部访问受保护的静态成员 x
Base.x;

继承静态成员

类的 public 静态成员和 protected 静态成员也可以被继承。例如,下例中派生类 Derived 继承了基类 Base 的静态成员 xy

class Base {
    public static x: string = '';
    protected static y: string = '';
}

class Derived extends Base {
    b() {
        // 继承了基类的静态成员 x
        Derived.x;

        // 继承了基类的静态成员 y
        Derived.y;
    }
}

抽象类和抽象成员

前面介绍的类和类的成员都属于具体类和具体类成员。TypeScript 也支持定义抽象类和抽象类成员。抽象类和抽象类成员都使用 abstract 关键字来定义。

抽象类

定义抽象类时,只需要在 class 关键字之前添加 abstract 关键字即可。示例如下:

abstract class A {}

抽象类与具体类的一个重要区别是,抽象类不能被实例化。也就是说,不允许使用 new 运算符来创建一个抽象类的实例。示例如下:

abstract class A {}

const a = new A();
//        ~~~~~~~
//        编译错误!不能创建抽象类的实例

抽象类的作用是作为基类使用,派生类可以继承抽象类。示例如下:

abstract class Base {}

class Derived extends Base {}

const derived = new Derived();

抽象类也可以继承其他抽象类。示例如下:

abstract class Base {}

abstract class Derived extends Base {}

此例中,基类和派生类都是抽象类,它们都不能被实例化。

抽象类中允许(通常)包含抽象成员,也允许包含非抽象成员。示例如下:

abstract class Base {
    abstract a: string;

    b: string = '';
}

接下来,我们将介绍抽象类中的抽象成员。

抽象成员

在抽象类中允许声明抽象成员,抽象成员不允许包含具体实现代码。示例如下:

// 以下用法均为正确用法
abstract class A {
    abstract a: string;
    abstract b: number = 0;

    abstract method(): string;

    abstract get accessor(): string;
    abstract set accessor(value: string);
}

abstract class B {
    // 编译错误!抽象方法不能带有具体实现
    abstract method() {}

    // 编译错误!抽象存取器不能带有具体实现
    abstract get c(): string { return ''; };
    abstract set c(value: string) {};
}

如果一个具体类继承了抽象类,那么在具体的派生类中必须实现抽象类基类中的所有抽象成员。因此,抽象类中的抽象成员不能声明为 private,否则将无法在派生类中实现该成员。示例如下:

abstract class Base {
    abstract a: string;

    abstract get accessor(): string;
    abstract set accessor(value: string);

    abstract method(): boolean;
}

class Derived extends Base {
    // 实现抽象属性 a
    a: string = '';

    // 实现抽象存取器accessor
    private _accessor: string = '';
    get accessor(): string {
        return this._accessor;
    }
    set accessor(value: string) {
        this._accessor = value;
    }

    // 实现抽象方法 method
    method(): boolean {
        return true;
    }
}

若没有正确地在具体的派生类中实现抽象成员,将产生编译错误。

this类型

在类中存在一种特殊的 this 类型,它表示当前 this 值的类型。我们可以在类的非静态成员的类型注解中使用 this 类型。例如,下例中 add() 方法和 subtract() 方法的返回值类型为 this 类型。第 20 行,我们可以链式调用 add() 方法和 subtract() 方法,因为它们返回的是当前实例对象。示例如下:

class Counter {
    private count: number = 0;

    public add(): this {
        this.count++;
        return this;
    }
    public subtract(): this {
        this.count--;
        return this;
    }

    public getResult(): number {
        return this.count;
    }
}

const counter = new Counter();

counter
    .add()
    .add()
    .subtract()
    .getResult(); // 结果为1

需要强调的是,this 类型是动态的,表示当前 this 值的类型。当前 this 值的类型不一定是引用了 this 类型的那个类,该差别主要体现在类之间有继承关系的时候。示例如下:

class A {
    foo(): this {
        return this;
    }
}

class B extends A {
    bar(): this {
        return this;
    }
}

const b = new B();
const x = b.bar().foo();
//    ~
//    类型为B

此例中,foo 方法和 bar 方法的返回值类型都是 this 类型,且 B 继承了 A。第 14 行,通过B类的实例来调用 foo 方法和 bar 方法时,返回值类型都是 B 类的实例类型。

注意,this 类型不允许应用于类的静态成员。示例如下:

class A {
    static a: this;
    //        ~~~~
    //        编译错误! 'this' 类型只能用于类的非静态成员
}

类类型

类声明将会引入一个新的命名类型,即与类同名的类类型。类类型表示类的实例类型,它由类的实例成员类型构成。例如,下例中 Circle 类声明同时也定义了 Circle 类类型,该类型包含 number 类型的 radius 属性和函数类型的 area 属性。该类类型与 CircleType 接口表示的对象类型是相同的类型。示例如下:

class Circle {
    radius: number;
    area(): number {
        return Math.PI * this.radius * this.radius;
    }
}

interface CircleType {
    radius: number;
    area(): number;
}

// 正确
const a: Circle = new Circle();

// 正确
const b: CircleType = new Circle();

在定义一个类时,实际上我们定义了一个构造函数。随后,我们可以使用 new 运算符和该构造函数来创建类的实例。我们可以将该类型称作类的构造函数类型,在该类型中也包含了类的静态成员类型。例如,下例中常量 a 的类型是类类型 A,也就是我们经常提到的类的实例类型。常量 b 的类型是类的构造函数类型,我们使用了包含构造签名的接口表示该类型,并将类 A 赋值给了常量 b。不难发现,类的静态成员 x 是类构造函数类型的一部分。示例如下:

class A {
    static x: number = 0;
    y: number = 0;
}

// 类类型,即实例类型
const a: A = new A();

interface AConstructor {
    new (): A;
    x: number;
}

// 类构造函数类型
const b: AConstructor = A;