TypeScript 完全指南:从基础到高级类型系统(八)「类」

在面向对象编程的世界里,类(class)就像是构建大厦的基石🧱,它将属性和方法封装在一起,为代码的组织和复用提供了强大的支持。而TypeScript作为JavaScript的超集,对类的特性给予了全面且细致的支持。

📌属性的类型

类的属性可以有多种声明方式,既可以在类的顶层进行声明,也可以在构造方法内部完成声明。

  • 顶层声明并指定类型:当我们在顶层声明属性时,可以同时为其指定类型。
class Point {
  x: number;
  y: number;
}

在这个例子中,属性xy的类型都被明确指定为number

  • 不指定类型:如果在声明属性时不指定类型,TypeScript会默认将其类型视为any
class Point {
  x;
  y;
}

这里的xy的类型就是any,这意味着它们可以是任意类型的值。

  • 自动推断类型:要是在声明属性时同时给出了初值,TypeScript会根据初值自动推断属性的类型。
class Point {
  x = 0;
  y = 0;
}

此时,属性xy的类型会被推断为number

  • 严格属性初始化检查:TypeScript有一个配置项strictPropertyInitialization,默认是开启状态。当它开启时,TypeScript会检查属性是否设置了初值,如果没有设置就会报错。
// 打开 strictPropertyInitialization
class Point {
  x: number; // 报错
  y: number; // 报错
}

为了避免这种报错,我们可以使用非空断言。

class Point {
  x!: number;
  y!: number;
}

在属性名后面添加感叹号!,表示这两个属性肯定不会为空,这样TypeScript就不会报错了,具体可以参考《类型断言》一章。

🔒readonly修饰符

在属性名前面加上readonly修饰符,就可以将该属性设置为只读属性。一旦设置为只读,实例对象就不能修改这个属性的值了。

class A {
  readonly id = 'foo';
}

const a = new A();
a.id = 'bar'; // 报错

在这个例子中,id属性前面有readonly修饰符,所以当我们尝试修改实例对象aid属性时,就会报错。

readonly属性的初始值可以在顶层属性声明时设置,也可以在构造方法里面进行设置。

class A {
  readonly id: string;

  constructor() {
    this.id = 'bar'; // 正确
  }
}

在构造方法内部设置只读属性的初值是被允许的。同样,即使在顶层属性声明时已经设置了初值,在构造方法中也可以修改只读属性的值,并且以构造方法中的设置为准。不过,在其他方法中修改只读属性的值会报错。

class A {
  readonly id: string = 'foo';

  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
    );
  }
}

在这个例子中,构造方法constructor()和普通方法add()都明确注明了参数类型,而返回值类型则被省略了,因为TypeScript可以根据方法的实现自动推断出来。

类的方法和普通函数一样,也支持参数默认值和函数重载。

  • 参数默认值
class Point {
  x: number;
  y: number;

  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}

在这个例子中,如果我们在创建Point类的实例时,没有提供属性xy的值,那么它们会默认等于0

  • 函数重载
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;
  }
}

在这个例子中,get name()是取值器,当外部代码读取name属性时,实例对象会自动调用这个方法,该方法的返回值就是name属性的值。而set name()是存值器,当外部代码写入name属性时,实例对象会自动调用这个方法,并将所赋的值作为函数参数传入。

TypeScript对存取器有以下几个规则:

  1. 只读属性:如果某个属性只有get方法,没有set方法,那么该属性会自动成为只读属性。
class C {
  _name = 'foo';

  get name() {
    return this._name;
  }
}

const c = new C();
c.name = 'bar'; // 报错

在这个例子中,name属性没有set方法,所以对该属性进行赋值操作时会报错。

  1. 类型兼容性:在TypeScript 5.1版之前,set方法的参数类型必须与get方法的返回值类型兼容,否则会报错。
// TypeScript 5.1 版之前
class C {
  _name = '';
  get name(): string {  // 报错
    return this._name;
  }
  set name(value: number) {
    this._name = String(value);
  }
}

在这个例子中,get方法的返回值类型是字符串,而set方法的参数类型是number,两者不兼容,所以会报错。修改后的代码如下:

