JavaScript常用设计模式示例与应用
发布于 4 年前 作者 oxiang 491 次浏览 来自 分享

JavaScript常用设计模式实例与应用

前言

1. 什么是设计模式

小时候打游戏,我们总是追求快速完美通关;上下班交通,我们总是会选择最方便便捷乘车路线。我们总是追求一件事情的最优美便捷的解决方案,也就是其所谓的最佳实践。

一个设计模式就是一个可重用的方案,可应用于在软件设计中常见的问题,在本次分享主题中,就是编写JavaScript的web应用程序中常见的问题,设计模式的另一种解释就是一个我们如何解决问题的模板。那些在许多不同但类似的情况下使用的模板。

2. 为什么要学习设计模式

JavaScript是一门以原型为基础,面向对象的,动态数据类型语言。在把函数视为第一公民,支持函数式编程的同时也不排斥面向对象的开发方式,甚至在ES6+的标准中还引入了面向对象的一些原生支持。这使得JavaScript成为一门功能强大的语言同时也导致了编程风格的碎片化,同一个功能实现的多样性。对于一些传统的、强面向对象的设计模式会有各种类型的实现,有时候会让人感觉牵强。但是这些并不妨碍我们使用JavaScript来表达设计模式的理念、所要解决的问题以及它的核心思想,这才是我们所要关注的核心。

设计模式可以让我们站在巨人的肩膀上,获得前人的经验,保证我们以优雅的方式组织我们的代码并满足我们解决问题所需要的条件。

内容

一、设计原则

设计原则是指导思想,是我们在程序设计中尽可能要遵守的准则。设计模式就是这些设计原则的一些具体实现,所要达到的目标就是高内聚低耦合。在这里我简单介绍一些六大设计原则中的单一职责原则(SPR)、开放封闭原则(OCP)、最少知识原则(LKP)。

1. 单一职责原则

__单一职责原则__指的是一个类应该仅有一个引起它变化的原因,也就是说一个对象只做一件事情。这样做可以让我们对对象的维护变得简单,如果一个对象拥有多种职责,职责之间相互耦合,对一个职责的修改势必会影响到其他职责。也就是说,一个对象负责的职责越多,耦合越强,对模块的修改就越危险。

2. 开放封闭原则

__开放封闭原则__指的是一个模块应该在对扩展开放,而对修改封闭。当需要修改增加需求的时候,应该尽量通过扩展新代码的方式,而不是修改已有的代码。因为修改已有代码会给依赖原有代码的模块带来隐患,从而需要把依赖原有代码的模块重新测试一遍,加重测试成本。

3. 最少知识原则

__最少知识原则__指的是一个类应该对自己需要耦合或调用的类了解得尽可能少,调用者或依赖着仅需要知道他所需要的方法即可,其他的概不关心。因为类与类之间的关系越密切,耦合性越高,当一个类发生改变时,对另一个类的影响也越大。通常我们减少对象之间的联系的方法是引入一个第三者来帮助通信,阻隔对象之间的直接通信,从而减少耦合。

二、设计模式的分类

设计模式可以被分成几个不同的种类:

  • 创建型设计模式

__创建型设计模式__关注的是对象创建的机制方法,一般会把对象的创建和使用分离,从而帮助创建类的实例对象。属于这一类的设计模式主要有:构造器模式、工厂模式、单例模式、建造者模式等。

  • 结构型设计模式

__结构型设计模式__关注对象组成以及不同对象之间的关系。这类模式有助于在系统的某一部分发生变化时减少对整个系统结构的改变。主要包括:代理模式、享元模式、外观模式、适配器模式、装饰者模式等。

  • 行为型设计模式

__行为型设计模式__关注对象之间的通信,描述对象之间如何相互协作。主要包括:发布订阅模式,策略模式,状态模式,迭代器模式,命令模式,职责链模式,中介者模式等。

三、设计模式示例

1. 单例模式

单例模式(Singleton Pattern)属于创建型设计模式,它限制一个类只能有一个实例化对象,并提供一个访问它的全局访问点。

单例模式可能是最简单的设计模式了,虽然简单,但在实际项目开发中是很常用的一种模式。

单例模式中有几个需要知道的概念:

  • Singleton:特定的类,也就是我们需要访问的类,访问者要拿到的就是它的实例。
  • Instance: 单例,是特定类的唯一实例。
  • getInstance: 获取单例的方法。
