# 前言

设计模式(Design pattern)是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。

作者认为设计模式不应该脱离场景问题存在,设计模式应该是解决特定场景下的特定问题,提高代码的可复用性和可靠性。

本章会给大家介绍四种前端常用的设计模式,了解它们的适用场景以及解决的问题。四种常用设计模式:单例模式,工厂模式,代理(委托)模式,发布-订阅模式。其中在前端应用最广泛以及最重要的模式是发布-订阅模式模式(没有之一)。

# 设计模式源码

源码地址:github.com/dkypooh/fro…

# 工厂模式

工厂模式(Factory Pattern)是 Java 中最常用的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

在工厂模式中,我们在创建对象时不会对客户端暴露创建逻辑,并且是通过使用一个共同的接口来指向新创建的对象。

# 定义

定义一个创建对象的接口,让其子类自己决定实例化哪一个工厂类,工厂模式使其创建过程延迟到子类进行。

# 实现方法

让其子类实现工厂接口,返回的也是一个抽象的产品。

# 适用场景

子类不需要定义父类的实现,只需要实现父类定义接口。

  1. 您需要一辆汽车,可以直接从工厂里面提货,而不用去管这辆汽车是怎么做出来的,以及这个汽车里面的具体实现。
  2. Hibernate 换数据库只需换方言和驱动就可以。

# 代码实例

// 1. 父类实现基本定义,定义姓名
class Parent {
    getName() {
        
    }
}

// 2. 子类集成了父类的各个基因,男孩名字叫 bob
class ChildBoy extends Parent {
    getName() {
        return 'bob';
    }
}

// 3. 子类集成了父类的各个基因,女孩名字叫 alice
class ChildGirl extends Parent {
    getName() {
        return 'alice';
    }
}

# 单例模式

# 定义

保证一个类仅有一个实例,并提供一个访问它的全局访问点。

# 实现方法

判断系统是否已经有这个单例,如果有则返回,如果没有则创建,确保了一个类只有一个实例对象。

# 适用场景

当您想控制实例数目,节省系统资源的时候,有如下场景可以适用:

  1. 全局性实例化组件,例如:Toast组件Modal弹窗组件
  2. 避免类防止多次创建实例的场景,例如:实例化事件模块new EventEmitter()

# 代码实例

//  1. 创建 Toast 单例类
class Singleton {
  constructor(options) {
    this.options = options
  }
  show(message) {
    alert(message)
  }
}

// 2. 创建代理类,确保构造器只有一个实例
function ProxyClass() {
  let instance = null
  return function(options) {
    if (!instance) {
      instance = new Singleton(options);
    }

    return instance;
  }
}

// 3. 执行代理函数,闭包保存实例,返回单例类
const SingletonClass =  ProxyClass();

// 4. 测试代码,实例化两个类,实例是否相同
const d = new SingletonClass('dd');
const c = new SingletonClass('cc');
d === c

# 事件代理模式

事件代理模式在前端的主要应用场景是事件委托(event delegate)。

# 定义

JavaScript高级程序设计上讲:事件委托就是利用事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。

# 实现方法

一般来讲,会把一个或者一组元素的事件委托到它的父层或者更外层元素上,真正绑定事件的是外层元素,当事件响应到需要绑定的元素上时,会通过事件冒泡机制从而触发它的外层元素的绑定事件上,然后在外层元素上去执行函数。

# 适用场景

image-20210214200609476

事件冒泡 和 事件捕获 分别由 微软 和 网景 公司提出,后来 W3C 将两者结合,制定了统一的标准 —— 先捕获再冒泡。

为了更好的理解事件流模型,我们把 DOM 树想象成一个靶子,父节点在外,子节点在内。如下图所示:

  • 事件冒泡(event bubbling) 由内向外,即从 DOM 树的子到父,div -> body -> html -> document
  • 事件捕获(event capturing) 由外向内,即从 DOM 树的父到子,document -> html -> body -> div

# 代码实例

在 JavaScript 中,addEventListener 方法用于向指定元素添加事件句柄。 语法:element.addEventListener(event, function, useCapture)

element 目标元素
event 事件名,如 click
function 事件触发时执行的函数
useCapture Bool值,true - 事件句柄在 捕获 阶段执行,false- false- 默认。事件句柄在 冒泡 阶段执行

设置 addEventListener 捕获方式为 false 冒泡方式

/**.html**/
<div class="t3">document
  <div class="t2">html
    <div class="t1">body
      <div class="t0">div</div>
    </div>
  </div>
</div>

/**.js**/
var $t0 = document.getElementsByClassName('t0')[0];
var $t1 = document.getElementsByClassName('t1')[0];
var $t2 = document.getElementsByClassName('t2')[0];
var $t3 = document.getElementsByClassName('t3')[0];