class C {
  _name = '';
  get name(): string {
    return this._name;
  }
  set name(value: number | string) {
    this._name = String(value);
  }
}

在这个修改后的例子中,set方法的参数类型(number|string)与get方法的返回值类型(string)兼容,所以不会报错。不过,TypeScript 5.1版做出了改变,现在两者可以不兼容。

  1. 可访问性一致性get方法与set方法的可访问性必须保持一致,要么都为公开方法,要么都为私有方法。

🗂️属性索引

类允许我们定义属性索引,通过属性索引可以对类的属性进行更灵活的类型定义。

class MyClass {
  [s: string]: boolean |
    ((s: string) => boolean);

  get(s: string) {
    return this[s] as boolean;
  }
}

在这个例子中,[s:string]表示所有属性名类型为字符串的属性,它们的属性值要么是布尔值,要么是返回布尔值的函数。

需要注意的是,由于类的方法本质上也是一种特殊属性(属性值为函数的属性),所以属性索引的类型定义也会涵盖方法。如果一个对象同时定义了属性索引和方法,那么属性索引的类型必须包含方法的类型。

class MyClass {
  [s: string]: boolean;
  f() { // 报错
    return true;
  }
}

在这个例子中,属性索引的类型里面不包括方法,所以后面的方法f()定义会报错。正确的写法如下:

class MyClass {
  [s: string]: boolean | (() => boolean);
  f() {
    return true;
  }
}

属性存取器在TypeScript中视同属性。

class MyClass {
  [s: string]: boolean;

  get isInstance() {
    return true;
  }
}

在这个例子中,属性isInstance的读取器虽然是一个函数方法,但在TypeScript中视同属性,所以即使属性索引没有涉及方法类型,也不会报错。

🚀类的interface接口

🔗implements关键字

在TypeScript中,interface接口或type别名可以以对象的形式,为class指定一组检查条件。类可以使用implements关键字,表示当前类满足这些外部类型条件的限制。

interface Country {
  name: string;
  capital: string;
}
// 或者
type Country = {
  name: string;
  capital: string;
}

class MyCountry implements Country {
  name = '';
  capital = '';
}

在这个例子中,无论是使用interface还是type,都可以定义一个对象类型。类MyCountry使用implements关键字,表示该类的实例对象满足这个外部类型的要求。

需要注意的是,interface只是用于指定检查条件,如果类不满足这些条件就会报错,但它并不能代替class自身的类型声明。

interface A {
  get(name: string): boolean;
}

class B implements A {
  get(s) { // s 的类型是 any
    return true;
  }
}

在这个例子中,类B实现了接口A,但接口A并不能代替B的类型声明。因此,Bget()方法的参数s的类型是any,而不是stringB类依然需要自行声明参数s的类型。

class B implements A {
  get(s: string) {
    return true;
  }
}

再看另一个例子:

interface A {
  x: number;
  y?: number;
}

class B implements A {
  x = 0;
}

const b = new B();
b.y = 10; // 报错

在这个例子中,接口A有一个可选属性y,类B没有声明这个属性,所以可以通过类型检查。但是,如果给B的实例对象的属性y赋值,就会报错。所以,B类还是需要声明可选属性y

class B implements A {
  x = 0;
  y?: number;
}

同理,类可以定义接口没有声明的方法和属性。

interface Point {
  x: number;
  y: number;
}

class MyPoint implements Point {
  x = 1;
  y = 1;
  z: number = 1;
}

在这个例子中,MyPoint类实现了Point接口,但它内部还定义了一个额外的属性z,这是允许的,表示除了满足接口给出的条件,类还有额外的特性。

implements关键字后面,不仅可以是接口,也可以是另一个类。这时,后面的类将被当作接口。

class Car {
  id: number = 1;
  move(): void {};
}

class MyCar implements Car {
  id = 2; // 不可省略
  move(): void {};   // 不可省略
}

在这个例子中,implements后面是类Car,这时TypeScript就把Car视为一个接口,要求MyCar实现Car里面的每一个属性和方法,否则就会报错。所以,这时不能因为Car类已经实现过一次,而在MyCar类省略属性或方法。