代码示例
var GameManager = (function () {
  // 单例
  var instance;
  function init() {
    // 私有变量和方法
    var _saveData = {
      name: 'glenn',
      level: 1
    };
    function _privateMethod(){
        console.log( "I am private function" );
    }
    return {
      // 公有变量和方法
      levelUp: function(){
        _saveData.level ++;
      },
      getCurLevel: function(){
        return _saveData.level;
      },
      getName: function(){
        return _saveData.name;
      },
      publicProperty: "this is a public prop",
    };
  };
  return {
    // 如果存在获取此单例实例,如果不存在创建一个单例实例
    getInstance: function () {
      if ( !instance ) {
        instance = init();
      }
      return instance;
    }
  };
})();

// 使用:
var singleA = GameManager.getInstance();
singleA.levelUp();
var singleB = GameManager.getInstance();
console.log( singleA.getCurLevel() === singleB.getCurLevel() ); // true

在本例中,GameManager是一个单例类,我们首先使用立即调用函数IIFE把希望隐藏的单例示例instance隐藏起来,在init方法中定义该单例类的公有和私有方法变量,然后返回一个对象,把获取单例实例的方法getInstance暴露出去。在getInstance方法中,通过JavaScript的闭包特性把单例实例instance存进闭包中,在第一次获取实例时才初始化单例,并在之后的获取操作中返回的都是这个相同的实例。

可以看到,在使用单例的代码中,我们调用了两次getInstance获取的两个对象singleA和singleB指向的是同一个对象。

源码中的单例模式

以 ElementUI 为例,ElementUI中的全屏Loading蒙层使用服务的形式调用的使用方式示意:

Vue.prototype.$loading = service;
this.$loading({ fullscreen: true });

我们可以看看这个loading在ElementUI2.9.2源码中是如何实现的。

下面是为了方便观看省略了部分代码后的源码

import Vue from 'vue';
import loadingVue from './loading.vue';

const LoadingConstructor = Vue.extend(loadingVue);

//...

//单例
let fullscreenLoading;

LoadingConstructor.prototype.originalPosition = '';
LoadingConstructor.prototype.originalOverflow = '';
LoadingConstructor.prototype.close = function() {
  //...
};
const addStyle = (options, parent, instance) => {
  //...
};

const Loading = (options = {}) => {

    //...
  
    //判断示例是否已经初始化
    if (options.fullscreen && fullscreenLoading) {
    return fullscreenLoading;
    }

    //一系列的初始化操作
    let parent = options.body ? document.body : options.target;
    let instance = new LoadingConstructor({
    el: document.createElement('div'),
    data: options
    });
    addStyle(options, parent, instance);
    if (instance.originalPosition !== 'absolute' && instance.originalPosition !== 'fixed') {
    addClass(parent, 'el-loading-parent--relative');
    }
    if (options.fullscreen && options.lock) {
    addClass(parent, 'el-loading-parent--hidden');
    }
    parent.appendChild(instance.$el);
    Vue.nextTick(() => {
    instance.visible = true;
    });
    
    //把初始化出来的实例缓存下来
    if (options.fullscreen) {
    fullscreenLoading = instance;
    }
    return instance;
};

export default Loading;

这里的单例是fullscreenLoading,缓存在闭包中。当用户调用时传入的options中fullscreen为true且之前已经创建并初始化过单例的情况下直接返回之前创建的单例,否则继续执行后面的初始化操作,并把创建的单例赋值给闭包中的fullscreenLoading后返回新创建的单例。

这是一个典型的单例模式应用,通过复用之前创建的全屏加载蒙层单例,不仅减少了重复实例化过程带来的额外开销,还保证了页面中不会出现重复的全屏加载蒙层。

单例模式的应用场景
  1. 当项目中需要一个公共的状态管理时,我们可以引入单例模式来确保访问的一致性。
  2. 当项目中存在一些同一时间只会出现一个且会重复出现的对象时,我们可以引入单例模式避免重复创建对象产生的多余开销,例如项目中的弹窗,消息框提醒等。

2. 外观模式

__外观模式__又叫门面模式,属于结构型模式,它将子系统的一系列复杂的接口集成起来组成一个更高级别的更舒适的高层接口,从而隐藏其真正的潜在复杂性,对外提供一个一致的外观。

