浅谈TypeScript设计模式-基础篇(二)

avatar
Web前端 @CVTE_希沃

希沃ENOW大前端

公司官网:CVTE(广州视源股份)

团队:CVTE旗下未来教育希沃软件平台中心enow团队

本文作者:

毅春名片.jpeg

前言

设计模式的学习过程中往往有四个境界

  1. 没学过以前是一点也不懂,在特定的场景下想不到一种通用的设计方式,设计的代码比较糟糕。
  2. 学了几个模式以后很开心想着到处用自己学过的模式,于是会造成误用模式而不自知。
  3. 学了很多设计模式,感觉诸多模式极其相似,无法分清模式之间的差异,但深知误用有害,应用时有所犹豫。
  4. 灵活应用模式,甚至不应用具体的某种模式也能设计出非常优秀的代码,以达到无剑胜有剑的境界。

本系列将会和大家一起从了解面向对象开始,再深入到常用的设计模式,一起探索TypeScript配合设计模式在我们平时开发过程中的无限可能,设计出易维护、易扩展、易复用、灵活性好的程序。

上一篇我们一起了解了面向对象的几个基本概念

类与实例、构造函数、方法重载、属性与修饰符,附上篇链接:

juejin.cn/post/689857…

今天我们继续来学习面向对象的几个重要概念。

准备

为了让大家可以更直观的了解面向对象的概念,我们在这一篇一起用面向对象的思维去实现一个可以动态添加形状到画布,并且形状可以在画布内自由拖动的效果。附上源码地址:github.com/goccult/typ…

屏幕录制2021-04-26 20.07.05.gif 我们先准备重新整理一下我们的代码,在src文件夹下新建以下文件 image.png

// index.ts
require('dist/circle.js')
require('dist/main.js')

// require.ts
const require = (path: string) => {
  const script = document.createElement('script')
  script.async = false
  script.defer = true
  script.src = path
  document.body.appendChild(script)
}