需要注意的是,interface描述的是类的对外接口,也就是实例的公开属性和公开方法,不能定义私有的属性和方法。这是因为TypeScript设计者认为,私有属性是类的内部实现,接口作为模板,不应该涉及类的内部代码写法。

interface Foo {
  private member: {}; // 报错
}

在这个例子中,接口Foo有一个私有属性,结果就报错了。

🌈实现多个接口

类可以实现多个接口(其实是接受多重限制),每个接口之间使用逗号分隔。

class Car implements MotorVehicle, Flyable, Swimmable {
  // ...
}

在这个例子中,Car类同时实现了MotorVehicleFlyableSwimmable三个接口。这意味着,它必须部署这三个接口声明的所有属性和方法,满足它们的所有条件。

但是,同时实现多个接口并不是一个好的写法,容易使得代码难以管理,可以使用两种方法替代。

🚀第一种方法是类的继承
class Car implements MotorVehicle {
}

class SecretCar extends Car implements Flyable, Swimmable {
}

在这个例子中,Car类实现了MotorVehicle,而SecretCar类继承了Car类,然后再实现FlyableSwimmable两个接口,相当于SecretCar类同时实现了三个接口。

🚀第二种方法是接口的继承
interface A {
  a: number;
}

interface B extends A {
  b: number;
}

在这个例子中,接口B继承了接口A,类只要实现接口B,就相当于实现AB两个接口。

前一个例子可以用接口继承改写。

interface MotorVehicle {
  // ...
}
interface Flyable {
  // ...
}
interface Swimmable {
  // ...
}

interface SuperCar extends MotorVehicle,Flyable, Swimmable {
  // ...
}

class SecretCar implements SuperCar {
  // ...
}

在这个例子中,类SecretCar通过SuperCar接口,就间接实现了多个接口。

需要注意的是,发生多重实现时(即一个接口同时实现多个接口),不同接口不能有互相冲突的属性。

interface Flyable {
  foo: number;
}

interface Swimmable {
  foo: string;
}

在这个例子中,属性foo在两个接口里面的类型不同,如果同时实现这两个接口,就会报错。

⚙️类与接口的合并

TypeScript不允许两个同名的类,但是如果一个类和一个接口同名,那么接口会被合并进类。

class A {
  x: number = 1;
}

interface A {
  y: number;
}

let a = new A();
a.y = 10;

a.x // 1
a.y // 10

在这个例子中,类A与接口A同名,后者会被合并进前者的类型定义。

需要注意的是,合并进类的非空属性(上例的y),如果在赋值之前读取,会返回undefined

class A {
  x: number = 1;
}

interface A {
  y: number;
}

let a = new A();
a.y // undefined

在这个例子中,根据类型定义,y应该是一个非空属性。但是合并后,y有可能是undefined

🎯Class类型

📍实例类型

TypeScript的类本身就是一种类型,但是它代表该类的实例类型,而不是class的自身类型。