外观模式让外界减少对子系统的直接交互,从而降低耦合,让外界可以轻松使用子系统,其本质是__封装交互,简化调用__。

代码示例
var module = (function() {

	var _sportsman = {
		speed: 5,
		height: 10,
		set : function(key, val) {
			this[key] = val;
		},
		run : function() {
			console.log('运动呀正在以'+this.speed+'米每秒的速度向前跑着。');
		},
		jump: function(){
			console.log( "运动员往上跳了"+this.height+'米');
		}
	};

	return {
		facade : function( args ) {
			args.speed != undefined && _sportsman.set('speed', args.speed);
			args.height != undefined && _sportsman.set('height', args.height);
			args.run && _sportsman.run();
			args.jump && _sportsman.jump();
		}
	};
}());

// Outputs: 运动呀正在以10米每秒的速度向前跑着。
// 			运动员往上跳了5米
module.facade( {run: true, speed: 10, jump: true, height: 5} );

这是表达外观模式一个简单的例子。在本例中,调用module的门面方法facede会触发运动员对象_sportsman中的一系列私有方法。但在这一次,用户不需要关心运动员对象内部方法的实现,就可以让运动员动起来。

源码中的外观模式

当我们使用Jquery的$(document).ready()来给浏览器加载完成添加事件回调时,Jquery会调用源码中的私有方法:

// ...

bindReady: function() {

		//...

		// Mozilla, Opera and webkit nightlies currently support this event
		if ( document.addEventListener ) {
			// Use the handy event callback
			document.addEventListener( "DOMContentLoaded", DOMContentLoaded, false );

			// A fallback to window.onload, that will always work
			window.addEventListener( "load", jQuery.ready, false );

		// If IE event model is used
		} else if ( document.attachEvent ) {
			// ensure firing before onload,
			// maybe late but safe also for iframes
			document.attachEvent( "onreadystatechange", DOMContentLoaded );

			// A fallback to window.onload, that will always work
			window.attachEvent( "onload", jQuery.ready );

			// If IE and not a frame
			// continually check to see if the document is ready
			var toplevel = false;

			try {
				toplevel = window.frameElement == null;
			} catch(e) {}

			if ( document.documentElement.doScroll && toplevel ) {
				doScrollCheck();
			}
		}
	}
	
// ...

由于IE9之前的IE版本浏览器以及Opera7.0之前的Opera浏览器不支持addEventListener方法,在需要适配这些浏览器的项目中,我们需要自己手动判断浏览器版版本来决定使用什么事件绑定方法以及事件。而如果使用了Jquery库中提供的这个外观方法,用户则不需要关心浏览器的兼容问题,使用一致的外观接口$(document).ready()就可以实现监听浏览器加载完成事件的功能,从而简化了使用。

除了抹平浏览器的兼容性问题之外,Jquery还有一些其他的外观模式的应用:

  • 比如设置或获取dom结点的内容和属性时使用的text()、html()和val()方法时,Jquery判断调用方法是否有传参数来确定是设置还是获取操作。这里Jquery把设置和获取操作对外提供了同一个外观接口,使调用简化了不少。
  • 再比如Jquery的ajax的API$.ajax(url[,settings]),当我们需要设置以JSONP的形式发送请求时,只需要传入dataType: 'jsonp'设置,jquery会进行额外的操作帮我们启动JSONP流程,而不需要调用者添加额外的代码。
外观模式的适用场景
  1. 维护设计粗糙和难以理解的上古系统,或者非常复杂的一些系统时,可以为这些系统设置一个外观模块,给外界提供清晰的接口,以后的新系统只需要与外观接口交互即可。
  2. 构建多层系统时,可以使用外观模式来将系统分层,让外观接口成为每一层的入口,简化层间调用,给层间松耦。
  3. 团队协作时,可以将各自负责的模块建立合适的外观,简化其他同事的使用,节约沟通时间。

发布订阅者模式

发布 - 订阅模式(Publish-Subscribe Pattern, pub-sub)又叫观察者模式(Observer Pattern),属于行为型模式,它定义了一种一对多的关系,让多个订阅者对象同时监听某一个发布者,或者叫主题对象,这个主题对象的状态发生变化时就会通知所有订阅自己的订阅者对象,使得它们能够自动更新自己。

