TypeScript 中的类

9 阅读12分钟

TypeScript 中的类

探索 TypeScript 类:构造函数、属性、继承、方法和抽象类。

8

类是 JavaScript 的一项特性,可帮助您将数据和行为封装到单个单元中。它们是面向对象编程的基础组成部分,用于创建具有属性和方法的对象。

您可以使用 class 关键字定义一个类,然后使用 new 关键字创建该类的实例。TypeScript 为类添加了一个静态类型检查层,这可以帮助您捕获错误并在代码中强制执行结构。

让我们从头开始构建一个类,看看它是如何工作的。

创建类

要创建一个类,您需要使用 class 关键字,后跟类名。与类型和接口类似,约定俗成的做法是使用帕斯卡命名法(PascalCase)为类命名,即名称中每个单词的首字母都大写。

我们将以类似于创建类型或接口的方式开始创建 Album 类:

class Album {
  title: string;
// 属性“title”没有初始值设定项,且未在构造函数中明确赋值。2564
  artist: string;
// 属性“artist”没有初始值设定项,且未在构造函数中明确赋值。2564
  releaseYear: number;
// 属性“releaseYear”没有初始值设定项,且未在构造函数中明确赋值。2564
}

此时,尽管它看起来像一个类型或接口,但 TypeScript 会为类中的每个属性显示一个错误。我们如何修复这个问题呢?

添加构造函数

为了修复这些错误,我们需要向类中添加一个 constructorconstructor 是一个特殊的方法,在创建类的新实例时运行。您可以在这里设置对象的初始状态。

首先,我们将添加一个构造函数,为 Album 类的属性赋值:

class Album {
  title: string;
  artist: string;
  releaseYear: number;

  constructor() {
    this.title = "Loop Finding Jazz Records";
    this.artist = "Jan Jelinek";
    this.releaseYear = 2001;
  }
}

现在,当我们创建 Album 类的新实例时,我们可以访问在构造函数中设置的属性和值:

const loopFindingJazzRecords = new Album();
console.log(loopFindingJazzRecords.title); // 输出: Loop Finding Jazz Records

new 关键字创建了 Album 类的一个新实例,构造函数设置了我们类属性的初始值。在这种情况下,由于属性是硬编码的,Album 类的每个实例都将具有相同的值。

并非总是需要为类属性指定类型

正如我们将看到的,TypeScript 在处理类时能够进行一些非常智能的推断。它能够从我们在构造函数中赋值的地方推断出属性的类型,所以我们实际上可以省略一些类型注解:

class Album {
  title;
  artist;
  releaseYear;

  constructor() {
    this.title = "Loop Finding Jazz Records"; // TypeScript 推断 title 为 string
    this.artist = "Jan Jelinek";         // TypeScript 推断 artist 为 string
    this.releaseYear = 2001;            // TypeScript 推断 releaseYear 为 number
  }
}

然而,通常情况下,我们还是会在类主体中指定类型,因为它们可以作为类的一种快速阅读的文档形式。

向构造函数添加参数

我们可以使用构造函数为类声明参数。这允许我们在创建类的新实例时传入值。

更新构造函数以接受一个 opts 参数,该参数包含 Album 类的属性:

// 在 Album 类内部
constructor(opts: { title: string; artist: string; releaseYear: number }) {
 // ...
}

然后在构造函数的主体内部,我们将 this.titlethis.artistthis.releaseYear 赋值为 opts 参数的值。

// 在 Album 类内部
constructor(opts: { title: string; artist: string; releaseYear: number }) {
  this.title = opts.title;
  this.artist = opts.artist;
  this.releaseYear = opts.releaseYear;
}

this 关键字指向类的实例,用于访问类的属性和方法。

现在,当我们创建 Album 类的新实例时,我们可以传入一个包含我们想要设置的属性的对象。