$t0.addEventListener("click", function(){
  alert("click div")
}, false);

$t1.addEventListener("click", function(){
  alert("click body")
}, false);

$t2.addEventListener("click", function(){
  alert("click html")
}, false);

$t3.addEventListener("click", function(){
  alert("click document")
}, false);

# 发布-订阅模式

# 定义

观察者模式中的源(Subject)就像一个发布者(Publisher),观察者(Observer)完全和订阅者(Subscriber)关联。

在发布-订阅模式,消息的发送方,叫做发布者(publishers),消息不会直接发送给特定的接收者,需要有个一个中间媒介做集中化处理。

image-20210214200722364

上图解释:一个发布者发布一条消息,通过订阅-发布模型,可以被多的接收者收到。

# 观察者模式 VS 发布-订阅模式

读者开始会对这两者模式差异存在疑惑,作者认为在前端领域,这两种存在一些细微差别,使用功能来说可以等同。我们同样可以成为观察者模式 为 发布-订阅模式。 它们细小的差别在于,发布-订阅模式相比较于观察者模式,有一个中心管控(或者中介者)的模块。如下图所示:

image-20210214200741722

# 应用场景

在前端发布-订阅模式,又称为观察者模式。它主要有如下几方面的应用场景。

  1. 保持组件数据通信的扁平化,例如不同根节点子组件通信可以使用发布-订阅模式
  2. 数据通信解耦,在统一内存环境下都可以实现不同模块之间的通信

# 源码实现

DOM原生支持的 CustomEvent 也是一个发布-订阅模型,下面使用此 DOM API 使用此模型。

<body>
  <div id="tap"> Sub/Pub </div>

  <script>
    // 1. 获取id 为 tap DOM元素节点
    const node = document.getElementById('tap');
    
    // 2. 添加tap节点自定义事件
    node.addEventListener('cat', (result) => {
      console.log(result.detail);
    })

    // 3. 绑定tap节点 click 事件
    node.addEventListener('click', () => {
      // 3.1 定义自定义事件
      const event = new CustomEvent("cat", {"detail":{"hazcheeseburger":true}})
      // 3.2 节点出发事件
      node.dispatchEvent(event);
    })
  </script>
</body>

# EventEmitter实现

设计模式章节的最后, 和作者一起通过 Typescript 实现一个 EventEmitter 类, EventEmitter 实现两个核心方法: emit(发布事件) 和 on (订阅事件)。

EventEmitter 实现的基本思想:使用Map来维护事件名称和事件方法的映射关系。同时通过 emit 绑定当前作用域,执行方法。

# 代码实例

源码参考地址:github.com/dkypooh/fro…

export default class EventEmitter {
  // 1. 创建一个eventMap,用于存储事件名和函数的映射关系
  private _eventMap = new Map();
  /**
   * on
   * @param {String} type     事件名称
   * @param {Function} fn     绑定函数
   */
  on(type: string, fn: ICallback) {
    // 2. 实现 on 事件监听方法
    // 2.1 如果 eventMap 有此方法,则维护多个方法,push到方法数组中, emit的时候批量执行
    // 2.2 如果 enentMap 没有此方法, 则 set 这个事件函数
    if (this._eventMap.has(type)) {
      const cbs = this._eventMap.get(type);
      cbs.push(fn);
    } else {
      this._eventMap.set(type, [fn]);
    }
    return this;
  }
  /**
   * emit 
   * @param {String} type  事件名称
   * @param args
   */
  emit(type: string, ...args: any[]): boolean {
    // 3. 实现 emit 事件订阅方法
    // 3.1 如果 event 中有此方法,则批量执行,同时通过Apply 绑定当前作用域执行。
    if (this._eventMap.has(type)) {
      const cbs = this._eventMap.get(type);
      for (const fn of cbs) {
        fn.apply(this, args);
      }
      return true;
    } else {
      return false;
    }
  }
  
}

代码详解如下:

  1. 创建一个eventMap,用于存储事件名和函数的映射关系
  2. 实现 on 事件监听方法,如果 eventMap 有此方法,则维护多个方法,push到方法数组中, emit的时候批量执行,如果 enentMap 没有此方法, 则 set 这个事件函数
  3. 实现 emit 事件订阅方法,如果 event 中有此方法,则批量执行,同时通过Apply 绑定当前作用域执行。

# 结语

设计模式的选择, 必须是根据不同场景问题出发,解决不同场景的实际问题。 上文介绍了前端开发中常用的四种设计模式。

最后带大家实现一个 EventEmitter 事件发布-订阅类(前端最常用的工具类),读者可以体会其中的实现思想,同时可以使用此类解耦项目数据通信。

# 思考题

Q:如何实现一个类的单例,防止多次初始化?

阅读全文