class Color {
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

const green: Color = new Color('green');

在这个例子中,定义了一个类Color。它的类名就代表一种类型,实例对象green就属于该类型。

对于引用实例对象的变量来说,既可以声明类型为Class,也可以声明类型为Interface,因为两者都代表实例对象的类型。

interface MotorVehicle {
}

class Car implements MotorVehicle {
}

// 写法一
const c1: Car = new Car();
// 写法二
const c2: MotorVehicle = new Car();

在这个例子中,变量的类型可以写成类Car,也可以写成接口MotorVehicle。它们的区别是,如果类Car有接口MotorVehicle没有的属性和方法,那么只有变量c1可以调用这些属性和方法。

作为类型使用时,类名只能表示实例的类型,不能表示类的自身类型。

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有三种方法可以为对象类型起名:typeinterfaceclass

🚦类的自身类型

要获得一个类的自身类型,一个简便的方法就是使用typeof运算符。

function createPoint(
  PointClass: typeof Point,
  x: number,
  y: number
): Point {
  return new PointClass(x, y);
}

在这个例子中,createPoint()的第一个参数PointClassPoint类自身,要声明这个参数的类型,简便的方法就是使用typeof Point。因为Point类是一个值,typeof Point返回这个值的类型。注意,createPoint()的返回值类型是Point,代表实例类型。

JavaScript语言中,类只是构造函数的一种语法糖,本质上是构造函数的另一种写法。所以,类的自身类型可以写成构造函数的形式。

function createPoint(
  PointClass: new (x: number, y: number) => Point,
  x: number,
  y: number
): Point {
  return new PointClass(x, y);
}

在这个例子中,参数PointClass的类型写成了一个构造函数,这时就可以把Point类传入。

构造函数也可以写成对象形式,所以参数PointClass的类型还有另一种写法。

function createPoint(
  PointClass: {
    new (x: number, y: number): 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); // 正确

在这个例子中,对象bar满足类Foo的实例结构,只是多了一个属性amount。所以,它可以当作参数,传入函数fn()

如果两个类的实例结构相同,那么这两个类就是兼容的,可以用在对方的使用场合。

class Person {
  name: string;
}

class Customer {
  name: string;
}

// 正确
const cust: Customer = new Person();

在这个例子中,PersonCustomer是两个结构相同的类,TypeScript将它们视为相同类型,因此Person可以用在类型为Customer的场合。

现在修改一下代码,Person类添加一个属性。

class Person {
  name: string;
  age: number;
}

class Customer {
  name: string;
}

// 正确
const cust: Customer = new Person();

在这个例子中,Person类添加了一个属性age,跟Customer类的结构不再相同。但是这种情况下,TypeScript依然认为,Person属于Customer类型。

这是因为根据“结构类型原则”,只要Person类具有name属性,就满足Customer类型的实例结构,所以可以代替它。反过来就不行,如果Customer类多出一个属性,就会报错。

class Person {
  name: string;
}

class Customer {
  name: string;
  age: number;
}

// 报错
const cust: Customer = new Person();

在这个例子中,Person类比Customer类少一个属性age,它就不满足Customer类型的实例结构,就报错了。因为在使用Customer类型的情况下,可能会用到它的age属性,而Person类就没有这个属性。

总之,只要A类具有B类的结构,哪怕还有额外的属性和方法,TypeScript也认为A兼容B的类型。

不仅是类,如果某个对象跟某个class的实例结构相同,TypeScript也认为两者的类型相同。

class Person {
  name: string;
}

const obj = { name: 'John' };
const p: Person = obj; // 正确

在这个例子中,对象obj并不是Person的实例,但是赋值给变量p不会报错,TypeScript认为obj也属于Person类型,因为它们的属性相同。

由于这种情况,运算符instanceof不适用于判断某个对象是否跟某个class属于同一类型。

obj instanceof Person // false

在这个例子中,运算符instanceof确认变量obj不是Person的实例,但是两者的类型是相同的。

空类不包含任何成员,任何其他类都可以看作与空类结构相同。因此,凡是类型为空类的地方,所有类(包括对象)都可以使用。

class Empty {}

function fn(x: Empty) {
  // ...
}

fn({});
fn(window);
fn(fn);

在这个例子中,函数fn()的参数是一个空类,这意味着任何对象都可以用作fn()的参数。

注意,确定两个类的兼容关系时,只检查实例成员,不考虑静态成员和构造方法。

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('');

在这个例子中,PointPosition的静态属性和构造方法都不一样,但因为Point的实例成员与Position相同,所以Position兼容Point

如果类中存在私有成员(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();

在这两个例子中,AB都有私有成员(或保护成员)name,这时只有在B继承A的情况下(class B extends A),B才兼容A

🔄类的继承

类(这里又称“子类”)可以使用extends关键字继承另一个类(这里又称“基类”)的所有属性和方法。

class A {
  greet() {
    console.log('Hello, world!');
  }
}

class B extends A {
}

const b = new B();
b.greet() // "Hello, world!"

在这个例子中,子类B继承了基类A,因此就拥有了greet()方法,不需要再次在类的内部定义这个方法了。

根据结构类型原则,子类也可以用于类型为基类的场合。

const a: A = b;
a.greet()

在这个例子中,变量a的类型是基类,但是可以赋值为子类的实例。

子类可以覆盖基类的同名方法。

class B extends A {
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`Hello, ${name}`);
    }
  }
}