const loopFindingJazzRecords = new Album({
  title: "Loop Finding Jazz Records",
  artist: "Jan Jelinek",
  releaseYear: 2001,
});
console.log(loopFindingJazzRecords.title); // 输出: Loop Finding Jazz Records

将类用作类型

TypeScript 中类的一个有趣特性是它们可以用作变量和函数参数的类型。语法类似于使用任何其他类型或接口。

在这种情况下,我们将使用 Album 类来类型化 printAlbumInfo 函数的 album 参数:

function printAlbumInfo(album: Album) {
  console.log(
    `${album.title} by ${album.artist}, released in ${album.releaseYear}.`,
  );
}

然后我们可以调用该函数并传入 Album 类的实例:

printAlbumInfo(sixtyNineLoveSongsAlbum);
// 输出: 69 Love Songs by The Magnetic Fields, released in 1999.

虽然可以将类用作类型,但更常见的模式是要求类实现特定的接口。

类中的属性

现在我们已经了解了如何创建类并创建它的新实例,让我们更仔细地看看属性是如何工作的。

类属性初始化器

您可以直接在类主体中为属性设置默认值。这些被称为类属性初始化器。

class Album {
  title = "Unknown Album";
  artist = "Unknown Artist";
  releaseYear = 0;
}

您可以将它们与类型注解结合使用:

class Album {
  title: string = "Unknown Album";
  artist: string = "Unknown Artist";
  releaseYear: number = 0;
}

重要的是,类属性初始化器在构造函数被调用之前解析。这意味着您可以通过在构造函数中赋一个不同的值来覆盖默认值:

class User {
  name = "Unknown User";
  constructor() {
    this.name = "Matt Pocock";
  }
}

const user = new User();
console.log(user.name); // 输出: Matt Pocock

readonly 类属性

正如我们对类型和接口所见,readonly 关键字可用于使属性不可变。这意味着一旦属性被设置,它就不能被更改:

class Album {
  readonly title: string;
  readonly artist: string;
  readonly releaseYear: number;
  // 构造函数中必须初始化这些只读属性
  constructor(opts: { title: string; artist: string; releaseYear: number }) {
    this.title = opts.title;
    this.artist = opts.artist;
    this.releaseYear = opts.releaseYear;
  }
}

可选的类属性

我们也可以像对象一样将属性标记为可选,使用 ?: 注解:

class Album {
  title?: string;
  artist?: string;
  releaseYear?: number;
}

从上面没有错误可以看出,这也意味着它们不需要在构造函数中设置。

publicprivate 属性

publicprivate 关键字用于控制类属性的可见性和可访问性。

默认情况下,属性是 public 的,这意味着可以从类外部访问它们。

如果我们想限制对某些属性的访问,可以将它们标记为 private。这意味着它们只能从类本身内部访问。

例如,假设我们想给专辑类添加一个 rating 属性,该属性只在类内部使用:

class Album {
  private rating = 0;
}

现在,如果我们尝试从类外部访问 rating 属性,TypeScript 会给我们一个错误:

// const loopFindingJazzRecords = new Album(/* ... */); // 假设已实例化
// console.log(loopFindingJazzRecords.rating);
// 属性“rating”为私有属性,只能在类“Album”中访问。2341

然而,这实际上并不能阻止它在运行时被访问——private 只是一个编译时注解。你可以使用 @ts-ignore(我们稍后会看到)来抑制错误,并且仍然可以访问该属性:

// @ts-ignore
// console.log(loopFindingJazzRecords.rating); // 输出: 0 (如果已实例化)
运行时私有属性

要在运行时获得相同的行为,你还可以使用 # 前缀将属性标记为私有:

class Album {
  #rating = 0;
}

# 语法的行为与 private 相同,但它是一个较新的特性,是 ECMAScript 标准的一部分。这意味着它既可以在 JavaScript 中使用,也可以在 TypeScript 中使用。

尝试从类外部访问以 # 为前缀的属性将导致语法错误:

// const loopFindingJazzRecords = new Album(/* ... */); // 假设已实例化
// console.log(loopFindingJazzRecords.#rating); // SyntaxError
// 属性 '#rating' 在类 'Album' 外部不可访问,因为它具有私有标识符。18013

试图通过动态字符串访问它来“作弊”将返回 undefined —— 并且仍然会给出 TypeScript 错误。

// const loopFindingJazzRecords = new Album(/* ... */); // 假设已实例化
// console.log(loopFindingJazzRecords["#rating"]); // 输出: undefined
// 元素隐式具有 "any" 类型,因为类型为 ""#rating"" 的表达式不能用于索引类型 "Album"。
//   属性 "#rating" 在类型 "Album" 上不存在。7053

因此,如果你想确保一个属性是真正私有的,你应该使用 # 语法。

类方法

除了属性之外,类还可以包含方法。这些函数有助于表达类的行为,并可用于与公共和私有属性交互。

实现类方法

让我们向 Album 类添加一个 printAlbumInfo 方法,该方法将记录专辑的标题、艺术家和发行年份。

有几种向类添加方法的技术。

第一种是遵循与构造函数相同的模式,直接将方法添加到类主体中:

// 在 Album 类内部
class Album {
  title: string;
  artist: string;
  releaseYear: number;

  constructor(opts: { title: string; artist: string; releaseYear: number }) {
    this.title = opts.title;
    this.artist = opts.artist;
    this.releaseYear = opts.releaseYear;
  }

  printAlbumInfo() {
    console.log(`${this.title} by ${this.artist}, released in ${this.releaseYear}.`);
  }
}

另一种选择是使用箭头函数来定义方法:

// 在 Album 类内部
class Album {
  title: string;
  artist: string;
  releaseYear: number;

  constructor(opts: { title: string; artist: string; releaseYear: number }) {
    this.title = opts.title;
    this.artist = opts.artist;
    this.releaseYear = opts.releaseYear;
  }

  printAlbumInfo = () => {
    console.log(
      `${this.title} by ${this.artist}, released in ${this.releaseYear}.`,
    );
  };
}

一旦添加了 printAlbumInfo 方法,我们就可以调用它来记录专辑的信息:

const loopFindingJazzRecords = new Album({
  title: "Loop Finding Jazz Records",
  artist: "Jan Jelinek",
  releaseYear: 2001,
});
loopFindingJazzRecords.printAlbumInfo();
// 输出: Loop Finding Jazz Records by Jan Jelinek, released in 2001.
箭头函数还是类方法?

箭头函数和类方法在行为上确实有所不同。区别在于 this 的处理方式。

这是运行时的 JavaScript 行为,所以略微超出了本书的范围。但为了提供帮助,这里有一个例子:

class MyClass {
  location = "Class";

  arrow = () => {
    console.log("arrow", this);
  };

  method() {
    console.log("method", this);
  }
}

const myInstance = new MyClass();
const myObj = {
  location: "Object",
  arrow: myInstance.arrow, // this 指向 MyClass 实例
  method: myInstance.method, // this 指向调用它的对象 (myObj)
};

myObj.arrow(); // arrow { location: 'Class', arrow: [Function: arrow], method: [Function: method] }
myObj.method(); // method { location: 'Object', arrow: [Function: arrow], method: [Function: method] }

arrow 方法中,this 绑定到定义它的类的实例。在 method 方法中,this 绑定到调用它的对象。

这在处理类时可能是一个容易出错的地方,无论是在 JavaScript 还是 TypeScript 中。

类继承

与我们可以扩展类型和接口类似,我们也可以在 TypeScript 中扩展类。这允许您创建类的层次结构,这些类可以相互继承属性和方法,使您的代码更有组织性和可重用性。

对于这个例子,我们将回到我们的基本 Album 类,它将作为我们的基类:

class Album {
  title: string;
  artist: string;
  releaseYear: number;

