众所周知,JavaScript是一门面向对象编程语言。大家或多或少都学习过一些JavaScript面向对象相关的知识,比如构造函数、实例化、继承等。

面向对象是一种程序编程范式,也是一种程序设计思想。通过对业务场景的进行抽象、建模来设计逻辑清晰、易维护、可扩展的应用程序。

本篇文章不讨论JavaScript面向对象的具体技术实现方式,比如:继承的实现方式,实例化的方法等。而是基于typescript来学习面向对象的程序设计思想。

Why typescript

因为身为前端,就应该使用typescript,并且语法上与Java比较接近,容易理解。可以套用绝大部分在Java中的最佳实践。

面向对象的设计思想,其应用不局限于具体的语言形式,只要是支持面向对象编程的语言,都能够使用面向对象的思想去设计程序。

三大特性

面向对象程序设计有三大特性,分别是封装、继承、多态。这三个特性都是抽象概念,依赖于具体的技术实现。

本文主要就是围绕三大特性,结合简单的例子来讲述面向对象编程。

封装

封装的概念大家都很熟悉,就是把相互关联的一些属性、方法等放到一起统一管理,再根据需求,决定对外部暴露哪些属性、方法。封装通常会使用到类(class)来实现,结合访问控制关键字来精确控制成员属性的可见性。

常用的访问控制关键字有:privateprotectedpublicstaticreadonly。在设计类的时候,应该尽可能的将可见性控制在最小的范围,没有必要暴露给外加的数据,就是用private

在面向对象思想中,类与类之间依赖接口(Interface)进行交互。我们在使用一个对象的时候,无需关心这个对象是个什么东西,只要它提供(实现)了我们需要使用的接口,那么我们就能使用它。

思考一个场景:世界上的汽车种类数不胜数,不同的品牌、外观、性能、体积,我们只要学会了开车,无论是什么品牌、任何形状的汽车,都能开走,因为它有一套共同的结构——方向盘、刹车、油门。

如果我们把汽车抽象成一个类,那么提供给驾驶员操作的方法、属性就是汽车提供给驾驶员的接口,驾驶员只要知道如何使用这些接口就能把车开走,无论车是什么车。

我们可以简单写个代码

class Cayenne implements Drivable {
  startEngine(key: Key): boolean {
    console.log('启动引擎');
    return true;
  }
  accelerate(force: number): void {
    console.log('踩油门,力度:' + force * 100 + '%');
  }
  brake(force: number): void {
    console.log('踩刹车,力度:' + force * 100 + '%');
  }
  turnTo(angle: number): void {
    console.log('转向:' + angle + '度');
  }
}

interface Drivable {
  startEngine(key: Key): boolean; // 启动
  accelerate(force: number): void; // 加速
  brake(force: number): void; // 制动
  turnTo(angle: number): void; // 转向
}

class Driver {

  drive(car: Drivable) {
    if (car.startEngine(Key.Physical)) {
      car.accelerate(0.5);
      car.turnTo(30);
    } else {
      throw new Error('Start failed')
    }
  }
}

const cayenne = new Cayenne();
const LaoWang = new Driver();

LaoWang.drive(cayenne);

代码中有两个类,分别是CayenneDriverCayenne是一种汽车,Driver是司机,司机老王驾驶该汽车只需要该汽车拥有特定的功能(Drivable接口),而Cayenne实现了Drivable接口,因此老王可以把车开走。

继承

继承可以理解为“在原来的基础上进行扩展”,当子类继承了父类时,会保留父类原有的成员属性和方法,除此之外,它还可以拥有自己的属性和方法,实现更丰富的功能。

上面的例子中,Cayenne是一种具体的汽车型号,但是不同型号的汽车,总是有很多相同的地方,比如都会具有提供人类驾驶的接口,此外还有很多如车轮、油箱、车灯、等。这些所有汽车都会有东西,我们可以再进行一次抽象,封装一个Car类,并实现Drivable接口。

class Car implements Drivable {
  ...
  startEngine(key: Key): boolean {
    console.log('启动引擎');
    return true;
  }
  accelerate(force: number): void {
    console.log('踩油门,力度:' + force * 100 + '%');
  }
  brake(force: number): void {
    console.log('踩刹车,力度:' + force * 100 + '%');
  }
  turnTo(angle: number): void {
    console.log('转向:' + angle + '度');
  }
}