发布 - 订阅模式有几个主要概念:

  1. Publisher:发布者,当消息发生时负责通知对应订阅者
  2. Subscriber:订阅者,当消息发生时被通知的对象
  3. SubscriberMap:以type为主键存储数组,每个数组存储所有对应type的订阅者
  4. type: 消息类型,订阅者可以订阅的不同消息类型
  5. subscribe:该方法可以将订阅者添加到SubscriberMap中对应的数组中
  6. unSubscribe:该方法为SubscriberMap中删除订阅者
  7. notify:该方法遍历通知SubscriberMap中对应type的所有订阅者
代码示例
var Publisher = (function() {
    var _subsMap = {}   // 存储订阅者
    return {
        /* 消息订阅 */
        subscribe(type, cb) {
            if(_subsMap[type]){
                if (!_subsMap[type].includes(cb)){
					_subsMap[type].push(cb);
				}
            }else{
				_subsMap[type] = [cb];
			} 
        },
        /* 消息退订 */
        unsubscribe(type, cb) {
            if(!_subsMap[type] || !_subsMap[type].includes(cb))return;
			var idx = _subsMap[type].indexOf(cb);
            _subsMap[type].splice(idx, 1);
        },
        /* 消息发布 */
        notify(type) {
			if (!_subsMap[type])return;
			var args = Array.prototype.slice.call(arguments, 1);
            _subsMap[type].forEach(function(cb){
				cb.apply(this, args);
			})
        }
    }
})()

Publisher.subscribe('运动鞋', function(message){console.log('111' + message)});   // 订阅运动鞋
Publisher.subscribe('运动鞋', function(message){console.log('222' + message)});
Publisher.subscribe('帆布鞋', function(message){console.log('333' + message)});    // 订阅帆布鞋

Publisher.notify('运动鞋', ' 运动鞋到货了 ~')   // 打电话通知买家运动鞋消息
Publisher.notify('帆布鞋', ' 帆布鞋售罄了 T.T') // 打电话通知买家帆布鞋消息

这是一个发布-订阅模式的通用代码实现,Publisher就是一个发布者,这里使用了立即调用函数IIFE方式来将不希望被外界调用的_subsMap隐藏。订阅者采用回调函数的形式,在消息发布时使用JavaScript的apply、call函数使发布的消息参数可以传到订阅者回调函数中去。

源码中的发布-订阅模式

我们使用Jquery的API可以轻松实现消息的订阅、发布以及退订操作:

function eventHandler() {
    console.log('自定义方法')
}

/* ---- 事件订阅 ---- */
$('#app').on('myevent', eventHandler)
// 发布
$('#app').trigger('myevent') // 输出:自定义方法

/* ---- 取消订阅 ---- */
$('#app').off('myevent')
$('#app').trigger('myevent') // 没有输出

对应api源码参见: event.js

其中add方法为on接口的内部直接绑定方法,remove方法对应off接口的内部实现。

发布-订阅模式的优缺点

发布-订阅模式最大优点就是解耦:

  1. 时间上的解耦:注册事件后,订阅者不需要持续关注发布者的动态,当事件触发时,发布者会通知对应的订阅者,调用对应的回调函数。
  2. 对象间的解耦: 发布者不需要提前知道事件的订阅者有哪些,当事件发生时直接遍历对应的订阅者回调函数来通知订阅者,从而解耦了发布者和订阅者之间的联系,使它们之间互不持有。

发布-订阅模式也有一些缺点:

  1. 增加消耗:创建结构和缓存订阅者两个过程都会消耗计算和内存资源,即时订阅后没有触发过,订阅者使用会存在内存中。
  2. 增加复杂度 :订阅者被缓存在一起,如果多个订阅者和发布者层层嵌套,那么程序将变得难以追踪和调试,参考一下 Vue 调试的时候你点开原型链时看到的那堆 deps/subs/watchers 们…

总结

设计模式能够让我站在巨人的肩膀上,享受其他开发者们长期以来在一些有挑战性问题上的解决方案以及优秀的架构。

对我们来讲,知道有这些设计模式是很重要的,但更重要的是应该知道怎样以及什么时候去使用它们。遵守设计原则,使用设计模式是好事,但是过犹不及,在实际项目中我们不能刻板的遵守这些设计原则以及使用设计模式,在想使用每个模式前先去了解下它的优缺点。要真正的理解模式能给你带来什么好处需要花时间去尝试,以实际情况中模式给你的程序带来的好处作为标准来选择。

回到顶部