// main.ts
function addCircle() {
  new Circle('#canvas', {x: 0, y: 0})
}
//index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="dist/require.js"></script>
  <style>
    body {
      padding: 0;
      margin: 0;
    }
    img {
      -webkit-user-drag: none;
    }
    .container {
      width: 100vw;
      height: 100vh;
      padding: 40px;
      box-sizing: border-box;
    }
    #canvas {
      border: dashed 1px black;
      width: 1000px;
      height: 700px;
      position: relative;
      overflow: hidden;
    }
    .action-box {
      width: 100%;
      height: 60px;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="action-box">
      <button onclick="addCircle()">添加圆形</button>
    </div>
    <div id="canvas"></div>
  </div>
  <script src="dist/index.js"></script>
</body>
</html>

封装

我们重新设计一下我们的Circle类

// circle.ts
class Circle {
  private location: {x: number, y: number};
  private dom: HTMLDivElement;
  private canvasDom: HTMLDivElement | null; // 画布dom元素

  constructor(canvasId: string, location?: {x: number, y: number}) {
    this.location = location ? location : { x:0, y:0 };
    this.dom = document.createElement('div')
    this.canvasDom = document.querySelector(canvasId);
    this.appendElement()
  }

  get Location() {
    return this.location
  }

  set Location(obj: {x: number, y: number}) {
    if (!obj.x || !obj.y) {
      throw new Error('Invalid location')
    }
    this.location = obj
  }

  private appendElement() { // 加入appendElement方法往画布添加dom元素
    this.dom.style.position = 'absolute';
    this.dom.style.width = '60px';
    this.dom.style.height = '60px';
    this.dom.style.borderRadius = '50%';
    this.dom.style.background = 'green';
    this.dom.style.top = `${this.location.y}px`;
    this.dom.style.left = `${this.location.x}px`;
    this.dom.style.cursor = 'move';
    (this.canvasDom as HTMLDivElement).appendChild(this.dom);
  }
}

Circle接收外部传入的id用于获取画布的dom元素,接收location确定插入的圆相对于画布的位置,再通过appendElement方法确定我们要插入元素的样式并插入到画布。

这时候我们的页面就已经可以添加圆形到画布里了 image.png

到这一步其实我们就已经完成了对Circle类的封装,它把类内部的属性和方法统一保护了起来,只保留有限的接口与外部进行联系,尽可能屏蔽对象的内部实现细节,并且使用访问器属性对属性的访问和设值做了限制,防止外部随意修改内部数据。

封装有三大好处。

1、良好的封装可以减少耦合

2、类内部的实现可以自由的修改

3、类具有清晰的对外接口

如果我们还想画一个正方形,那你可能会说简单啦,仿造Circle类封装一个Square类在appendElement方法里设置正方形的样式再添加多一个按钮就可以实现啦。

class Square {
  ...

  constructor(canvasId: string, location?: {x: number, y: number}) {
    ...
  }

  get Location() {
    ...
  }

  set Location(obj: {x: number, y: number}) {
    ...
  }

  public appendElement() {
    this.dom.style.position = 'absolute';
    this.dom.style.width = '60px';
    this.dom.style.height = '60px';
    this.dom.style.background = 'red';
    this.dom.style.top = `${this.location.y}px`;
    this.dom.style.left = `${this.location.x}px`;
    this.dom.style.cursor = 'move';
    (this.canvasDom as HTMLDivElement).appendChild(this.dom);
  }
}

虽然我们通过这种方式实现了功能,但是你会发现Circle类和Square类里面有大量重复的代码,而且这些代码都是必须的不可去除的,要解决这个问题就需要用到面向对象的第二大特性“继承”。

继承

我们抛开代码层面去看圆形和正方形,其实这两者都可以归属于形状,那我们就可以理解为圆形、正方形与形状是继承关系。

那从我们程序设计的角度看,一个对象的继承代表了“is-a”的关系,在这里可以理解为圆形是形状,则表明圆形可以继承形状。

我们可以新创建一个形状的类,形状类叫做父类或者基类,圆形和正方形叫做子类或者派生类,其中子类继承父类的所有特性,子类不但继承了父类所有的特性,还可以定义新特性。

同时在使用继承时要记住三句话:

1、子类拥有父类非private的属性和方法。
2、子类拥有自己的属性和方法,即子类可以扩展父类没有的属性和方法。
3、子类还可以自己实现父类的功能。

有了对继承简单的认识,那我们现在就可以对我们的代码进行优化了。现在Square类和Circle类中存在大量的重复代码,我们可以新建一个Shape类当做父类,把重复的代码都尽量放到Shape类中。

// shape.ts
class Shape {
  protected location: {x: number, y: number}; // 注意这里的修饰符都变成了 protected
  protected dom: HTMLDivElement;
  protected canvasDom: HTMLDivElement | null;

  constructor(canvasId: string, location?: {x: number, y: number}) {
    this.location = location ? location : { x:0, y:0 };
    this.dom = document.createElement('div')
    this.canvasDom = document.querySelector(canvasId);
  }

  get Location() {
    return this.location
  }

  set Location(obj: {x: number, y: number}) {
    if (!obj.x || !obj.y) {
      throw new Error('Invalid location')
    }
    this.location = obj
  }

  protected appendElement() {} // 多态概念后面说
}

这里要注意,我们用上了上一篇中讲到的protected修饰符,刚才有讲过,子类拥有父类非"private"的属性和方法,既然我们这里的属性都是子类需要用到的,那我们就需要把修饰符改为protected

有了Shape类,那我们的CircleSquare类就可以通过继承Shape来获得共用的属性和方法了。

// circle.ts
class Circle extends Shape{  // extends关键字指定继承的父类函数
  constructor(canvasId: string, location?: {x: number, y: number}) {
    super(canvasId, location) // super()方法访问父类的构造函数
    this.appendElement()
  }

  public appendElement() {
    this.dom.style.position = 'absolute';
    this.dom.style.width = '60px';
    this.dom.style.height = '60px';
    this.dom.style.borderRadius = '50%';
    this.dom.style.background = 'green';
    this.dom.style.top = `${this.location.y}px`;
    this.dom.style.left = `${this.location.x}px`;
    this.dom.style.cursor = 'move';
    (this.canvasDom as HTMLDivElement).appendChild(this.dom);
  }
}
// square.ts
class Square extends Shape{  // extends关键字指定继承的父类函数
  constructor(canvasId: string, location?: {x: number, y: number}) {
    super(canvasId, location) // super()方法访问父类的构造函数
    this.appendElement()
  }

  public appendElement() {
    this.dom.style.position = 'absolute';
    this.dom.style.width = '60px';
    this.dom.style.height = '60px';
    this.dom.style.background = 'red';
    this.dom.style.top = `${this.location.y}px`;
    this.dom.style.left = `${this.location.x}px`;
    this.dom.style.cursor = 'move';
    (this.canvasDom as HTMLDivElement).appendChild(this.dom);
  }
}

然后我们在页面中添加一个插入正方形的按钮,分别在index.ts、main.ts、index.html加入以下代码

// index.ts
require('dist/shape.js')
require('dist/square.js')

// main.ts
function addSquare() {
  new Square('#canvas', {x: 100, y: 0})
}

//index.html
<button onclick="addSquare()">添加正方形</button>

到这一步我们的页面就可以动态添加圆形和正方形了。 屏幕录制2021-04-27 09.43.25.gif

这时候前方来了需求我们需要给每个形状加可拖动的功能,试想一下,如果我们现在有大于5个形状,而没有用继承的方式实现Shape类,那么我们添加拖动的代码就需要在多个不同形状的类中添加重复的代码,这工作量不仅很大,而且很容易出错。我们现在有了Shape类,就只需要在Shape类中改就好了。

// shape.ts
class Shape {
  protected location: {x: number, y: number};
  protected dom: HTMLDivElement;
  protected canvasDom: HTMLDivElement | null;
  protected move: boolean = false; // 判断是否处于移动状态
  protected offsetY: number = 0;  // 记录鼠标按下形状时鼠标在形状X轴方向的偏移量
  protected offsetX: number = 0;  // 记录鼠标按下形状时鼠标在形状Y轴方向的偏移量
  protected canvasOffsetLeft: number = 0;  // 记录画布相对于整个浏览器视口区域X轴方向的偏移量
  protected canvasOffsetTop: number = 0;   // 记录画布相对于整个浏览器视口区域Y轴方向的偏移量

  constructor(canvasId: string, location?: {x: number, y: number}) {
    this.location = location ? location : { x:0, y:0 };
    this.dom = document.createElement('div')
    this.canvasDom = document.querySelector(canvasId);
    this.addMoveFn()
  }

  get Location() {
    return this.location
  }

  set Location(obj: {x: number, y: number}) {
    if (!obj.x || !obj.y) {
      throw new Error('Invalid location')
    }
    this.location = obj
  }

  protected appendElement() {}
  
  private addMoveFn() {
    this.dom.addEventListener('pointerdown', (e) => {
      this.move = true
      this.offsetX = e.offsetX
      this.offsetY = e.offsetY
      const {left = 0, top = 0} = (this.canvasDom as HTMLDivElement).getBoundingClientRect()
      this.canvasOffsetLeft = left
      this.canvasOffsetTop = top
    })
  
    this.canvasDom?.addEventListener('pointerup', () => {
      this.move = false
    })
    this.canvasDom?.addEventListener('pointermove', (e) => {
      if (this.move) {
        this.dom.style.left = `${e.clientX - this.offsetX - this.canvasOffsetLeft }px`
        this.dom.style.top = `${e.clientY - this.offsetY - this.canvasOffsetTop }px`
      }
    })
    this.canvasDom?.addEventListener('mouseleave', (e) => {
      if (this.move) {
        this.move = false
      }
    })
  }
}

屏幕录制2021-04-27 10.25.38.gif 具体的实现就不赘述了,就是添加一些鼠标的事件监听来实现拖动元素修改left和top属性。

总结一下继承的优势和劣势

继承可以使得子类公共的部分都放在父类,使得代码得到了共享避免重复,另外继承可使得修改或扩展而来的实现都较为容易。

继承也有它的劣势,继承把个各类的耦合性增强了,父类变子类也得跟着变,所以当两个类之间如果没有表现出"is-a"的关系,还是要谨慎继承。

多态

完成到这一步时我们再回到代码上看,在创建Shape类的时候里面的appendElement()方法没有具体的代码实现。方法的实现在子类中通过重写该方法做了不同的实现。

// shape.ts
  protected appendElement() {}
  
// circle.ts
  public appendElement() {
		...
    this.dom.style.borderRadius = '50%';
    this.dom.style.background = 'green';
    ...
  }
  
// square.ts
  public appendElement() {
    ...
    this.dom.style.background = 'red';
    ...
  }

其实这就是多态的概念:不同的对象可以实现同名的方法,但是通过自己的实现来完成不同的功能。

多态的优势也是比较明显的,提供了代码的扩展性,提供了代码的维护性。

抽象类

我们再次回到代码,其实我们可以发现Shape类的作用只是用于被子类继承,并不需要被实例化,那么这时候我们就可以把Shape类认为是一个抽象类。

TypeScript中抽象类和抽象方法用abstract关键字定义。

由此我们再改造一下我们的Shape类把它改为抽象类

// shape.ts
abstract class Shape { // 抽象类前加abstract关键字
	...
	protected abstract appendElement(): void // 抽象方法加abstract关键字
	...
}

抽象类有三点要求:

1、抽象类不能被实例化
2、抽象方法必须被子类重写
3、如果类中包含了抽象方法,则该类就必须被定义为抽象类

假设这时候来了一个新人开发我们的功能,他需要添加一个长方形到画布中,由于抽象类和抽象方法的限制,它自己新建的Rectangle类中如果没有实现appendElement抽象方法,那ts会给他报错提示。 image.png

形状的移动逻辑都已经封装在了父类中,那新人只需要重写appendElement方法就可以实现添加各种想要的形状到画布中了。

class AnyShape extends Shape{
  constructor(canvasId: string, location?: {x: number, y: number}) {
    super(canvasId, location) // super()方法访问父类的构造函数
    this.appendElement()
  }

  public appendElement() { // 重写该方法实现添加不同形状或者元素
    ...
  }
}

总结

本系列的基础篇就先介绍到这里啦,前两篇简单介绍了面向对象的一些基本概念,为后面学习设计模式做一些简单的铺垫。当然仅仅通过几篇文章还是不够的,还是需要大家在平时的开发中多思考多实践,多看一些经典书籍理解它,才能达到无剑胜有剑的境界。