何时使用TypeScript抽象类的教程

1,382 阅读13分钟

如果你刚开始接触面向对象编程,abstract 类的概念可能看起来有点陌生。抽象是面向对象编程的关键思想之一,而抽象类是OO提供的实现抽象的两个工具之一。

在这篇文章中,我们将了解所有关于抽象、抽象类以及在你的项目中使用它们的日常用例。

让我们从理解抽象开始:也许是面向对象编程中最关键的概念。

什么是抽象?

一般来说,抽象是一种技术,可以最大限度地减少复杂性,帮助我们解决更高级的架构挑战。抽象消除了预先实现所有低层次细节的需要;相反,我们可以专注于高层次的东西,并在以后弄清具体细节。

你也可以说,抽象允许我们花更多的时间在声明性上(要求我们想要的东西),而减少在命令性上(具体说明它如何工作)。

凝结与抽象

在面向对象编程语言中,我们主要处理两种类型的类:具体类和抽象类。

具体类是真实的,我们可以使用new 关键字从它们中构造对象,以便在运行时使用。它们是我们大多数人在刚开始学习面向对象编程时最终要使用的东西。

// Concrete class
class User {
  private name: string;

  public getName (): string {
    return this.name;
  }

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

const user = new User('Khalil'); // Creating an instance 
                                 // of a concrete class

另一方面,抽象是蓝图契约,它规定了一个具体事物应该具有的属性和方法。我们可以用它们来契约化一个对象或一个类的有效结构。

// Interface (abstraction)
interface Box {
  length: number;
  width: number;
}

const boxOne: Box = { length: 1, width: 2 }; // ✅ Valid! Has all props
const boxTwo: Box = { length: 1 };           // ❌ Not valid, missing prop
// Interface (abstraction)
interface Box {
  length: number;
  width: number;
}

// Concrete class implementing Box abstraction
class MobileBox implements Box { // ✅ Valid! Implements all necessary props
  public length: number;  
  public width: number;

  constructor (length: number, width: number) {
    this.length = length;
    this.width = width;
  }
}

let boxThree = new MobileBox(1, 2); 

如何在OO语言中创建抽象

在前面的例子中,我们使用interface 关键字来创建抽象。然而,当我们在面向对象编程中谈到抽象时,我们一般指的是两种工具中的一种。

  • 接口(即:interface 关键字)或
  • 抽象类(即:在类的前面加上abstract 关键字)。

类型系统作为一种抽象化工具。我们还应该注意到,如果我们使用一种具有类型系统的语言(如TypeScript),我们可以使用类型来实现抽象。

为什么抽象是必要的?

抽象之所以重要,有许多不同的原因,但关键的原因是它允许我们创建一个插件架构,将高层和低层分开。

在传统的结构化编程中,我们无法安全地实现所谓的动态绑定--能够引用一个抽象的东西(不具体存在的东西),并确信这个依赖会与我们的代码一起工作。

例如,想象一下,我们正在为一个使USB端口工作的操作系统编写代码。我们需要确保每一个插入的设备都能正常工作,但在现实中,有很多不同的东西可以被插入。我们应该怎么做?为每一个东西写一些代码?

switch (deviceType) {
  case 'MOUSE':
		mouseSubroutine();
	case 'HEADPHONES':
		headphonesSubroutine();
	case 'MIDI_KEYBOARD':
		midiKeyboardSubroutine();
	case 'WEBCAM':
		webcamSubroutine();
	...
}

那是不可能的。当有一种新的USB设备出现时,会发生什么?来写更多的代码吗?不,这在架构上是很糟糕的。如果没有抽象,随着时间的推移,我们增加对更多设备的支持,我们将继续增加USB端口代码的循环复杂性。我们会看到我们的简单程序被炸成一个巨大的、不断增长的子程序上的控制流图。

如果有一种方法可以颠覆这种依赖关系呢?如果我们能专注于定义一个契约,其中包含USB设备需要提供的所有必要的东西,如果他们想让它与操作系统一起工作的话--并让未来的USB设备开发者来实现它们,那会怎么样?

我们在问:如果我们可以针对一个抽象概念而不是一个实现(具体化)进行编程,会怎么样?

我们可以!端口适配器的概念可能是理解这一现象的最简单方法。把抽象看作是定义了有效的适配器的端口。通过这种设计,我们创建了一个插件架构,使我们不再需要需要定义每一个最后的USB设备,而把它留给未来的开发者来实现基于我们契约的兼容适配器

抽象与以下思想有很深的关系。