此时重新写Cayenne类,让他继承自Car,并且增加空调功能

class Cayenne extends Car implements AirConditioner {
  turnOnAc(): void {
    console.log('打开空调');
  }
  turnOffAc(): void {
    console.log('关闭空调');
  }
}

interface AirConditioner {
  turnOnAc(): void;
  turnOffAc(): void;
}

Cayenne类除了具备可驾驶的接口外,它还实现了空调操作接口,增加了空调功能。老王开车的时候,可以检查是否拥有(实现)了空调接口,来判断能不能使用空调。

更新一下Driver

class Driver {

  drive(car: Drivable) {
    if (car.startEngine(Key.Physical)) {
      car.accelerate(0.5);
      car.turnTo(30);
      // 由于typescript中的Interface是编译时的概念,无法使用该方式检查,可以使用下面的方法曲线救国
      // if (car instanceof AirConditioner) {
      //   car.turnOnAc();
      // }
      if (instanceOfAirConditioner(car)) {
        car.turnOnAc();
      } else {
        console.log('没有空调');
      }
    } else {
      throw new Error('Start failed')
    }
  }
}

// 检查是否实现了AirConditioner接口
function instanceOfAirConditioner(target: any): target is AirConditioner {
  return typeof target.turnOnAc === 'function' && typeof target.turnOffAc === 'function';
}

多态

多态(Polymorphism)即多种形态,这个词源于生物学领域,表示同一个生物种群会存在多种不同的形态。最简单的例子就是同为人类,不同的民族存在不同的肤色、生活方式。

在面向对象中,多态的含义类似,表示同一类实体(继承自相同的父类或实现了相同的接口),对于特定方法拥有自己的实现逻辑和表现。比如上面的startEngine方法,对于不同的车来说,其实现和表现都可能不同,那么我们就需要通过重写父类的startEngine方法来实现差异化。

方法重写

在上面的例子中,startEngine方法对于不同的车来说,实现和表现可能都不一样。比如一辆纯电动车,在启动引擎的时候,可能是连接电源;而一辆燃油动力的车,启动引擎的时候需要电机带动内燃机启动,并且伴有轰鸣声。要实现这样的差异化,我们可以在子类中重写startEngine方法来实现。

abstract class Car implements Drivable {
  abstract startEngine(): boolean;
  ...
}

我们把Car中的startEngine方法写成抽象方法,抽象方法不需要有方法体,只用声明方法名、参数以及返回值类型。

拥有抽象方法的类必须是抽象类,抽象类由于存在没有实现的方法,所以不能被实例化,只能被继承。

class RongGuang extends Car {
  startEngine(): boolean {
    console.log('五菱荣光启动,轰轰轰~');
    return true;
  }
}

class P7 extends Car {
  startEngine(): boolean {
    console.log('小鹏P7启动,叮~');
    return true;
  }
}

上面的代码分别创建了两个汽车类,荣光和P7,都继承自Car类,但是分别实现了自己的startEngine,无论两者的启动方法有什么区别,老王依旧能够愉快的驾驶这两辆汽车。

class Driver {
  drive(car: Car) {
    if (car.startEngine()) {
      car.accelerate(0.5);
      car.turnTo(30);
    } else {
      throw new Error('Start failed')
    }
  }
}

方法重载

多态还有一种形式是方法的重载,让同一个(名称)方法针对不同的参数进行区别处理。typescript也提供了方法重载的能力,比如下面的例子,启动汽车的时候可以使用实体钥匙,也可以使用指纹启动,两者的实现逻辑会存在一定的差异,但是我们可以只用一个startEngine方法而不是startEngineByFingerprint之类的变体。

class X6 extends Car {
  startEngine(key: Key.Physical): boolean;
  startEngine(key: Key.Fingerprint): boolean;
  startEngine(key: Key.Physical | Key.Fingerprint): boolean {
    switch (key) {
      case Key.Physical: {
        console.log('使用实体钥匙启动X6');
        break;
      }
      case Key.Fingerprint: {
        console.log('使用指纹启动X6');
        break;
      }
    }
    return true;
  }
}