在这个例子中,子类B定义了一个方法greet(),覆盖了基类A的同名方法。其中,参数name省略时,就调用基类Agreet()方法,这里可以写成super.greet(),使用super关键字指代基类是常见做法。

但是,子类的同名方法不能与基类的类型定义相冲突。

class A {
  greet() {
    console.log('Hello, world!');
  }
}

class B extends A {
  // 报错
  greet(name: string) {
    console.log(`Hello, ${name}`);
  }
}

在这个例子中,子类Bgreet()有一个name参数,跟基类Agreet()定义不兼容,因此就报错了。

如果基类包括保护成员(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 = '';
}

在这个例子中,子类B将基类A的受保护成员改成私有成员,就会报错。

注意,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());
  }
}

在这个例子中,例一和例二的extends关键字后面都是构造函数,例三的extends关键字后面是一个表达式,执行后得到的也是一个构造函数。

🔒可访问性修饰符

类的内部成员的外部可访问性,由三个可访问性修饰符(access modifiers)控制:publicprivateprotected

这三个修饰符的位置,都写在属性或方法的最前面。

🟢public

public修饰符表示这是公开成员,外部可以自由访问。

class Greeter {
  public greet() {
    console.log("hi!");
  }
}

const g = new Greeter();
g.greet();

在这个例子中,greet()方法前面的public修饰符,表示该方法可以在类的外部调用,即外部实例可以调用。

public修饰符是默认修饰符,如果省略不写,实际上就带有该修饰符。因此,类的属性和方法默认都是外部可访问的。

正常情况下,除非为了醒目和代码可读性,public都是省略不写的。

🔴private

private修饰符表示私有成员,只能用在当前类的内部,类的实例和子类都不能使用该成员。

class A {
  private x: number = 0;
}

const a = new A();
a.x // 报错

class B extends A {
  showX() {
    console.log(this.x); // 报错
  }
}

在这个例子中,属性x前面有private修饰符,表示这是私有成员。因此,实例对象和子类使用该成员,都会报错。

注意,子类不能定义父类私有成员的同名成员。

class A {
  private x = 0;
}

class B extends A {
  x = 1; // 报错
}

在这个例子中,A类有一个私有属性x,子类B就不能定义自己的属性x了。

如果在类的内部,当前类的实例可以获取私有成员。

class A {
  private x = 10;

  f(obj: A) {
    console.log(obj.x);
  }
}

const a = new A();
a.f(a) // 10

在这个例子中,在类A内部,A的实例对象可以获取私有成员x

严格地说,private定义的私有成员,并不是真正意义的私有成员。一方面,编译成JavaScript后,private关键字就被剥离了,这时外部访问该成员就不会报错。另一方面,由于前一个原因,TypeScript对于访问private成员没有严格禁止,使用方括号写法([])或者in运算符,实例对象就能访问该成员。

class A {
  private x = 1;
}

const a = new A();
a['x'] // 1

if ('x' in a) { // 正确
  // ...
}

在这个例子中,A类的属性x是私有属性,但是实例使用方括号,就可以读取这个属性,或者使用in运算符检查这个属性是否存在,都可以正确执行。

由于private存在这些问题,加上它是ES2022标准发布前出台的,而ES2022引入了自己的私有成员写法#propName。因此建议不使用private,改用ES2022的写法,获得真正意义的私有成员。

class A {
  #x = 1;
}

const a = new A();
a['x'] // 报错

在这个例子中,采用了ES2022的私有成员写法(属性名前加#),TypeScript就正确识别了实例对象没有属性x,从而报错。

构造方法也可以是私有的,这就直接防止了使用new命令生成实例对象,只能在类的内部创建实例对象。

这时一般会有一个静态方法,充当工厂函数,强制所有实例都通过该方法生成。

class Singleton {
  private static instance?: Singleton;
  private constructor() {}

  static getInstance() {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton();
    }
    return Singleton.instance;
  }
}

const s = Singleton.getInstance();