  • Liskov替代原则--因为我们定义了有效的子类型,所以只要实现了契约,每个实现都应该是有效的,可以互换的。

  • 依赖性反转--我们不直接依赖具体化;相反,我们依赖具体化所依赖的抽象;这使我们的核心代码保持可测试的单元。

  • 控制反转--我们可以通过在钩子点上将程序控制权反转给客户开发者,让客户开发者有能力定制行为。

接下来,让我们看看在TypeScript中创建和使用抽象类背后的机制。

TypeScript中的抽象类

TypeScript中的抽象类最常见的用途是找到一些共同的行为,在相关的子类中共享。然而,必须知道你不能实例化一个抽象类。因此,访问共享行为的唯一方法是用一个子类扩展abstract

对于一个简单的演示,我们将想象我们正在销售数字书籍(PDF,EPUB,Kindle, 等等)--所有这些都有一些共同的逻辑和它们的特定逻辑。首先,我们将建立一个基础的Book 抽象,以放置共同的逻辑。

为了声明一个抽象类,我们使用abstract 关键字。

abstract class Book { 
  // .. 
}

定义通用属性

在这个Book 抽象中,我们就可以决定一个Book 的契约。让我们说,所有的Book 子类必须有authortitle 的属性。我们可以把它们定义为实例变量,并使用抽象类的构造函数接受它们作为输入。

abstract class Book { 
  private author: string;  private title: string;  
  constructor (author: string, title: string) {
    this.author = author;
    this.title = title;
  }
}

定义共同的逻辑

然后我们可以可以使用常规的方法将一些常见的逻辑放在Book 抽象类中。

abstract class Book { 
  private author: string;
  private title: string;

  constructor (author: string, title: string) {
    this.author = author;
    this.title = title;
  }

  // Common methods
  public getBookTitle (): string {    return this.title;  }
  public getBookAuthor (): string {    return this.title;  }}

记住,abstract 类是一个抽象的、可与interface 相媲美的。我们不能直接实例化它。无论如何,为了演示的目的而尝试,我们会注意到我们得到一个错误,看起来像这样。

let book = new Book (          // ❌ error TS2511: Cannot create an 
  'Robert Greene',             // instance of an abstract class. 
  'The Laws of Human Nature'
);

那么,我们应该怎么做呢?我们将引入我们的一个特定类型的Book 凝结物--PDF 类。我们扩展/继承抽象的Book 类,作为一个新的子类将其挂接起来。

class PDF extends Book { // Extends the abstraction
  
  private belongsToEmail: string;
  
  constructor (author: string, title: string, belongsToEmail: string) {
    super(author, title); // Must call super on subclass
    
    this.belongsToEmail = belongsToEmail;
  }
}

由于PDF 是一个具体的类,我们可以把它实例化。PDF 对象拥有Book 抽象类的所有属性和方法,所以我们可以调用getBookTitlegetBookAuthor ,就像它最初在PDF 类上声明一样。

let book: PDF = new PDF(
	'Robert Greene', 
	'The Laws of Human Nature', 
	'khalil@khalilstemmler.com'
);

book.getBookTitle();  // "The Laws of Human Nature"
book.getBookAuthor(); // "Robert Greene"

定义强制性方法

抽象类有最后一个重要特征:abstract 方法的概念。抽象方法是我们必须在任何实现子类上定义的方法。

在抽象类中,我们像这样定义它们。

abstract class Book { 
  private author: string;
  private title: string;