  constructor(opts: { title: string; artist: string; releaseYear: number }) {
    this.title = opts.title; // 注意:原文这里是 title,应为 opts.title
    this.artist = opts.artist; // 注意:原文这里是 artist,应为 opts.artist
    this.releaseYear = opts.releaseYear; // 注意:原文这里是 releaseYear,应为 opts.releaseYear
  }

  displayInfo() {
    console.log(
      `${this.title} by ${this.artist}, released in ${this.releaseYear}.`,
    );
  }
}

目标是创建一个 SpecialEditionAlbum 类,它扩展 Album 类并添加一个 bonusTracks 属性。

扩展类

第一步是使用 extends 关键字创建 SpecialEditionAlbum 类:

class SpecialEditionAlbum extends Album {}

一旦添加了 extends 关键字,添加到 SpecialEditionAlbum 类的任何新属性或方法都将是在它从 Album 类继承的内容之外的。例如,我们可以向 SpecialEditionAlbum 类添加一个 bonusTracks 属性:

class SpecialEditionAlbum extends Album {
  bonusTracks: string[];
// 属性“bonusTracks”没有初始值设定项,且未在构造函数中明确赋值。2564
}

接下来,我们需要添加一个构造函数,它包括 Album 类的所有属性以及 bonusTracks 属性。在扩展类时,关于构造函数有几点需要注意。

首先,构造函数的参数应与父类中使用的形状匹配。在这种情况下,那是一个 opts 对象,包含 Album 类的属性以及新的 bonusTracks 属性。

其次,我们需要包含对 super() 的调用。这是一个特殊的方法,它调用父类的构造函数并设置它定义的属性。这对于确保基类属性正确初始化至关重要。我们将把 opts 传递给 super() 方法,然后设置 bonusTracks 属性:

class SpecialEditionAlbum extends Album {
  bonusTracks: string[];

  constructor(opts: {
    title: string;
    artist: string;
    releaseYear: number;
    bonusTracks: string[];
  }) {
    super(opts); // 将 opts 传递给父类 Album 的构造函数
    this.bonusTracks = opts.bonusTracks;
  }
}

现在我们已经设置好了 SpecialEditionAlbum 类,我们可以像创建 Album 类实例一样创建新实例:

const plasticOnoBandSpecialEdition = new SpecialEditionAlbum({
  title: "Plastic Ono Band",
  artist: "John Lennon",
  releaseYear: 2000,
  bonusTracks: ["Power to the People", "Do the Oz"],
});

这种模式可用于向 SpecialEditionAlbum 类添加更多方法、属性和行为,同时仍保留 Album 类的属性和方法。

protected 属性

除了 publicprivate 之外,还有第三个可见性修饰符叫做 protected。这类似于 private,但它允许从扩展该类的子类内部访问该属性。

例如,如果我们想将 Album 类的 title 属性设为 protected,可以这样做:

class Album {
  protected title: string;
  // ...
  artist: string; // 假设这些仍然是 public 或需要相应调整
  releaseYear: number;

  constructor(opts: { title: string; artist: string; releaseYear: number }) {
    this.title = opts.title;
    this.artist = opts.artist;
    this.releaseYear = opts.releaseYear;
  }
}

现在,title 属性可以从 SpecialEditionAlbum 类内部访问,但不能从类外部访问。

使用 override 进行安全重写

当扩展类并试图在子类中重写方法时,可能会遇到麻烦。假设我们的 Album 类实现了一个 displayInfo 方法:

class Album {
  title: string;
  artist: string;
  releaseYear: number;

  constructor(opts: { title: string; artist: string; releaseYear: number }) {
    this.title = opts.title;
    this.artist = opts.artist;
    this.releaseYear = opts.releaseYear;
  }
  // ...
  displayInfo() {
    console.log(
      `${this.title} by ${this.artist}, released in ${this.releaseYear}.`,
    );
  }
}

并且我们的 SpecialEditionAlbum 类也实现了一个 displayInfo 方法:

class SpecialEditionAlbum extends Album {
  bonusTracks: string[];

  constructor(opts: {
    title: string;
    artist: string;
    releaseYear: number;
    bonusTracks: string[];
  }) {
    super(opts);
    this.bonusTracks = opts.bonusTracks;
  }
  // ...
  displayInfo() {
    console.log(
      `${this.title} by ${this.artist}, released in ${this.releaseYear}.`,
    ); // 注意:this.title, this.artist, this.releaseYear 若在 Album 中为 protected 则可访问
    console.log(`Bonus tracks: ${this.bonusTracks.join(", ")}`);
  }
}

这会覆盖 Album 类中的 displayInfo 方法,为附赠曲目添加额外的日志。

但是,如果我们将 Album 中的 displayInfo 方法更改为 displayAlbumInfo 会发生什么?SpecialEditionAlbum 不会自动更新,其重写将不再起作用。

为了防止这种情况,你可以在子类中使用 override 关键字来表明你正在有意地重写父类的方法:

class SpecialEditionAlbum extends Album {
  bonusTracks: string[];

  constructor(opts: {
    title: string;
    artist: string;
    releaseYear: number;
    bonusTracks: string[];
  }) {
    super(opts);
    this.bonusTracks = opts.bonusTracks;
  }
  // ...
  override displayInfo() {
    console.log(
      `${this.title} by ${this.artist}, released in ${this.releaseYear}.`,
    );
    console.log(`Bonus tracks: ${this.bonusTracks.join(", ")}`);
  }
}

现在,如果 Album 类中的 displayInfo 方法被更改,TypeScript 将在 SpecialEditionAlbum 类中给出一个错误,让你知道该方法不再被重写。

你也可以通过在 tsconfig.json 文件中将 noImplicitOverride 设置为 true 来强制执行此操作。这将强制你在重写方法时始终指定 override

{
  "compilerOptions": {
    "noImplicitOverride": true
  }
}

implements 关键字

在某些情况下,您希望强制类遵守特定的结构。为此,您可以使用 implements 关键字。

我们在前一个示例中创建的 SpecialEditionAlbum 类向 Album 类添加了一个 bonusTracks 属性,但常规 Album 类没有 trackList 属性。

让我们创建一个接口,以强制任何实现它的类都必须具有 trackList 属性。

我们将接口命名为 IAlbum,并包含 titleartistreleaseYeartrackList 属性:

interface IAlbum {
  title: string;
  artist: string;
  releaseYear: number;
  trackList: string[];
}

注意,I 前缀用于表示接口,而 T 表示类型。虽然不是必须使用这些前缀,但这是一种常见的约定,称为匈牙利命名法,它在阅读代码时能更清晰地表明接口的用途。我不建议为你所有的接口和类型都这样做——仅当它们与同名类冲突时。

创建接口后,我们可以使用 implements 关键字将其与 Album 类关联起来。

class Album implements IAlbum {
// 类“Album”错误地实现了接口“IAlbum”。
//   类型“Album”中缺少属性“trackList”,但类型“IAlbum”中需要该属性。2420
  title: string;
  artist: string;
  releaseYear: number;

  constructor(opts: { title: string; artist: string; releaseYear: number }) {
    this.title = opts.title;
    this.artist = opts.artist;
    this.releaseYear = opts.releaseYear;
  }
}

由于 Album 类中缺少 trackList 属性,TypeScript 现在会给我们一个错误。为了修复它,需要将 trackList 属性添加到 Album 类中。一旦添加了该属性,我们就可以相应地更新接口或设置 getter 和 setter:

class Album implements IAlbum {
  title: string;
  artist: string;
  releaseYear: number;
  trackList: string[]; // 添加了 trackList

  constructor(opts: {
    title: string;
    artist: string;
    releaseYear: number;
    trackList: string[]; // 构造函数也需要更新
  }) {
    this.title = opts.title;
    this.artist = opts.artist;
    this.releaseYear = opts.releaseYear;
    this.trackList = opts.trackList; // 初始化 trackList
  }
  // ...
}