在这个例子中,使用私有构造方法,实现了单例模式。想要获得Singleton的实例,不能使用new命令,只能使用getInstance()方法。

🟡protected

protected修饰符表示该成员是保护成员,只能在类的内部使用该成员,实例无法使用该成员,但是子类内部可以使用。

class A {
  protected x = 1;
}

class B extends A {
  getX() {
    return this.x;
  }
}

const a = new A();
const b = new B();

a.x // 报错
b.getX() // 1

在这个例子中,类A的属性x是保护成员,直接从实例读取该属性(a.x)会报错,但是子类B内部可以读取该属性。

子类不仅可以拿到父类的保护成员,还可以定义同名成员。

class A {
  protected x = 1;
}

class B extends A {
  x = 2;
}

在这个例子中,子类B定义了父类A的同名成员x,并且父类的x是保护成员,子类将其改成了公开成员。B类的x属性前面没有修饰符,等同于修饰符是public,外界可以读取这个属性。

在类的外部,实例对象不能读取保护成员,但是在类的内部可以。

class A {
  protected x = 1;

  f(obj: A) {
    console.log(obj.x);
  }
}

const a = new A();
a.x // 报错
a.f(a) // 1

在这个例子中,属性x是类A的保护成员,在类的外部,实例对象a拿不到这个属性。但是,实例对象a传入类A的内部,就可以从a拿到x

📝实例属性的简写形式

实际开发中,很多实例属性的值,是通过构造方法传入的。

class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

在这个例子中,属性xy的值是通过构造方法的参数传入的。

这样的写法等于对同一个属性要声明两次类型,一次在类的头部,另一次在构造方法的参数里面。这有些累赘,TypeScript就提供了一种简写形式。

class Point {
  constructor(
    public x: number,
    public y: number
  ) {}
}

const p = new Point(10, 10);
p.x // 10
p.y // 10

在这个例子中,构造方法的参数x前面有public修饰符,这时TypeScript就会自动声明一个公开属性x,不必在构造方法里面写任何代码,同时还会设置x的值为构造方法的参数值。注意,这里的public不能省略。

除了public修饰符,构造方法的参数名只要有privateprotectedreadonly修饰符,都会自动声明对应修饰符的实例属性。

class A {
  constructor(
    public a: number,
    protected b: number,
    private c: number,
    readonly d: number
  ) {}
}

// 编译结果
class A {
    a;
    b;
    c;
    d;
    constructor(a, b, c, d) {
      this.a = a;
      this.b = b;
      this.c = c;
      this.d = d;
    }
}

在这个例子中,从编译结果可以看到,构造方法的abcd会生成对应的实例属性。

readonly还可以与其他三个可访问性修饰符,一起使用。

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

🏗️顶层属性的处理方法

对于类的顶层属性,TypeScript早期的处理方法,与后来的ES2022标准不一致。这会导致某些代码的运行结果不一样。

类的顶层属性在TypeScript里面,有两种写法。

class User {
  // 写法一
  age = 25;
  // 写法二
  constructor(private currentYear: number) {}
}

在这个例子中,写法一是直接声明一个实例属性age,并初始化;写法二是顶层属性的简写形式,直接将构造方法的参数currentYear声明为实例属性。

TypeScript早期的处理方法是,先在顶层声明属性,但不进行初始化,等到运行构造方法时,再完成所有初始化。

class User {
  age = 25;
}

// TypeScript 的早期处理方法
class User {
  age: number;
  constructor() {
    this.age = 25;
  }
}

在这个例子中,TypeScript早期会先声明顶层属性age,然后等到运行构造函数时,再将其初始化为25

ES2022标准里面的处理方法是,先进行顶层属性的初始化,再运行构造方法。这在某些情况下,会使得同一段代码在TypeScript和JavaScript下运行结果不一致。

这种不一致一般发生在两种情况。第一种情况是,顶层属性的初始化依赖于其他实例属性。

class User {
  age = this.currentYear - 1998;

  constructor(private currentYear: number) {
    // 输出结果将不一致
    console.log('Current age:', this.age);
  }
}

const user = new User(2023);

