众所周知,JavaScript是一门面向对象编程语言。大家或多或少都学习过一些JavaScript面向对象相关的知识,比如构造函数、实例化、继承等。
面向对象是一种程序编程范式,也是一种程序设计思想。通过对业务场景的进行抽象、建模来设计逻辑清晰、易维护、可扩展的应用程序。
本篇文章不讨论JavaScript面向对象的具体技术实现方式,比如:继承的实现方式,实例化的方法等。而是基于typescript来学习面向对象的程序设计思想。
Why typescript
因为身为前端,就应该使用typescript,并且语法上与Java比较接近,容易理解。可以套用绝大部分在Java中的最佳实践。
面向对象的设计思想,其应用不局限于具体的语言形式,只要是支持面向对象编程的语言,都能够使用面向对象的思想去设计程序。
三大特性
面向对象程序设计有三大特性,分别是封装、继承、多态。这三个特性都是抽象概念,依赖于具体的技术实现。
本文主要就是围绕三大特性,结合简单的例子来讲述面向对象编程。
封装
封装的概念大家都很熟悉,就是把相互关联的一些属性、方法等放到一起统一管理,再根据需求,决定对外部暴露哪些属性、方法。封装通常会使用到类(class)来实现,结合访问控制关键字来精确控制成员属性的可见性。
常用的访问控制关键字有:private
、protected
、public
、static
、readonly
。在设计类的时候,应该尽可能的将可见性控制在最小的范围,没有必要暴露给外加的数据,就是用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);
代码中有两个类,分别是Cayenne
和Driver
。Cayenne
是一种汽车,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
是游戏主体类,处理游戏核心逻辑,将各个模块组合在一起工作。