  constructor (author: string, title: string) {
    this.author = author;
    this.title = title;
  }

  abstract getBookType (): string; // No implementation}

注意到这个抽象方法没有实现吗?那是因为我们在子类上实现了它。

PDF 和另外一个EPUB 子类进行演示,我们在这两个子类中都添加了所需的抽象方法。

...

class PDF extends Book {
  ...
  getBookType (): string { // Must implement this    return 'PDF';  }}

class EPUB extends Book {
  
  constructor (author: string, title: string) {
    super(author, title);
  }
  
  getBookType (): string { // Must implement this    return 'EPUB';  }}

如果不实现所需的抽象方法,就不能使类变得完整具体--这意味着我们在试图编译代码或创建实例时,会遇到错误。

使用这种技术是有充分理由的。了解的最好方法是考虑真实世界的场景。我们将在下一节中探讨这个问题。

使用案例(何时使用抽象类)

现在我们知道了抽象类的工作原理,让我们来谈谈它在现实生活中的用例--你有可能遇到的场景。

有两个需要使用抽象类的主要用例。

  1. 分享共同的行为和
  2. 模板方法模式(框架钩子方法)

1.分享共同的行为(基础HTTP类的例子)。

一个普遍的场景是,需要在一个依赖多个后端API端点的前端应用程序中执行HTTP数据获取逻辑。

比方说,我们肯定需要从以下地方获取数据。

常见的逻辑是什么?

  • 设置一个HTTP库(如Axios)
  • 设置拦截器,以设置常见的重新获取逻辑(例如,当访问令牌过期时)。
  • 执行HTTP方法

使用一个抽象类,我们可以将BaseAPI ,作为一个已经提供了部分实现的抽象。

export abstract class BaseAPI {
  protected baseUrl: string;
  private axiosInstance: AxiosInstance | any = null;
   
  constructor (baseUrl: string) {
    this.baseUrl = baseUrl
    this.axiosInstance = axios.create({})
    this.enableInterceptors();
  }

  private enableInterceptors () {
    // Here's where you can define common refetching logic
  }

  protected get (url: string, params?: any, headers?: any): Promise<any> {
    return this.getAxiosInstance({
      method: 'GET',
      url: `${this.baseUrl}${url}`,
      params: params ? params : null,
      headers: headers ? headers : null
    })
  }

  protected post (url: string, params?: any, headers?: any): Promise<any> {
    return this.getAxiosInstance({
      method: 'POST',
      url: `${this.baseUrl}${url}`,
      params: params ? params : null,
      headers: headers ? headers : null
    })
  }
} 

在这里,我们已经把低层次的行为放在一个BaseAPI 的抽象中。现在我们可以在子类中定义高层行为--丰富的东西。

export class UsersAPI extends BaseAPI {
  constructor () {
    super('http://example.com/users');
  }
 
  // High-level functionality
  async getAllUsers (): Promise<User[]> {    let response = await this.get('/');    return response.data.users as User[];  }  
  ...
}

这种模式是很普遍的。Apollo的REST DataSource API也是建立在同样的抽象类方法之上的。

2.模板方法设计模式(前端库和框架的例子)

模板方法设计模式(也被称为模板模式)是一种行为设计模式,它通过在一个抽象类中定义算法的骨架,并将一些步骤推迟到子类中。

因为它允许自定义,所以模板方法模式在Angular、Vue或React.js等库和框架中很普遍。

为了证明这一点,让我们考虑一下React.js的旧版本,它是用于构建前端界面的流行库。

当你使用基于类的组件创建一个React组件时,你绝对肯定需要实现的一个方法是什么?那就是render 方法,对吗?

所以,一个组件的abstract 类的简化版本可以是下面这个样子。

abstract class Component {
  private props: any;
  private state: any;

  abstract render (): void; // Mandatory
}

然后为了使用它,我们需要扩展Component 抽象,并实现渲染方法。

class Box extends Component {
  render () {
    // Must implement this method
  }
}

我们需要实现渲染方法,因为它是决定在屏幕上创建什么的关键部分。客户端开发者(我们)要决定浏览器应该显示什么HTML和CSS。库不能为我们决定这个问题,所以我们需要提供它。

abstract 类是解决这个问题的合适工具,因为有一个算法在幕后运行--在这个算法中,render 只是众多步骤中的一个。

我们可以看到,React有三个不同的阶段:安装、更新和卸载。React的抽象类进一步给我们(客户端开发者)提供了通过连接各种生命周期事件的能力来定制我们的组件的能力。例如,你可以在你的组件挂载时 (componentDidMount)、更新时 (componentDidUpdate)、以及即将卸载时 (componentWillUnmount)执行一些行为。

另一个非常简单(并不完全准确或完整)的描述是这样的:在幕后可能看起来像这样的。

abstract class Component {
  private props: any;
  private state: any;

  abstract render (): void;
	
	// Algorithm for mounting
	constructor () {
    this.render();
    this.componentDidMount();
  }

	// The general algorithm for updating. Called when new props occur, 
	// when setState is called or when forceUpdate is called
  onUpdate () {
    if (this.componentShouldUpdate()) {
      this.render();
      this.componentDidUpdate(); 
    }
  }
  
  // Algorithm for unmounting
  onUnmount () {
    this.componentWillUnmount();
    // Complete unmounting
  }
	
  public componentDidMount (): void {
    // No implementation lifecycle method, allow the client to override
  }

  public componentDidUpdate (): void {
    // No implementation lifecycle method, allow the client to override
  }

  public componentWillUnmount (): void {
    // No implementation lifecycle method, allow the client to override
  }

  private componentShouldUpdate (): boolean {
    // Determines if the component should update
    ...
  }
}

现在,一个定制的组件子类可以在这些关键的生命周期挂钩方法中插入行为。这就是控制反转的概念。

class Box extends Component {
  constructor () {
    super();
  }
  
  componentDidMount () {
    // (Optional) Perform custom logic
  }
  
  componentDidUpdate () {
    // (Optional) Perform custom logic
  }
  
  componentWillUnmount () {
    // (Optional) Perform custom logic
  }
  
  render () {
    // Must implement this method
  }
}

因此,作为一个框架设计者,模板方法设计模式在以下情况下很有吸引力。

  • 你需要客户实现算法的某一步→所以你把这一步变成了抽象方法
  • 你想为客户提供在算法的不同步骤中定制行为的能力→所以你向客户公开了可选的生命周期方法

综上所述

  • 面向对象的程序包含具体化和抽象化。抽象类是我们在面向对象编程中实现抽象的两种方式之一。
  • 使用抽象类来定义共享行为或实现模板方法模式。

常见问题

抽象类构造函数是做什么的?

抽象类的构造函数用于在抽象类中设置属性或运行其他设置逻辑,以便低级的抽象类方法可以使用它们。我们通过在子类(派生类)上调用super 来调用构造函数。

抽象方法是做什么的?

抽象方法的存在是为了迫使客户实现抽象类工作所需的特定功能。我们把具体的细节抽象给子类。

接口与抽象类的区别是什么?

接口和抽象类都是面向对象编程中抽象的例子。接口只能定义抽象的属性和方法,而抽象类除了定义属性和方法外,还可以定义一些常见的行为。

我们也使用implements 关键字来继承一个接口,但使用extends 关键字来对一个abstract 类进行继承。

当你需要一个抽象时,默认使用一个接口(或类型化语言中的一个类型),但当你需要定义共同行为或使用模板方法设计模式来概括一个算法时,选择一个抽象类。