在这个例子中,顶层属性age的初始化值依赖于实例属性this.currentYear。按照TypeScript的处理方法,初始化是在构造方法里面完成的,会输出结果为25。但是,按照ES2022标准的处理方法,初始化在声明顶层属性时就会完成,这时this.currentYear还等于undefined,所以age的初始化结果为NaN,因此最后输出的也是NaN

第二种情况与类的继承有关,子类声明的顶层属性在父类完成初始化。

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继承自AnimalHouse。它声明了顶层属性resident,但是该属性的初始化是在父类AnimalHouse完成的。不同的设置运行下面的代码,结果将不一致。

const dog = {
  animalStuff: 'animal',
  dogStuff: 'dog'
};

const dogHouse = new DogHouse(dog);
console.log(dogHouse.resident) // 输出结果将不一致

在这个例子中,TypeScript的处理方法,会使得resident属性能够初始化,所以输出参数对象的值。但是,ES2022标准的处理方法是,顶层属性的初始化先于构造方法的运行。这使得resident属性不会得到赋值,因此输出为undefined

为了解决这个问题,同时保证以前代码的行为一致,TypeScript从3.7版开始,引入了编译设置useDefineForClassFields。这个设置设为true,则采用ES2022标准的处理方法,否则采用TypeScript早期的处理方法。

它的默认值与target属性有关,如果输出目标设为ES2022或者更高,那么useDefineForClassFields的默认值为true,否则为false。关于这个设置的详细说明,参见官方3.7版本的发布说明

如果希望避免这种不一致,让代码在不同设置下的行为都一样,那么可以将所有顶层属性的初始化,都放到构造方法里面。

class User  {
  age: number;

  constructor(private currentYear: number) {
    this.age = this.currentYear - 1998;
    console.log('Current age:', this.age);
  }
}

const user = new User(2023);

在这个例子中,顶层属性age的初始化就放在构造方法里面,那么任何情况下,代码行为都是一致的。

对于类的继承,还有另一种解决方法,就是使用declare命令,去声明子类顶层属性的类型,告诉TypeScript这些属性的初始化由父类实现。

class DogHouse extends AnimalHouse {
  declare resident: Dog;

  constructor(dog: Dog) {
    super(dog);
  }
}

在这个例子中,resident属性的类型声明前面用了declare命令。这种情况下,这一行代码在编译成JavaScript后就不存在,那么也就不会有行为不一致,无论是否设置useDefineForClassFields,输出结果都是一样的。

🕹️静态成员

类的内部可以使用static关键字,定义静态成员。

静态成员是只能通过类本身使用的成员,不能通过实例对象使用。

class MyClass {
  static x = 0;
  static printX() {
    console.log(MyClass.x);
  }
}

MyClass.x // 0
MyClass.printX() // 0

在这个例子中,x是静态属性,printX()是静态方法。它们都必须通过MyClass获取,而不能通过实例对象调用。

static关键字前面可以使用publicprivateprotected修饰符。

class MyClass {
  private static x = 0;
}

MyClass.x // 报错

在这个例子中,静态属性x前面有private修饰符,表示只能在MyClass内部使用,如果在外部调用这个属性就会报错。

静态私有属性也可以用ES6语法的#前缀表示,上面示例可以改写如下。

class MyClass {
  static #x = 0;
}

publicprotected的静态成员可以被继承。

class A {
  public static x = 1;
  protected static y = 1;
}

class B extends A {
  static getY() {
    return B.y;
  }
}

B.x // 1
B.getY() // 1

在这个例子中,类A的静态属性xy都被B继承,公开成员x可以在B的外部获取,保护成员y只能在B的内部获取。

🎲泛型类

类也可以写成泛型,使用类型参数。关于泛型的详细介绍,请看《泛型》一章。

class Box<Type> {
  contents: Type;

  constructor(value: Type) {
    this.contents = value;
  }
}

const b: Box<string> = new Box('hello!');

在这个例子中,类Box有类型参数Type,因此属于泛型类。新建实例时,变量的类型声明需要带有类型参数的值,不过本例等号左边的Box<string>可以省略不写,因为可以从等号右边推断得到。

注意,静态成员不能使用泛型的类型参数。