这使我们能够为 Album 类定义一个契约,从而强制执行类的结构并帮助及早发现错误。

抽象类

另一种可用于定义基类的模式是 abstract 关键字。抽象类模糊了类型和运行时之间的界限。你可以像这样声明一个抽象类:

abstract class AlbumBase {}

然后你可以在它上面定义方法和行为,就像一个常规类一样:

abstract class AlbumBase {
  title: string;
  artist: string;
  releaseYear: number;
  trackList: string[] = [];

  constructor(opts: { title: string; artist: string; releaseYear: number }) {
    this.title = opts.title;
    this.artist = opts.artist;
    this.releaseYear = opts.releaseYear;
  }

  addTrack(track: string) {
    this.trackList.push(track);
  }
}

但是如果你尝试创建 AlbumBase 类的实例,TypeScript 会给你一个错误:

// const albumBase = new AlbumBase({
// 不能创建抽象类的实例。2511
//   title: "Unknown Album",
//   artist: "Unknown Artist",
//   releaseYear: 0,
// });

相反,你需要创建一个扩展 AlbumBase 类的类:

class Album extends AlbumBase {
  // 你想要的任何额外功能
}

const album = new Album({
  title: "Unknown Album",
  artist: "Unknown Artist",
  releaseYear: 0,
});

你会注意到这个想法类似于实现接口 —— 不同之处在于抽象类还可以包含实现细节。

这意味着你可以稍微模糊类型和运行时之间的界限。你可以为一个类定义一个类型契约,但使其更具可重用性。

抽象方法

在我们的抽象类上,我们可以在方法前使用 abstract 关键字来表明任何扩展该抽象类的类都必须实现该方法:

abstract class AlbumBase {
  // ...其他属性和方法
  abstract addReview(author: string, review: string): void;
}

现在,任何扩展 AlbumBase 的类都必须实现 addReview 方法:

class Album extends AlbumBase {
  // ...其他属性和方法 (如构造函数等)
  constructor(opts: { title: string; artist: string; releaseYear: number }) {
    super(opts);
  }

  addReview(author: string, review: string) {
    // ...实现
    console.log(`Review by ${author}: ${review}`);
  }
}

这为我们提供了另一种工具来表达类的结构,并确保它们遵守特定的契约。

练习

练习 1: 创建一个类

这里我们有一个名为 CanvasNode 的类,它目前的功能与空对象完全相同:

class CanvasNode {}

在一个测试用例中,我们通过调用 new CanvasNode() 来实例化该类。

然而,我们遇到了一些错误,因为我们期望它包含两个属性,即 xy,每个属性的默认值为 0

it("Should store some basic properties", () => {
  const canvasNode = new CanvasNode();
  expect(canvasNode.x).toEqual(0); // 属性“x”在类型“CanvasNode”上不存在。2339
  expect(canvasNode.y).toEqual(0); // 属性“y”在类型“CanvasNode”上不存在。2339

  // @ts-expect-error Property is readonly (属性是只读的)
  canvasNode.x = 10;
  // @ts-expect-error Property is readonly (属性是只读的)
  canvasNode.y = 20;
});

@ts-expect-error 指令可以看出,我们还期望这些属性是只读的。

你的挑战是实现 CanvasNode 类以满足这些要求。作为额外的练习,请分别使用和不使用构造函数来解决这个挑战。

练习 1: 创建一个类

练习 2: 实现类方法

在这个练习中,我们简化了 CanvasNode 类,使其不再具有只读属性:

class CanvasNode {
  x = 0;
  y = 0;
}

有一个测试用例用于测试是否能够将 CanvasNode 对象移动到新位置:

it("Should be able to move to a new location", () => {
  const canvasNode = new CanvasNode();
  expect(canvasNode.x).toEqual(0);
  expect(canvasNode.y).toEqual(0);

  canvasNode.move(10, 20); // 属性“move”在类型“CanvasNode”上不存在。2339

  expect(canvasNode.x).toEqual(10);
  expect(canvasNode.y).toEqual(20);
});

目前,在 move 方法调用下有一个错误,因为 CanvasNode 类没有 move 方法。

你的任务是向 CanvasNode 类添加一个 move 方法,该方法将更新 xy 属性到新的位置。

练习 2: 实现类方法

练习 3: 实现一个 Getter

让我们继续使用 CanvasNode 类,它现在有一个构造函数,接受一个可选参数,重命名为 position。这个 position 是一个对象,取代了我们之前单独的 xy

class CanvasNode {
  x: number;
  y: number;

  constructor(position?: { x: number; y: number }) {
    this.x = position?.x ?? 0;
    this.y = position?.y ?? 0;
  }

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

在这些测试用例中,访问 position 属性时会出错,因为它当前不是 CanvasNode 类的属性:

it("Should be able to move", () => {
  const canvasNode = new CanvasNode();
  expect(canvasNode.position).toEqual({ x: 0, y: 0 }); // 属性“position”在类型“CanvasNode”上不存在。2339

  canvasNode.move(10, 20);
  expect(canvasNode.position).toEqual({ x: 10, y: 20 }); // 属性“position”在类型“CanvasNode”上不存在。2339
});

it("Should be able to receive an initial position", () => {
  const canvasNode = new CanvasNode({
    x: 10,
    y: 20,
  });
  expect(canvasNode.position).toEqual({ x: 10, y: 20 }); // 属性“position”在类型“CanvasNode”上不存在。2339
});

你的任务是更新 CanvasNode 类,使其包含一个 position getter,以便测试用例能够通过。

练习 3: 实现一个 Getter

练习 4: 实现一个 Setter

CanvasNode 类已更新,以便 xy 现在是私有属性:

class CanvasNode {
  #x: number;
  #y: number;

  constructor(position?: { x: number; y: number }) {
    this.#x = position?.x ?? 0;
    this.#y = position?.y ?? 0;
  }

  // 你的 `position` getter 方法在这里

  // move 方法和以前一样
  move(x: number, y: number) {
    this.#x = x;
    this.#y = y;
  }
}

xy 属性前面的 # 表示它们是私有的,不能在类外部直接修改。此外,当存在 getter 而没有 setter 时,其属性也将被视为只读,如此测试用例所示:

// const canvasNode = new CanvasNode(); // 假设已正确实现 getter
// canvasNode.position = { x: 10, y: 20 };
// 无法赋值给 "position",因为它是只读属性。2540

你的任务是为 position 属性编写一个 setter,以便测试用例能够通过。

练习 4: 实现一个 Setter

练习 5: 扩展一个类

这里有一个更复杂版本的 CanvasNode 类。

除了 xy 属性,该类现在还有一个 viewMode 属性,其类型为 ViewMode,可以设置为 hiddenvisibleselected

type ViewMode = "hidden" | "visible" | "selected";

class CanvasNode {
  x = 0;
  y = 0;
  viewMode: ViewMode = "visible";

  constructor(options?: { x: number; y: number; viewMode?: ViewMode }) {
    this.x = options?.x ?? 0;
    this.y = options?.y ?? 0;
    this.viewMode = options?.viewMode ?? "visible";
  }

  /* getter, setter, 和 move 方法和以前一样 */
  get position() {
    return { x: this.x, y: this.y };
  }

