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 会为类中的每个属性显示一个错误。我们如何修复这个问题呢?
添加构造函数
为了修复这些错误,我们需要向类中添加一个 constructor。constructor 是一个特殊的方法,在创建类的新实例时运行。您可以在这里设置对象的初始状态。
首先,我们将添加一个构造函数,为 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.title、this.artist 和 this.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;
}
从上面没有错误可以看出,这也意味着它们不需要在构造函数中设置。
public 和 private 属性
public 和 private 关键字用于控制类属性的可见性和可访问性。
默认情况下,属性是 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 属性
除了 public 和 private 之外,还有第三个可见性修饰符叫做 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,并包含 title、artist、releaseYear 和 trackList 属性:
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() 来实例化该类。
然而,我们遇到了一些错误,因为我们期望它包含两个属性,即 x 和 y,每个属性的默认值为 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 方法,该方法将更新 x 和 y 属性到新的位置。
练习 2: 实现类方法
练习 3: 实现一个 Getter
让我们继续使用 CanvasNode 类,它现在有一个构造函数,接受一个可选参数,重命名为 position。这个 position 是一个对象,取代了我们之前单独的 x 和 y:
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 类已更新,以便 x 和 y 现在是私有属性:
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;
}
}
x 和 y 属性前面的 # 表示它们是私有的,不能在类外部直接修改。此外,当存在 getter 而没有 setter 时,其属性也将被视为只读,如此测试用例所示:
// const canvasNode = new CanvasNode(); // 假设已正确实现 getter
// canvasNode.position = { x: 10, y: 20 };
// 无法赋值给 "position",因为它是只读属性。2540
你的任务是为 position 属性编写一个 setter,以便测试用例能够通过。
练习 4: 实现一个 Setter
练习 5: 扩展一个类
这里有一个更复杂版本的 CanvasNode 类。
除了 x 和 y 属性,该类现在还有一个 viewMode 属性,其类型为 ViewMode,可以设置为 hidden、visible 或 selected:
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 类,它只需要 x 和 y 属性以及移动的能力。它不需要 viewMode 属性或与之相关的逻辑。
你的任务是重构 CanvasNode 类,将 x 和 y 属性分离到一个名为 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() 将 x 和 y 属性传递给 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 独有特性
](/books/total-typescript-essentials/typescript-only-features)