class Box<Type> {
  static defaultContents: Type; // 报错
}

在这个例子中,静态属性defaultContents的类型写成类型参数Type会报错。因为这意味着调用时必须给出类型参数(即写成Box<string>.defaultContents),并且类型参数发生变化,这个属性也会跟着变,这并不是好的做法。

📜抽象类,抽象成员

TypeScript允许在类的定义前面,加上关键字abstract,表示该类不能被实例化,只能当作其他类的模板。这种类就叫做“抽象类”(abstract class)。

abstract class A {
  id = 1;
}

const a = new A(); // 报错

在这个例子中,直接新建抽象类的实例,会报错。

抽象类只能当作基类使用,用来在它的基础上定义子类。

abstract class A {
  id = 1;
}

class B extends A {
  amount = 100;
}

const b = new B();
b.id // 1
b.amount // 100

在这个例子中,A是一个抽象类,BA的子类,继承了A的所有成员,并且可以定义自己的成员和实例化。

抽象类的子类也可以是抽象类,也就是说,抽象类可以继承其他抽象类。

abstract class A {
  foo: number;
}

abstract class B extends A {
  bar: string;
}

抽象类的内部可以有已经实现好的属性和方法,也可以有还未实现的属性和方法。后者就叫做“抽象成员”(abstract member),即属性名和方法名有abstract关键字,表示该方法需要子类实现。如果子类没有实现抽象成员,就会报错。

abstract class A {
  abstract foo: string;
  bar: string = '';
}

class B extends A {
  foo = 'b';
}

在这个例子中,抽象类A定义了抽象属性foo,子类B必须实现这个属性,否则会报错。

下面是抽象方法的例子。如果抽象类的方法前面加上abstract,就表明子类必须给出该方法的实现。

abstract class A {
  abstract execute(): string;
}

class B extends A {
  execute() {
    return `B executed`;
  }
}

这里有几个注意点:

  1. 抽象成员只能存在于抽象类:不能存在于普通类。
  2. 抽象成员不能有具体实现的代码:已经实现好的成员前面不能加abstract关键字。
  3. 抽象成员前不能有private修饰符:否则无法在子类中实现该成员。
  4. 一个子类最多只能继承一个抽象类

总之,抽象类的作用是,确保各种相关的子类都拥有跟基类相同的接口,可以看作是模板。其中的抽象成员都是必须由子类实现的成员,非抽象成员则表示基类已经实现的、由所有子类共享的成员。

🔄this问题

类的方法经常用到this关键字,它表示该方法当前所在的对象。

class A {
  name = 'A';

  getName() {
    return this.name;
  }
}

const a = new A();
a.getName() // 'A'

const b = {
  name: 'b',
  getName: a.getName
};

b.getName() // 'b'

在这个例子中,变量abgetName()是同一个方法,但是执行结果不一样,原因就是它们内部的this指向不一样的对象。如果getName()在变量a上运行,this指向a;如果在b上运行,this指向b

有些场合需要给出this类型,但是JavaScript函数通常不带有this参数,这时TypeScript允许函数增加一个名为this的参数,放在参数列表的第一位,用来描述函数内部的this关键字的类型。

// 编译前
function fn(
  this: SomeType,
  x: number
) {
  /* ... */
}

// 编译后
function fn(x) {
  /* ... */
}

在这个例子中,函数fn()的第一个参数是this,用来声明函数内部的this的类型。编译时,TypeScript一旦发现函数的第一个参数名为this,则会去除这个参数,即编译结果不会带有该参数。

class A {
  name = 'A';

  getName(this: A) {
    return this.name;
  }
}

const a = new A();
const b = a.getName;
b() // 报错

在这个例子中,类AgetName()添加了this参数,如果直接调用这个方法,this的类型就会跟声明的类型不一致,从而报错。

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() {
    return function () {
      return this.width * this.height; // 报错
    };
  }
}

在这个例子中,getAreaFunction()方法返回一个函数,这个函数里面用到了this,但是这个thisRectangle这个类没关系,它的类型推断为any,所以就报错了。

在类的内部,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运算符的介绍详见《类型断言》一章。

🔗参考链接

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值