Drivable接口中提供的启动引擎的接口,并不包含指纹启动,这不是必要的功能,但是对于X6来说,想要提供更丰富的启动方式,那么可以通过重载启动方法实现,也体现了同一种类型(方法)的不同实现。

从代码上来看,我们只是通过判断变量的类型类决定要执行的逻辑,这是typescript特性所决定的实现方式,在其他的编程语言中,可以直接将逻辑写到重载的方法中,比如Java

class X6 extends Car {
  @override
  public boolean startEngine(Key.Physical key) {
    System.out.println("使用实体钥匙启动X6");
    return true;
  }
  @overload
  public boolean startEngine(Key.Fingerprint key) {
    System.out.println("使用指纹启动X6");
    return true;
  }
}

实现相同的接口

前面的例子中,我们创建了一个Drivable接口,他描述了能够被驾驶的接口,我们在Car类中实现了该接口,让汽车能够被驾驶员驾驶。

除了普通的汽车,能够被驾驶的工具还有很多,比如挖掘机

class Excavator implements Drivable {
  startEngine(): boolean {
    ...
  }
}

不同类型的工具过实现同一个接口Drivable,同一种(可驾驶的)工具,具有不同的形态和实现方式。

应用场景

面向对象是一种编程思想,不局限于特定场景,无论是游戏、网页、服务、客户端软件等都可以使用面向对象来设计程序。

在前端领域,一方面JavaScript的语言特性更为灵活,另一方面受前端框架影响较多,因此面向对象设计相对较少。但是typescript的为JavaScript提供了类型检查,让面向对象的程序设计变得更加简洁、安全。社区有很多优秀的框架,比如:Angular、Rxjs、Pixi等,都提供了非常优秀的实践。

业务案例

前面讲了面向对象的三大特性以及具体的实现方式,描述的那些例子可能有些偏离真实业务,我们需要在实践中加深对面向对象的理解,以及锻炼面向对象的思维方式。

下面就来看一个开发过的真实业务场景。

需求背景

一个停车场中有多个障碍物和一辆小车,我们需要移动(拖动)障碍物,让小车能够顺利的走到对面。其中障碍物只能横向或纵向移动,小车不可以拖动,在移走所有障碍物之后,小车沿着直线自动开到对面。

程序设计

了解和分析了需求后,开始设计我们的程序,面向对象程序设计有一个非常重要的工具——UML类图。我们可以通过UML类图,在编写代码之前建立好局部模型甚至完整系统模型。

汽车和障碍物

我们先初步设计Car和Obstacle,在canvas中绘制图片,需要有几个必要参数:图片(可以是img标签),左上角坐标。每个类都应该实现自己的绘制方法draw 这两个类本质上都是canvas画布中的对象,很多地方是一样的,但是两者之间有着细微的差别,比如Obstacle需要通过鼠标进行拖拽,因此多了一个isMouseOver方法来判断鼠标是否在它上面。

此外,draw方法对于不同的类型,实现可能不一样,比如绘制图片和绘制矩形,执行的逻辑是不一样的。因此draw方法需要抽象出来。

渲染器

初步设计好小车和障碍物后,我们可以设计一个canvas渲染器类Renderer,它包含一个view属性和一个render方法,view代表着canvas元素,render方法则是在画布上绘制内容。

思考一下render方法,既然要在画布上绘制内容,那么render方法就得有一个或多个参数来表示需要绘制的对象。而对于不同的对象,应该如何去绘制呢?此时我们可以设计一个可绘制(Drawable)接口,用来提供可以被renderer绘制的方法。

事件

Renderer向外界提供了视图组件(view),除此之外,还应该提供用户交互的API,比如鼠标事件。于是我们第一时间想到为Renderer提供一个on方法,让外界能够监听到鼠标事件,但是除了Renderer会有事件外,其它的类也可能会拥有一些事件,比如ParkingGame类,需要提供游戏状态更新的事件,因此我们可以设计一个EventEmitter类,来为有需要的类提供事件能力。 ParkingGame是游戏主体类,处理游戏核心逻辑,将各个模块组合在一起工作。

参考资料