  set position(pos: { x: number; y: number }) {
    this.x = pos.x;
    this.y = pos.y;
  }

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

想象一下,如果我们的应用程序有一个 Shape 类,它只需要 xy 属性以及移动的能力。它不需要 viewMode 属性或与之相关的逻辑。

你的任务是重构 CanvasNode 类,将 xy 属性分离到一个名为 Shape 的单独类中。然后,CanvasNode 类应该扩展 Shape 类,添加 viewMode 属性和与之相关的逻辑。

如果你愿意,可以使用 abstract 类来定义 Shape

练习 5: 扩展一个类

解决方案 1: 创建一个类

这是一个 CanvasNode 类的示例,它带有一个满足要求的构造函数:

class CanvasNode {
  readonly x: number;
  readonly y: number;

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

如果不使用构造函数,可以通过直接赋值属性来实现 CanvasNode 类:

class CanvasNode {
  readonly x = 0;
  readonly y = 0;
}

解决方案 2: 实现类方法

move 方法可以作为常规方法或箭头函数来实现:

这是常规方法:

class CanvasNode {
  x = 0;
  y = 0;

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

这是箭头函数:

class CanvasNode {
  x = 0;
  y = 0;

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

正如前面部分所讨论的,使用箭头函数更安全,以避免 this 的问题。

解决方案 3: 实现一个 Getter

以下是如何更新 CanvasNode 类以包含 position 属性的 getter:

class CanvasNode {
  x: number;
  y: number;

  constructor(position?: { x: number; y: number }) {
    this.x = position?.x ?? 0;
    this.y = position?.y ?? 0;
  }

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

  get position() {
    return { x: this.x, y: this.y };
  }
}

有了 getter 之后,测试用例就会通过。

请记住,使用 getter 时,您可以像访问类实例上的常规属性一样访问该属性:

const canvasNode = new CanvasNode();
console.log(canvasNode.position.x); // 0
console.log(canvasNode.position.y); // 0

解决方案 4: 实现一个 Setter

以下是如何向 CanvasNode 类添加 position setter:

class CanvasNode {
  #x: number; // 假设这些是私有的,如练习描述
  #y: number; // 假设这些是私有的,如练习描述

  constructor(position?: { x: number; y: number }) {
    this.#x = position?.x ?? 0;
    this.#y = position?.y ?? 0;
  }

  get position() {
    return { x: this.#x, y: this.#y };
  }
  // 在 CanvasNode 类内部
  set position(pos: { x: number; y: number }) { // 根据 getter 推断类型,也可显式声明
    this.#x = pos.x;
    this.#y = pos.y;
  }

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

注意,我们不必为 pos 参数添加类型,因为 TypeScript 足够智能,可以根据 getter 的返回类型推断它。

解决方案 5: 扩展一个类

新的 Shape 类看起来会与原始的 CanvasNode 类非常相似:

abstract class Shape { // 可以设为 abstract,也可以是普通类
  #x: number;
  #y: number;

  constructor(options?: { x: number; y: number }) {
    this.#x = options?.x ?? 0;
    this.#y = options?.y ?? 0;
  }

  get position() {
    return { x: this.#x, y: this.#y };
  }

  set position(pos: { x: number; y: number }) {
    this.#x = pos.x;
    this.#y = pos.y;
  }

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

然后 CanvasNode 类将扩展 Shape 类并添加 viewMode 属性。构造函数也将更新以接受 viewMode 并调用 super()xy 属性传递给 Shape 类:

type ViewMode = "hidden" | "visible" | "selected";

class CanvasNode extends Shape {
  #viewMode: ViewMode; // 可以设为私有,或 public

  constructor(options?: { x?: number; y?: number; viewMode?: ViewMode }) { // x, y 变为可选
    super(options); // 传递 options 或 options 中提取的 x, y
    this.#viewMode = options?.viewMode ?? "visible";
  }

  // 如果需要,可以添加 viewMode 的 getter/setter
  get viewMode() {
    return this.#viewMode;
  }

  set viewMode(mode: ViewMode) {
    this.#viewMode = mode;
  }
}

想成为 TypeScript 高手吗?

解锁专业精华版

TypeScript Pro Essentials

查看原文上一章 可变性

[

下一章 TypeScript 独有特性

](/books/total-typescript-essentials/typescript-only-features)