JavaScript设计模式总汇

前端达人

共 41569字,需浏览 84分钟

 ·

2021-01-18 21:50

来源 | http://www.fly63.com/article/detial/10063


设计模式简介:

设计模式是可重用的用于解决软件设计中一般问题的方案。设计模式如此让人着迷,以至在任何编程语言中都有对其进行的探索。
其中一个原因是它可以让我们站在巨人的肩膀上,获得前人所有的经验,保证我们以优雅的方式组织我们的代码,满足我们解决问题所需要的条件。
设计模式同样也为我们描述问题提供了通用的词汇。这比我们通过代码来向别人传达语法和语义性的描述更为方便。
下面介绍一些JavaScript里用到的设计模式:

1、构造器模式

在面向对象编程中,构造器是一个当新建对象的内存被分配后,用来初始化该对象的一个特殊函数。在JavaScript中几乎所有的东西都是对象,我们经常会对对象的构造器十分感兴趣。
对象构造器是被用来创建特殊类型的对象的,首先它要准备使用的对象,其次在对象初次被创建时,通过接收参数,构造器要用来对成员的属性和方法进行赋值。

1.1创建对象

// 第一种方式let obj = {};// 第二种方式let obj2 = Object.create( null );// 第三种方式let obj3 = new Object();

1.2设置对象的属性和方法

// 1. “点号”法// 设置属性obj.firstKey = "Hello World";// 获取属性let key = obj.firstKey;// 2. “方括号”法// 设置属性obj["firstKey"] = "Hello World";// 获取属性let key = newObject["firstKey"];// 方法1和2的区别在于用方括号的方式内可以写表达式// 3. Object.defineProperty方式// 设置属性Object.defineProperty(obj, "firstKey", {    value: "hello world",// 属性的值,默认为undefined    writable: true, // 是否可修改,默认为false    enumerable: true,// 是否可枚举(遍历),默认为false    configurable: true // 表示对象的属性是否可以被删除,以及除 value 和 writable 特性外的其他特性是否可以被修改。});// 如果上面的方式你感到难以阅读,可以简短的写成下面这样:let defineProp = function ( obj, key, value ){  let config = {};  config.value = value;  Object.defineProperty( obj, key, config );};// 4. Object.defineProperties方式(同时设置多个属性)// 设置属性Object.defineProperties( obj, {  "firstKey": {     value: "Hello World",     writable: true   },  "secondKey": {     value: "Hello World2",     writable: false   }});

1.3创建构造器

Javascript不支持类的概念,但它有一种与对象一起工作的构造器函数。使用new关键字来调用该函数,我们可以告诉Javascript把这个函数当做一个构造器来用,它可以用自己所定义的成员来初始化一个对象。

在这个构造器内部,关键字this引用到刚被创建的对象。回到对象创建,一个基本的构造函数看起来像这样:

function Car( model, year, miles ) {  this.model = model;  this.year = year;  this.miles = miles;  this.toString = function () {    return this.model + " has done " + this.miles + " miles";  };}// 使用:// 我们可以示例化一个Carlet civic = new Car( "Honda Civic", 2009, 20000 );let mondeo = new Car( "Ford Mondeo", 2010, 5000 );// 打开浏览器控制台查看这些对象toString()方法的输出值// output of the toString() method being called on// these objectsconsole.log( civic.toString() );console.log( mondeo.toString() );

上面是简单版本的构造器模式,但它还是有些问题。一个是难以继承,另一个是每个Car构造函数创建的对象中,toString()之类的函数都被重新定义。这不是非常好,理想的情况是所有Car类型的对象都应该引用同一个函数。 

在Javascript中函数有一个prototype的属性。当我们调用Javascript的构造器创建一个对象时,构造函数prototype上的属性对于所创建的对象来说都看见。照这样,就可以创建多个访问相同prototype的Car对象了。下面,我们来扩展一下原来的例子:

function Car( model, year, miles ) {  this.model = model;  this.year = year;  this.miles = miles;}Car.prototype.toString = function () {  return this.model + " has done " + this.miles + " miles";};// 使用:var civic = new Car( "Honda Civic", 2009, 20000 );var mondeo = new Car( "Ford Mondeo", 2010, 5000 );console.log( civic.toString() );console.log( mondeo.toString() );

通过上面代码,单个toString()实例被所有的Car对象所共享了。

2、模块化模式

模块是任何健壮的应用程序体系结构不可或缺的一部分,特点是有助于保持应用项目的代码单元既能清晰地分离又有组织。

在JavaScript中,实现模块有几个选项,他们包括:

  • 模块化模式

  • 对象表示法

  • AMD模块

  • Commonjs 模块

  • ECMAScript Harmony 模块

2.1对象字面值

对象字面值不要求使用新的操作实例,但是不能够在结构体开始使用,因为打开"{"可能被解释为一个块的开始。

let myModule = {  myProperty: "someValue",  // 对象字面值包含了属性和方法(properties and methods).  // 例如,我们可以定义一个模块配置进对象:  myConfig: {    useCaching: true,    language: "en"  },  // 非常基本的方法  myMethod: function () {    console.log( "Where in the world is Paul Irish today?" );  },  // 输出基于当前配置configuration的一个值  myMethod2: function () {    console.log( "Caching is:" + ( this.myConfig.useCaching ) ? "enabled" : "disabled" );  },  // 重写当前的配置(configuration)  myMethod3: function( newConfig ) {    if ( typeof newConfig === "object" ) {      this.myConfig = newConfig;      console.log( this.myConfig.language );    }  }};myModule.myMethod();// Where in the world is Paul Irish today?myModule.myMethod2();// enabledmyModule.myMethod3({  language: "fr",  useCaching: false});// fr

2.2模块化模式

模块化模式最初被定义为一种对传统软件工程中的类提供私有和公共封装的方法。

在JavaScript中,模块化模式用来进一步模拟类的概念,通过这样一种方式:我们可以在一个单一的对象中包含公共/私有的方法和变量,从而从全局范围中屏蔽特定的部分。

这个结果是可以减少我们的函数名称与在页面中其他脚本区域定义的函数名称冲突的可能性。

模块模式使用闭包的方式来将"私有信息",状态和组织结构封装起来。提供了一种将公有和私有方法,变量封装混合在一起的方式,这种方式防止内部信息泄露到全局中,从而避免了和其它开发者接口发生冲图的可能性。

在这种模式下只有公有的API 会返回,其它将全部保留在闭包的私有空间中。

这种方法提供了一个比较清晰的解决方案,在只暴露一个接口供其它部分使用的情况下,将执行繁重任务的逻辑保护起来。这个模式非常类似于立即调用函数式表达式(IIFE-查看命名空间相关章节获取更多信息),但是这种模式返回的是对象,而立即调用函数表达式返回的是一个函数。

需要注意的是,在javascript事实上没有一个显式的真正意义上的"私有性"概念,因为与传统语言不同,javascript没有访问修饰符。从技术上讲,变量不能被声明为公有的或者私有的,因此我们使用函数域的方式去模拟这个概念。

在模块模式中,因为闭包的缘故,声明的变量或者方法只在模块内部有效。在返回对象中定义的变量或者方法可以供任何人使用。

let testModule = (function () {  let counter = 0;  return {    incrementCounter: function () {      return counter++;    },    resetCounter: function () {      console.log( "counter value prior to reset: " + counter );      counter = 0;    }  };})();testModule.incrementCounter();testModule.resetCounter();

在这里我们看到,其它部分的代码不能直接访问我们的incrementCounter() 或者 resetCounter()的值。counter变量被完全从全局域中隔离起来了,因此其表现的就像一个私有变量一样,它的存在只局限于模块的闭包内部,因此只有两个函数可以访问counter。

我们的方法是有名字空间限制的,因此在我们代码的测试部分,我们需要给所有函数调用前面加上模块的名字(例如"testModule")。

当使用模块模式时,我们会发现通过使用简单的模板,对于开始使用模块模式非常有用。下面是一个模板包含了命名空间,公共变量和私有变量。

let myNamespace = (function () {  let myPrivateVar, myPrivateMethod;  myPrivateVar = 0;  myPrivateMethod = function( foo ) {      console.log( foo );  };  return {    myPublicVar: "foo",    myPublicFunction: function( bar ) {      myPrivateVar++;      myPrivateMethod( bar );    }  };})();

看一下另外一个例子,下面我们看到一个使用这种模式实现的购物车。这个模块完全自包含在一个叫做basketModule 全局变量中。

模块中的购物车数组是私有的,应用的其它部分不能直接读取。只存在与模块的闭包中,因此只有可以访问其域的方法可以访问这个变量。

let basketModule = (function () {  let basket = [];  function doSomethingPrivate() {    //...  }  function doSomethingElsePrivate() {    //...  }  return {    addItem: function( values ) {      basket.push(values);    },    getItemCount: function () {      return basket.length;    },    doSomething: doSomethingPrivate,    getTotal: function () {      let q = this.getItemCount(),          p = 0;      while (q--) {        p += basket[q].price;      }      return p;    }  };}());

上面的方法都处于basketModule 的名字空间中。

请注意在上面的basket模块中 域函数是如何在我们所有的函数中被封装起来的,以及我们如何立即调用这个域函数,并且将返回值保存下来。这种方式有以下的优势:

  • 可以创建只能被我们模块访问的私有函数。这些函数没有暴露出来(只有一些API是暴露出来的),它们被认为是完全私有的。

  • 当我们在一个调试器中,需要发现哪个函数抛出异常的时候,可以很容易的看到调用栈,因为这些函数是正常声明的并且是命名的函数。

  • 这种模式同样可以让我们在不同的情况下返回不同的函数。我见过有开发者使用这种技巧用于执行测试,目的是为了在他们的模块里面针对IE专门提供一条代码路径,但是现在我们也可以简单的使用特征检测达到相同的目的。

2.3Import mixins(导入混合)

这个变体展示了如何将全局(例如 jQuery, Underscore)作为一个参数传入模块的匿名函数。这种方式允许我们导入全局,并且按照我们的想法在本地为这些全局起一个别名。

let myModule = (function ( jQ, _ ) {    function privateMethod1(){        jQ(".container").html("test");    }    function privateMethod2(){      console.log( _.min([10, 5, 100, 2, 1000]) );    }    return{        publicMethod: function(){            privateMethod1();                       }               };}( jQuery, _ ));// 将JQ和lodash导入myModule.publicMethod();

2.4Exports(导出)

这个变体允许我们声明全局对象而不用使用它们。

let myModule = (function () {  let module = {},    privateVariable = "Hello World";  function privateMethod() {    // ...  }  module.publicProperty = "Foobar";  module.publicMethod = function () {    console.log( privateVariable );  };  return module;}());

2.5其它框架特定的模块模式实现

Dojo:

Dojo提供了一个方便的方法 dojo.setObject() 来设置对象。这需要将以"."符号为第一个参数的分隔符,如:myObj.parent.child 是指定义在"myOjb"内部的一个对象“parent”,它的一个属性为"child"。

使用setObject()方法允许我们设置children 的值,可以创建路径传递过程中的任何对象即使这些它们根本不存在。

例如,如果我们声明商店命名空间的对象basket.coreas,可以使用如下方式:

let store = window.store || {};
if ( !store["basket"] ) { store.basket = {};}
if ( !store.basket["core"] ) { store.basket.core = {};}
store.basket.core = { key:value,};

Extjs:

// create namespaceExt.namespace("myNameSpace");// create applicationmyNameSpace.app = function () {  // do NOT access DOM from here; elements don't exist yet  // private variables  let btn1,      privVar1 = 11;  // private functions  let btn1Handler = function ( button, event ) {      console.log( "privVar1=" + privVar1 );      console.log( "this.btn1Text=" + this.btn1Text );    };  // public space  return {    // public properties, e.g. strings to translate    btn1Text: "Button 1",    // public methods    init: function () {      if ( Ext.Ext2 ) {        btn1 = new Ext.Button({          renderTo: "btn1-ct",          text: this.btn1Text,          handler: btn1Handler        });      } else {        btn1 = new Ext.Button( "btn1-ct", {          text: this.btn1Text,          handler: btn1Handler        });      }    }  };}();

jQuery:

因为jQuery编码规范没有规定插件如何实现模块模式,因此有很多种方式可以实现模块模式。Ben Cherry 之间提供一种方案,因为模块之间可能存在大量的共性,因此通过使用函数包装器封装模块的定义。

在下面的例子中,定义了一个library 函数,这个函数声明了一个新的库,并且在新的库(例如 模块)创建的时候,自动将初始化函数绑定到document的ready上。

function library( module ) {  $( function() {    if ( module.init ) {      module.init();    }  });  return module;}let myLibrary = library(function () {  return {    init: function () {      // module implementation    }  };}());

优点:

既然我们已经看到单例模式很有用,为什么还是使用模块模式呢?首先,对于有面向对象背景的开发者来讲,至少从javascript语言上来讲,模块模式相对于真正的封装概念更清晰。

其次,模块模式支持私有数据-因此,在模块模式中,公共部分代码可以访问私有数据,但是在模块外部,不能访问类的私有部分(没开玩笑!感谢David Engfer 的玩笑)。

缺点:

模块模式的缺点是因为我们采用不同的方式访问公有和私有成员,因此当我们想要改变这些成员的可见性的时候,我们不得不在所有使用这些成员的地方修改代码。

我们也不能在对象之后添加的方法里面访问这些私有变量。也就是说,很多情况下,模块模式很有用,并且当使用正确的时候,潜在地可以改善我们代码的结构。

其它缺点包括不能为私有成员创建自动化的单元测试,以及在紧急修复bug时所带来的额外的复杂性。根本没有可能可以对私有成员打补丁。

相反地,我们必须覆盖所有的使用存在bug私有成员的公共方法。开发者不能简单的扩展私有成员,因此我们需要记得,私有成员并非它们表面上看上去那么具有扩展性。

3、单例模式

单例模式之所以这么叫,是因为它限制一个类只能有一个实例化对象。经典的实现方式是,创建一个类,这个类包含一个方法,这个方法在没有对象存在的情况下,将会创建一个新的实例对象。如果对象存在,这个方法只是返回这个对象的引用。

在JavaScript语言中, 单例服务作为一个从全局空间的代码实现中隔离出来共享的资源空间是为了提供一个单独的函数访问指针。

我们能像这样实现一个单例:

let mySingleton = (function () {  // Instance stores a reference to the Singleton  let instance;  function init() {    // 单例    // 私有方法和变量    function privateMethod(){        console.log( "I am private" );    }    let privateVariable = "Im also private";    let privateRandomNumber = Math.random();    return {      // 共有方法和变量      publicMethod: function () {        console.log( "The public can see me!" );      },      publicProperty: "I am also public",      getRandomNumber: function() {        return privateRandomNumber;      }    };  };  return {    // 如果存在获取此单例实例,如果不存在创建一个单例实例    getInstance: function () {      if ( !instance ) {        instance = init();      }      return instance;    }  };})();
let myBadSingleton = (function () { // 存储单例实例的引用 var instance; function init() { // 单例 let privateRandomNumber = Math.random(); return { getRandomNumber: function() { return privateRandomNumber; } }; }; return { // 总是创建一个新的实例 getInstance: function () { instance = init(); return instance; } };})();
// 使用:let singleA = mySingleton.getInstance();let singleB = mySingleton.getInstance();console.log( singleA.getRandomNumber() === singleB.getRandomNumber() ); // true
let badSingleA = myBadSingleton.getInstance();let badSingleB = myBadSingleton.getInstance();console.log( badSingleA.getRandomNumber() !== badSingleB.getRandomNumber() ); // true

创建一个全局访问的单例实例 (通常通过 MySingleton.getInstance()) 因为我们不能(至少在静态语言中) 直接调用 new MySingleton() 创建实例. 这在JavaScript语言中是不可能的。

在四人帮(GoF)的书里面,单例模式的应用描述如下:

  • 每个类只有一个实例,这个实例必须通过一个广为人知的接口,来被客户访问。

  • 子类如果要扩展这个唯一的实例,客户可以不用修改代码就能使用这个扩展后的实例。

关于第二点,可以参考如下的实例,我们需要这样编码:

mySingleton.getInstance = function(){  if ( this._instance == null ) {    if ( isFoo() ) {       this._instance = new FooSingleton();    } else {       this._instance = new BasicSingleton();    }  }  return this._instance;};

在这里,getInstance 有点类似于工厂方法,我们不需要去更新每个访问单例的代码。FooSingleton可以是BasicSinglton的子类,并且实现了相同的接口。

尽管单例模式有着合理的使用需求,但是通常当我们发现自己需要在javascript使用它的时候,这是一种信号,表明我们可能需要去重新评估自己的设计。

这通常表明系统中的模块要么紧耦合要么逻辑过于分散在代码库的多个部分。单例模式更难测试,因为可能有多种多样的问题出现,例如隐藏的依赖关系,很难去创建多个实例,很难清理依赖关系,等等。

4、观察者模式

观察者模式是这样一种设计模式:一个被称作被观察者的对象,维护一组被称为观察者的对象,这些对象依赖于被观察者,被观察者自动将自身的状态的任何变化通知给它们。

当一个被观察者需要将一些变化通知给观察者的时候,它将采用广播的方式,这条广播可能包含特定于这条通知的一些数据。

当特定的观察者不再需要接受来自于它所注册的被观察者的通知的时候,被观察者可以将其从所维护的组中删除。在这里提及一下设计模式现有的定义很有必要。这个定义是与所使用的语言无关的。

通过这个定义,最终我们可以更深层次地了解到设计模式如何使用以及其优势。在四人帮的《设计模式:可重用的面向对象软件的元素》这本书中,是这样定义观察者模式的:

一个或者更多的观察者对一个被观察者的状态感兴趣,将自身的这种兴趣通过附着自身的方式注册在被观察者身上。当被观察者发生变化,而这种便可也是观察者所关心的,就会产生一个通知,这个通知将会被送出去,最后将会调用每个观察者的更新方法。当观察者不在对被观察者的状态感兴趣的时候,它们只需要简单的将自身剥离即可。

我们现在可以通过实现一个观察者模式来进一步扩展我们刚才所学到的东西。这个实现包含一下组件:

  • 被观察者:维护一组观察者, 提供用于增加和移除观察者的方法。

  • 观察者:提供一个更新接口,用于当被观察者状态变化时,得到通知。

  • 具体的被观察者:状态变化时广播通知给观察者,保持具体的观察者的信息。

  • 具体的观察者:保持一个指向具体被观察者的引用,实现一个更新接口,用于观察,以便保证自身状态总是和被观察者状态一致的。

首先,让我们对被观察者可能有的一组依赖其的观察者进行建模:

function ObserverList(){  this.observerList = [];}ObserverList.prototype.Add = function( obj ){  return this.observerList.push( obj );};ObserverList.prototype.Empty = function(){  this.observerList = [];};ObserverList.prototype.Count = function(){  return this.observerList.length;};ObserverList.prototype.Get = function( index ){  if( index > -1 && index < this.observerList.length ){    return this.observerList[ index ];  }};ObserverList.prototype.Insert = function( obj, index ){  let pointer = -1;  if( index === 0 ){    this.observerList.unshift( obj );    pointer = index;  }else if( index === this.observerList.length ){    this.observerList.push( obj );    pointer = index;  }  return pointer;};ObserverList.prototype.IndexOf = function( obj, startIndex ){  let i = startIndex, pointer = -1;  while( i < this.observerList.length ){    if( this.observerList[i] === obj ){      pointer = i;    }    i++;  }  return pointer;};ObserverList.prototype.RemoveAt = function( index ){  if( index === 0 ){    this.observerList.shift();  }else if( index === this.observerList.length -1 ){    this.observerList.pop();  }};// Extend an object with an extensionfunction extend( extension, obj ){  for ( let key in extension ){    obj[key] = extension[key];  }}

接着,我们对被观察者以及其增加,删除,通知在观察者列表中的观察者的能力进行建模:

function Subject(){  this.observers = new ObserverList();}Subject.prototype.AddObserver = function( observer ){  this.observers.Add( observer );}; Subject.prototype.RemoveObserver = function( observer ){  this.observers.RemoveAt( this.observers.IndexOf( observer, 0 ) );}; Subject.prototype.Notify = function( context ){  let observerCount = this.observers.Count();  for(let i=0; i < observerCount; i++){    this.observers.Get(i).Update( context );  }};

我们接着定义建立新的观察者的一个框架。这里的update 函数之后会被具体的行为覆盖。

// The Observerfunction Observer(){  this.Update = function(){    // ...  };}

在我们的样例应用里面,我们使用上面的观察者组件,现在我们定义:

  • 一个按钮,这个按钮用于增加新的充当观察者的选择框到页面上

  • 一个控制用的选择框 , 充当一个被观察者,通知其它选择框是否应该被选中

  • 一个容器,用于放置新的选择框

我们接着定义具体被观察者和具体观察者,用于给页面增加新的观察者,以及实现更新接口。通过查看下面的内联的注释,搞清楚在我们样例中的这些组件是如何工作的。

html

Javascript

// 我们DOM 元素的引用let controlCheckbox = document.getElementById("mainCheckbox"),  addBtn = document.getElementById( "addNewObserver" ),  container = document.getElementById( "observersContainer" );// 具体的被观察者//Subject 类扩展controlCheckbox 类extend( new Subject(), controlCheckbox );//点击checkbox 将会触发对观察者的通知controlCheckbox["onclick"] = new Function("controlCheckbox.Notify(controlCheckbox.checked)");addBtn["onclick"] = AddNewObserver;// 具体的观察者function AddNewObserver(){  //建立一个新的用于增加的checkbox  let check  = document.createElement( "input" );  check.type = "checkbox";  // 使用Observer 类扩展checkbox  extend( new Observer(), check );  // 使用定制的Update函数重载  check.Update = function( value ){    this.checked = value;  };  // 增加新的观察者到我们主要的被观察者的观察者列表中  controlCheckbox.AddObserver( check );  // 将元素添加到容器的最后  container.appendChild( check );}

在这个例子里面,我们看到了如何实现和配置观察者模式,了解了被观察者,观察者,具体被观察者,具体观察者的概念。

观察者模式和发布/订阅模式的不同

观察者模式确实很有用,但是在javascript时间里面,通常我们使用一种叫做发布/订阅模式的变体来实现观察者模式。这两种模式很相似,但是也有一些值得注意的不同。

观察者模式要求想要接受相关通知的观察者必须到发起这个事件的被观察者上注册这个事件。

发布/订阅模式使用一个主题/事件频道,这个频道处于想要获取通知的订阅者和发起事件的发布者之间。

这个事件系统允许代码定义应用相关的事件,这个事件可以传递特殊的参数,参数中包含有订阅者所需要的值。这种想法是为了避免订阅者和发布者之间的依赖性。

这种和观察者模式之间的不同,使订阅者可以实现一个合适的事件处理函数,用于注册和接受由发布者广播的相关通知。

这里给出一个关于如何使用发布者/订阅者模式的例子,这个例子中完整地实现了功能强大的publish(), subscribe() 和 unsubscribe()。

// 一个非常简单的邮件处理器// 接受的消息的计数器let mailCounter = 0;// 初始化一个订阅者,这个订阅者监听名叫"inbox/newMessage" 的频道// 渲染新消息的粗略信息let subscriber1 = subscribe( "inbox/newMessage", function( topic, data ) {  // 日志记录主题,用于调试  console.log( "A new message was received: ", topic );  // 使用来自于被观察者的数据,用于给用户展示一个消息的粗略信息  $( ".messageSender" ).html( data.sender );  $( ".messagePreview" ).html( data.body );});// 这是另外一个订阅者,使用相同的数据执行不同的任务// 更细计数器,显示当前来自于发布者的新信息的数量let subscriber2 = subscribe( "inbox/newMessage", function( topic, data ) {  $('.newMessageCounter').html( mailCounter++ );});publish( "inbox/newMessage", [{  sender:"hello@google.com",  body: "Hey there! How are you doing today?"}]);// 在之后,我们可以让我们的订阅者通过下面的方式取消订阅来自于新主题的通知// unsubscribe( subscriber1,  );// unsubscribe( subscriber2 );

这个例子的更广的意义是对松耦合的原则的一种推崇。不是一个对象直接调用另外一个对象的方法,而是通过订阅另外一个对象的一个特定的任务或者活动,从而在这个任务或者活动出现的时候的得到通知。

优点

观察者和发布/订阅模式鼓励人们认真考虑应用不同部分之间的关系,同时帮助我们找出这样的层,该层中包含有直接的关系,这些关系可以通过一些列的观察者和被观察者来替换掉。

这中方式可以有效地将一个应用程序切割成小块,这些小块耦合度低,从而改善代码的管理,以及用于潜在的代码复用。

使用观察者模式更深层次的动机是,当我们需要维护相关对象的一致性的时候,我们可以避免对象之间的紧密耦合。例如,一个对象可以通知另外一个对象,而不需要知道这个对象的信息。

两种模式下,观察者和被观察者之间都可以存在动态关系。这提供很好的灵活性,而当我们的应用中不同的部分之间紧密耦合的时候,是很难实现这种灵活性的。

尽管这些模式并不是万能的灵丹妙药,这些模式仍然是作为最好的设计松耦合系统的工具之一,因此在任何的JavaScript 开发者的工具箱里面,都应该有这样一个重要的工具。

缺点

事实上,这些模式的一些问题实际上正是来自于它们所带来的一些好处。在发布/订阅模式中,将发布者共订阅者上解耦,将会在一些情况下,导致很难确保我们应用中的特定部分按照我们预期的那样正常工作。

例如,发布者可以假设有一个或者多个订阅者正在监听它们。比如我们基于这样的假设,在某些应用处理过程中来记录或者输出错误日志。如果订阅者执行日志功能崩溃了(或者因为某些原因不能正常工作),因为系统本身的解耦本质,发布者没有办法感知到这些事情。

另外一个这种模式的缺点是,订阅者对彼此之间存在没有感知,对切换发布者的代价无从得知。因为订阅者和发布者之间的动态关系,更新依赖也很能去追踪。

让我们看一下最小的一个版本的发布/订阅模式实现。这个实现展示了发布,订阅的核心概念,以及如何取消订阅。

let pubsub = {};(function(q) {    let topics = {},        subUid = -1;    q.publish = function( topic, args ) {        if ( !topics[topic] ) {            return false;        }        let subscribers = topics[topic],            len = subscribers ? subscribers.length : 0;        while (len--) {            subscribers[len].func( topic, args );        }        return this;    };    q.subscribe = function( topic, func ) {        if (!topics[topic]) {            topics[topic] = [];        }        let token = ( ++subUid ).toString();        topics[topic].push({            token: token,            func: func        });        return token;    };    q.unsubscribe = function( token ) {        for ( let m in topics ) {            if ( topics[m] ) {                for ( let i = 0, j = topics[m].length; i < j; i++ ) {                    if ( topics[m][i].token === token) {                        topics[m].splice( i, 1 );                        return token;                    }                }            }        }        return this;    };}( pubsub ));

我们现在可以使用发布实例和订阅感兴趣的事件,例如:

let messageLogger = function ( topics, data ) {    console.log( "Logging: " + topics + ": " + data );};let subscription = pubsub.subscribe( "inbox/newMessage", messageLogger );pubsub.publish( "inbox/newMessage", "hello world!" );// orpubsub.publish( "inbox/newMessage", ["test", "a", "b", "c"] );// orpubsub.publish( "inbox/newMessage", {  sender: "hello@google.com",  body: "Hey again!"});// We cab also unsubscribe if we no longer wish for our subscribers// to be notified// pubsub.unsubscribe( subscription );pubsub.publish( "inbox/newMessage", "Hello! are you still there?" );

观察者模式在应用设计中,解耦一系列不同的场景上非常有用,如果你没有用过它,我推荐你尝试一下今天提到的之前写到的某个实现。这个模式是一个易于学习的模式,同时也是一个威力巨大的模式。

5、中介者模式

如果系统组件之间存在大量的直接关系,就可能是时候,使用一个中心的控制点,来让不同的组件通过它来通信。中介者通过将组件之间显式的直接的引用替换成通过中心点来交互的方式,来做到松耦合。这样可以帮助我们解耦,和改善组件的重用性。

在现实世界中,类似的系统就是,飞行控制系统。一个航站塔(中介者)处理哪个飞机可以起飞,哪个可以着陆,因为所有的通信(监听的通知或者广播的通知)都是飞机和控制塔之间进行的,而不是飞机和飞机之间进行的。一个中央集权的控制中心是这个系统成功的关键,也正是中介者在软件设计领域中所扮演的角色。

5.1基础的实现

中间人模式的一种简单的实现可以在下面找到,publish()和subscribe()方法都被暴露出来使用:

let mediator = (function(){    let topics = {};    let subscribe = function( topic, fn ){        if ( !topics[topic] ){          topics[topic] = [];        }        topics[topic].push( { context: this, callback: fn } );        return this;    };    let publish = function( topic ){        let args;        if ( !topics[topic] ){          return false;        }        args = Array.prototype.slice.call( arguments, 1 );        for ( let i = 0, l = topics[topic].length; i < l; i++ ) {            let subscription = topics[topic][i];            subscription.callback.apply( subscription.context, args );        }        return this;    };    return {        publish: publish,        subscribe: subscribe,        installTo: function( obj ){            obj.subscribe = subscribe;            obj.publish = publish;        }    };}());

优点 & 缺点

中间人模式最大的好处就是,它节约了对象或者组件之间的通信信道,这些对象或者组件存在于从多对多到多对一的系统之中。由于解耦合水平的因素,添加新的发布或者订阅者是相对容易的。

也许使用这个模式最大的缺点是它可以引入一个单点故障。在模块之间放置一个中间人也可能会造成性能损失,因为它们经常是间接地的进行通信的。由于松耦合的特性,仅仅盯着广播很难去确认系统是如何做出反应的。

这就是说,提醒我们自己解耦合的系统拥有许多其它的好处,是很有用的——如果我们的模块互相之间直接的进行通信,对于模块的改变(例如:另一个模块抛出了异常)可以很容易的对我们系统的其它部分产生多米诺连锁效应。这个问题在解耦合的系统中很少需要被考虑到。

在一天结束的时候,紧耦合会导致各种头痛,这仅仅只是另外一种可选的解决方案,但是如果得到正确实现的话也能够工作得很好。

6、原型模式

原型模式是指通过克隆的方式基于一个现有对象的模板创建对象的模式。

我们能够将原型模式认作是基于原型的继承中,我们创建作为其它对象原型的对象.原型对象自身被当做构造器创建的每一个对象的蓝本高效的使用着.如果构造器函数使用的原型包含例如叫做name的属性,那么每一个通过同一个构造器创建的对象都将拥有这个相同的属性。

我们可以在下面的示例中看到对这个的展示:

let myCar = {  name: "Ford Escort",  drive: function () {    console.log( "Weeee. I'm driving!" );  },  panic: function () {    console.log( "Wait. How do you stop this thing?" );  }};let yourCar = Object.create( myCar );console.log( yourCar.name );// Ford Escort

Object.create也允许我们简单的继承先进的概念,比如对象能够直接继承自其它对象,这种不同的继承.我们早先也看到Object.create允许我们使用 供应的第二个参数来初始化对象属性。例如:

let vehicle = {  getModel: function () {    console.log( "The model of this vehicle is.." + this.model );  }};let car = Object.create(vehicle, {  "id": {    value: "1",    // writable:false, configurable:false by default    enumerable: true  },  "model": {    value: "Ford",    enumerable: true  }});

这里的属性可以被Object.create的第二个参数来初始化,使用一种类似于Object.defineProperties和Object.defineProperties方法所使用语法的对象字面值。

在枚举对象的属性,和在一个hasOwnProperty()检查中封装循环的内容时,原型关系会造成麻烦,这一事实是值得我们关注的。

如果我们希望在不直接使用Object.create的前提下实现原型模式,我们可以像下面这样,按照上面的示例,模拟这一模式:

let vehiclePrototype = {  init: function ( carModel ) {    this.model = carModel;  },  getModel: function () {    console.log( "The model of this vehicle is.." + this.model);  }};function vehicle( model ) {  function F() {};  F.prototype = vehiclePrototype;  let f = new F();  f.init( model );  return f;}let car = vehicle( "Ford Escort" );car.getModel();

注意:这种可选的方式不允许用户使用相同的方式定义只读的属性(因为如果不小心的话vehicle原型可能会被改变)。

原型模式的最后一种可选实现可以像下面这样:

let beget = (function () {    function F() {}    return function ( proto ) {        F.prototype = proto;        return new F();    };})();

7、命令模式

命名模式的目标是将方法的调用,请求或者操作封装到一个单独的对象中,给我们酌情执行同时参数化和传递方法调用的能力.另外,它使得我们能将对象从实现了行为的对象对这些行为的调用进行解耦,为我们带来了换出具体的对象这一更深程度的整体灵活性。

具体类是对基于类的编程语言的最好解释,并且同抽象类的理念联系紧密。抽象类定义了一个接口,但并不需要提供对它的所有成员函数的实现。它扮演着驱动其它类的基类角色.被驱动类实现了缺失的函数而被称为具体类.。命令模式背后的一般理念是为我们提供了从任何执行中的命令中分离出发出命令的责任,取而代之将这一责任委托给其它的对象。

实现明智简单的命令对象,将一个行为和对象对调用这个行为的需求都绑定到了一起.它们始终都包含一个执行操作(比如run()或者execute()).所有带有相同接口的命令对象能够被简单地根据需要调换,这被认为是命令模式的更大的好处之一。

为了展示命令模式,我们创建一个简单的汽车购买服务:

(function(){  let CarManager = {      requestInfo: function( model, id ){        return "The information for " + model + " with ID " + id + " is foobar";      },      buyVehicle: function( model, id ){        return "You have successfully purchased Item " + id + ", a " + model;      },      arrangeViewing: function( model, id ){        return "You have successfully booked a viewing of " + model + " ( " + id + " ) ";      }    };})();

看一看上面的这段代码,它也许是通过直接访问对象来琐碎的调用我们CarManager的方法。在技术上我们也许都会都会对这个没有任何失误达成谅解.它是完全有效的Javascript然而也会有情况不利的情况。

例如,想象如果CarManager的核心API会发生改变的这种情况.这可能需要所有直接访问这些方法的对象也跟着被修改.这可以被看成是一种耦合,明显违背了OOP方法学尽量实现松耦合的理念.取而代之,我们可以通过更深入的抽象这些API来解决这个问题。

现在让我们来扩展我们的CarManager,以便我们这个命令模式的应用程序得到接下来的这种效果:接受任何可以在CarManager对象上面执行的方法,传送任何可以被使用到的数据,如Car模型和ID。

这里是我们希望能够实现的样子:

CarManager.execute( "buyVehicle", "Ford Escort", "453543" );

按照这种结构,我们现在应该像下面这样,添加一个对于"CarManager.execute()"方法的定义:

CarManager.execute = function ( name ) {    return CarManager[name] && CarManager[name].apply( CarManager, [].slice.call(arguments, 1) );};

最终我们的调用如下所示:

CarManager.execute( "arrangeViewing", "Ferrari", "14523" );CarManager.execute( "requestInfo", "Ford Mondeo", "54323" );CarManager.execute( "requestInfo", "Ford Escort", "34232" );CarManager.execute( "buyVehicle", "Ford Escort", "34232" );

8、外观模式

当我们提出一个门面,我们要向这个世界展现的是一个外观,这一外观可能藏匿着一种非常与众不同的真实。这就是我们即将要回顾的模式背后的灵感——门面模式。

这一模式提供了面向一种更大型的代码体提供了一个的更高级别的舒适的接口,隐藏了其真正的潜在复杂性。

把这一模式想象成要是呈现给开发者简化的API,一些总是会提升使用性能的东西。

为了在我们所学的基础上进行构建,门面模式同时需要简化一个类的接口,和把类同使用它的代码解耦。这给予了我们使用一种方式直接同子系统交互的能力,这一方式有时候会比直接访问子系统更加不容易出错。

门面的优势包括易用,还有常常实现起这个模式来只是一小段路,不费力。

让我们通过实践来看看这个模式。这是一个没有经过优化的代码示例,但是这里我们使用了一个门面来简化跨浏览器事件监听的接口。我们创建了一个公共的方法来实现,此方法能够被用在检查特性的存在的代码中,以便这段代码能够提供一种安全和跨浏览器兼容方案。

let addMyEvent = function( el,ev,fn ){   if( el.addEventListener ){       el.addEventListener( ev,fn, false );   }else if(el.attachEvent){       el.attachEvent( "on" + ev, fn );   }else{       el["on" + ev] = fn;   }};

门面不仅仅只被用在它们自己身上,它们也能够被用来同其它的模式诸如模块模式进行集成。如我们在下面所看到的,我们模块模式的实体包含许多被定义为私有的方法。门面则被用来提供访问这些方法的更加简单的API:

let module = (function() {    let _private = {        i:5,        get : function() {            console.log( "current value:" + this.i);        },        set : function( val ) {            this.i = val;        },        run : function() {            console.log( "running" );        },        jump: function(){            console.log( "jumping" );        }    };    return {        facade : function( args ) {            _private.set(args.val);            _private.get();            if ( args.run ) {                _private.run();            }        }    };}());module.facade( {run: true, val:10} );// "current value: 10" and "running"

在这个示例中,调用module.facade()将会触发一堆模块中的私有方法。但再一次,用户并不需要关心这些。我们已经使得对用户而言不需要担心实现级别的细节就能消受一种特性。

9、工厂模式

工厂模式是另外一种关注对象创建概念的创建模式。它的领域中同其它模式的不同之处在于它并没有明确要求我们使用一个构造器。

取而代之,一个工厂能提供一个创建对象的公共接口,我们可以在其中指定我们希望被创建的工厂对象的类型。

下面我们通过使用构造器模式逻辑来定义汽车。这个例子展示了Vehicle 工厂可以使用工厂模式来实现。

function Car( options ) {  this.doors = options.doors || 4;  this.state = options.state || "brand new";  this.color = options.color || "silver";
}function Truck( options){ this.state = options.state || "used"; this.wheelSize = options.wheelSize || "large"; this.color = options.color || "blue";}function VehicleFactory() {}VehicleFactory.prototype.vehicleClass = Car;VehicleFactory.prototype.createVehicle = function ( options ) { if( options.vehicleType === "car" ){ this.vehicleClass = Car; }else{ this.vehicleClass = Truck; } return new this.vehicleClass( options );
};let carFactory = new VehicleFactory();let car = carFactory.createVehicle( { vehicleType: "car", color: "yellow", doors: 6 } );console.log( car );

何时使用工厂模式

当被应用到下面的场景中时,工厂模式特别有用:

  • 当我们的对象或者组件设置涉及到高程度级别的复杂度时。

  • 当我们需要根据我们所在的环境方便的生成不同对象的实体时。

  • 当我们在许多共享同一个属性的许多小型对象或组件上工作时。

  • 当带有其它仅仅需要满足一种API约定(又名鸭式类型)的对象的组合对象工作时.这对于解耦来说是有用的。

何时不要去使用工厂模式

当被应用到错误的问题类型上时,这一模式会给应用程序引入大量不必要的复杂性.除非为创建对象提供一个接口是我们编写的库或者框架的一个设计上目标,否则我会建议使用明确的构造器,以避免不必要的开销。

由于对象的创建过程被高效的抽象在一个接口后面的事实,这也会给依赖于这个过程可能会有多复杂的单元测试带来问题。

抽象工厂

了解抽象工厂模式也是非常实用的,它的目标是以一个通用的目标将一组独立的工厂进行封装.它将一堆对象的实现细节从它们的一般用例中分离。

抽象工厂应该被用在一种必须从其创建或生成对象的方式处独立,或者需要同多种类型的对象一起工作,这样的系统中。

简单且容易理解的例子就是一个发动机工厂,它定义了获取或者注册发动机类型的方式。抽象工厂会被命名为AbstractVehicleFactory。抽象工厂将允许像"car"或者"truck"的发动机类型的定义,并且构造工厂将仅实现满足发动机合同的类.(例如:Vehicle.prototype.driven和Vehicle.prototype.breakDown)。

let AbstractVehicleFactory = (function () {    let types = {};    return {        getVehicle: function ( type, customizations ) {            var Vehicle = types[type];            return (Vehicle ? new Vehicle(customizations) : null);        },        registerVehicle: function ( type, Vehicle ) {            let proto = Vehicle.prototype;            // only register classes that fulfill the vehicle contract            if ( proto.drive && proto.breakDown ) {                types[type] = Vehicle;            }            return AbstractVehicleFactory;        }    };})();
AbstractVehicleFactory.registerVehicle( "car", Car );AbstractVehicleFactory.registerVehicle( "truck", Truck );
let car = AbstractVehicleFactory.getVehicle( "car" , { color: "lime green", state: "like new" } );
let truck = AbstractVehicleFactory.getVehicle( "truck" , { wheelSize: "medium", color: "neon yellow" } );

10、Mixin 模式

mixin模式指一些提供能够被一个或者一组子类简单继承功能的类,意在重用其功能。

子类划分

子类划分是一个参考了为一个新对象继承来自一个基类或者超类对象的属性的术语.在传统的面向对象编程中,类B能够从另外一个类A处扩展。这里我们将A看做是超类,而将B看做是A的子类。如此,所有B的实体都从A处继承了其A的方法,然而B仍然能够定义它自己的方法,包括那些重载的原本在A中的定义的方法。

B是否应该调用已经被重载的A中的方法,我们将这个引述为方法链.B是否应该调用A(超类)的构造器,我们将这称为构造器链。

为了演示子类划分,首先我们需要一个能够创建自身新实体的基对象。

let Person =  function( firstName , lastName ){  this.firstName = firstName;  this.lastName =  lastName;  this.gender = "male";};

接下来,我们将制定一个新的类(对象),它是一个现有的Person对象的子类.让我们想象我们想要加入一个不同属性用来分辨一个Person和一个继承了Person"超类"属性的Superhero.由于超级英雄分享了一般人类许多共有的特征(例如:name,gender),因此这应该很有希望充分展示出子类划分是如何工作的。

let clark = new Person( "Clark" , "Kent" );let Superhero = function( firstName, lastName , powers ){    Person.call( this, firstName, lastName );    this.powers = powers;};SuperHero.prototype = Object.create( Person.prototype );let superman = new Superhero( "Clark" ,"Kent" , ["flight","heat-vision"] );console.log( superman );

Superhero构造器创建了一个自Peroson下降的对象。这种类型的对象拥有链中位于它之上的对象的属性,而且如果我们在Person对象中设置了默认的值,Superhero能够使用特定于它的对象的值覆盖任何继承的值。

Mixin(织入目标类)

在Javascript中,我们会将从Mixin继承看作是通过扩展收集功能的一种途径.我们定义的每一个新的对象都有一个原型,从其中它可以继承更多的属性.原型可以从其他对象继承而来,但是更重要的是,能够为任意数量的对象定义属性.我们可以利用这一事实来促进功能重用。

Mix允许对象以最小量的复杂性从它们那里借用(或者说继承)功能.作为一种利用Javascript对象原型工作得很好的模式,它为我们提供了从不止一个Mix处分享功能的相当灵活,但比多继承有效得多得多的方式。

它们可以被看做是其属性和方法可以很容易的在其它大量对象原型共享的对象.想象一下我们定义了一个在一个标准对象字面量中含有实用功能的Mixin,如下所示:

let myMixins = {
moveUp: function(){ console.log( "move up" ); },
moveDown: function(){ console.log( "move down" ); },
stop: function(){ console.log( "stop! in the name of love!" ); }
};

然后我们可以方便的扩展现有构造器功能的原型,使其包含这种使用一个 如下面的score.js_.extends()方法辅助器的行为:

function carAnimator(){  this.moveLeft = function(){    console.log( "move left" );  };}function personAnimator(){  this.moveRandomly = function(){ /*..*/ };}_.extend( carAnimator.prototype, myMixins );_.extend( personAnimator.prototype, myMixins );let myAnimator = new carAnimator();myAnimator.moveLeft();myAnimator.moveDown();myAnimator.stop();

如我们所见,这允许我们将通用的行为轻易的"混"入相当普通对象构造器中。

在接下来的示例中,我们有两个构造器:一个Car和一个Mixin.我们将要做的是静Car参数化(另外一种说法是扩展),以便它能够继承Mixin中的特定方法,名叫driveForwar()和driveBackward().这一次我们不会使用Underscore.js。

取而代之,这个示例将演示如何将一个构造器参数化,以便在无需重复每一个构造器函数过程的前提下包含其功能。

let Car = function ( settings ) {    this.model = settings.model || "no model provided";    this.color = settings.color || "no colour provided";};// Mixinlet Mixin = function () {};Mixin.prototype = {    driveForward: function () {        console.log( "drive forward" );    },    driveBackward: function () {        console.log( "drive backward" );    },    driveSideways: function () {        console.log( "drive sideways" );    }};function augment( receivingClass, givingClass ) {    if ( arguments[2] ) {        for ( var i = 2, len = arguments.length; i < len; i++ ) {            receivingClass.prototype[arguments[i]] = givingClass.prototype[arguments[i]];        }    }else {        for ( let methodName in givingClass.prototype ) {            if ( !Object.hasOwnProperty(receivingClass.prototype, methodName) ) {                receivingClass.prototype[methodName] = givingClass.prototype[methodName];            }        }    }}augment( Car, Mixin, "driveForward", "driveBackward" );let myCar = new Car({    model: "Ford Escort",    color: "blue"});myCar.driveForward();myCar.driveBackward();
augment( Car, Mixin );let mySportsCar = new Car({ model: "Porsche", color: "red"});mySportsCar.driveSideways();

优点 & 缺点

Mixin支持在一个系统中降解功能的重复性,增加功能的重用性.在一些应用程序也许需要在所有的对象实体共享行为的地方,我们能够通过在一个Mixin中维护这个共享的功能,来很容易的避免任何重复,而因此专注于只实现我们系统中真正彼此不同的功能。

也就是说,对Mixin的副作用是值得商榷的.一些开发者感觉将功能注入到对象的原型中是一个坏点子,因为它会同时导致原型污染和一定程度上的对我们原有功能的不确定性.在大型的系统中,很可能是有这种情况的。

但是,强大的文档对最大限度的减少对待功能中的混入源的迷惑是有帮助的,而且对于每一种模式而言,如果在实现过程中小心行事,我们应该是没多大问题的。

11、装饰器模式

装饰器是旨在提升重用性能的一种结构性设计模式。同Mixin类似,它可以被看作是应用子类划分的另外一种有价值的可选方案。

典型的装饰器提供了向一个系统中现有的类动态添加行为的能力。其创意是装饰本身并不关心类的基础功能,而只是将它自身拷贝到超类之中。

装饰器模式并不去深入依赖于对象是如何创建的,而是专注于扩展它们的功能这一问题上。不同于只依赖于原型继承,我们在一个简单的基础对象上面逐步添加能够提供附加功能的装饰对象。它的想法是,不同于子类划分,我们向一个基础对象添加(装饰)属性或者方法,因此它会是更加轻巧的。

向Javascript中的对象添加新的属性是一个非常直接了当的过程,因此将这一特定牢记于心,一个非常简单的装饰器可以实现如下:

示例1:带有新功能的装饰构造器

function vehicle( vehicleType ){    this.vehicleType = vehicleType || "car";    this.model = "default";    this.license = "00000-000";}let testInstance = new vehicle( "car" );console.log( testInstance );// vehicle: car, model:default, license: 00000-000
let truck = new vehicle( "truck" );truck.setModel = function( modelName ){ this.model = modelName;};truck.setColor = function( color ){ this.color = color;};truck.setModel( "CAT" );truck.setColor( "blue" );console.log( truck );// vehicle:truck, model:CAT, color: blue
let secondInstance = new vehicle( "car" );console.log( secondInstance );// vehicle: car, model:default, license: 00000-000

示例2:带有多个装饰器的装饰对象

function MacBook() {  this.cost = function () { return 997; };  this.screenSize = function () { return 11.6; };}function Memory( macbook ) {  let v = macbook.cost();  macbook.cost = function() {    return v + 75;  };}function Engraving( macbook ){  let v = macbook.cost();  macbook.cost = function(){    return  v + 200;  };}function Insurance( macbook ){  let v = macbook.cost();  macbook.cost = function(){     return  v + 250;  };}
let mb = new MacBook();Memory( mb );Engraving( mb );Insurance( mb );console.log( mb.cost() );// 1522console.log( mb.screenSize() );// 11.6

在上面的示例中,我们的装饰器重载了超类对象MacBook()的 object.cost()函数,使其返回的Macbook的当前价格加上了被定制后升级的价格。

这被看做是对原来的Macbook对象构造器方法的装饰,它并没有将其重写(例如,screenSize()),我们所定义的Macbook的其它属性也保持不变,完好无缺。

优点 & 缺点

因为它可以被透明的使用,并且也相当的灵活,因此开发者都挺乐意去使用这个模式——如我们所见,对象可以用新的行为封装或者“装饰”起来,而后继续使用,并不用去担心基础的对象被改变。在一个更加广泛的范围内,这一模式也避免了我们去依赖大量子类来实现同样的效果。

然而在实现这个模式时,也存在我们应该意识到的缺点。如果穷于管理,它也会由于引入了许多微小但是相似的对象到我们的命名空间中,从而显著的使得我们的应用程序架构变得复杂起来。这里所担忧的是,除了渐渐变得难于管理,其他不能熟练使用这个模式的开发者也可能会有一段要掌握它被使用的理由的艰难时期。

足够的注释或者对模式的研究,对此应该有助益,而只要我们对在我们的应程序中的多大范围内使用这一模式有所掌控的话,我们就能让两方面都得到改善。

12、亨元模式

享元模式是一个优化重复、缓慢和低效数据共享代码的经典结构化解决方案。它的目标是以相关对象尽可能多的共享数据,来减少应用程序中内存的使用(例如:应用程序的配置、状态等)。

此模式最先由Paul Calder 和 Mark Linton在1990提出,并用拳击等级中少于112磅体重的等级名称来命名。享元(“Flyweight”英语中的轻量级)的名称本身是从以帮以助我们完成减少重量(内存标记)为目标的重量等级推导出的。

实际应用中,轻量级的数据共享采集被多个对象使用的相似对象或数据结构,并将这些数据放置于单个的扩展对象中。我们可以把它传递给依靠这些数据的对象,而不是在他们每个上面都存储一次。

使用享元

有两种方法来使用享元。第一种是数据层,基于存储在内存中的大量相同对象的数据共享的概念。第二种是DOM层,享元模式被作为事件管理中心,以避免将事件处理程序关联到我们需要相同行为父容器的所有子节点上。享元模式通常被更多的用于数据层,我们先来看看它。

享元和数据共享

对于这个应用程序而言,围绕经典的享元模式有更多需要我们意识到的概念。享元模式中有一个两种状态的概念——内在和外在。内在信息可能会被我们的对象中的内部方法所需要,它们绝对不可以作为功能被带出。外在信息则可以被移除或者放在外部存储。

带有相同内在数据的对象可以被一个单独的共享对象所代替,它通过一个工厂方法被创建出来。这允许我们去显著降低隐式数据的存储数量。

个中的好处是我们能够留心于已经被初始化的对象,让只有不同于我们已经拥有的对象的内在状态时,新的拷贝才会被创建。

我们使用一个管理器来处理外在状态。如何实现可以有所不同,但针对此的一种方法就是让管理器对象包含一个存储外在状态以及它们所属的享元对象的中心数据库。

经典的享元实现

近几年享元模式已经在Javascript中得到了深入的应用,我们会用到的许多实现方式其灵感来自于Java和C++的世界。

我们来看下来自维基百科的针对享元模式的 Java 示例的 Javascript 实现。

在这个实现中我们将要使用如下所列的三种类型的享元组件:

  • 享元对应的是一个接口,通过此接口能够接受和控制外在状态。

  • 构造享元来实际的实际的实现接口,并存储内在状态。构造享元须是能够被共享的,并且具有操作外在状态的能力。

  • 享元工厂负责管理享元对象,并且也创建它们。它确保了我们的享元对象是共享的,并且可以对其作为一组对象进行管理,这一组对象可以在我们需要的时候查询其中的单个实体。如果一个对象已经在一个组里面创建好了,那它就会返回该对象,否则它会在对象池中新创建一个,并且返回之。

这些对应于我们实现中的如下定义:

  • CoffeeOrder:享元

  • CoffeeFlavor:构造享元

  • CoffeeOrderContext:辅助器

  • CoffeeFlavorFactory:享元工厂

  • testFlyweight:对我们享元的使用

鸭式冲减的 “implements”

鸭式冲减允许我们扩展一种语言或者解决方法的能力,而不需要变更运行时的源。由于接下的方案需要使用一个Java关键字“implements”来实现接口,而在Javascript本地看不到这种方案,那就让我们首先来对它进行鸭式冲减。

Function.prototype.implementsFor 在一个对象构造器上面起作用,并且将接受一个父类(函数—)或者对象,而从继承于普通的继承(对于函数而言)或者虚拟继承(对于对象而言)都可以。

// Simulate pure virtual inheritance/"implement" keyword for JS Function.prototype.implementsFor = function( parentClassOrObject ){    if ( parentClassOrObject.constructor === Function ) {        // Normal Inheritance        this.prototype = new parentClassOrObject();         this.prototype.constructor = this;         this.prototype.parent = parentClassOrObject.prototype;    } else {        // Pure Virtual Inheritance        this.prototype = parentClassOrObject; this.prototype.constructor = this; this.prototype.parent = parentClassOrObject;    }    return this;};

我们可以通过让一个函数明确的继承自一个接口来弥补implements关键字的缺失。下面,为了使我们得以去分配支持一个对象的这些实现的功能,CoffeeFlavor实现了CoffeeOrder接口,并且必须包含其接口的方法。

let CoffeeOrder = {    // Interfaces    serveCoffee:function(context){},    getFlavor:function(){}};function CoffeeFlavor( newFlavor ){    let flavor = newFlavor;    if( typeof this.getFlavor === "function" ){      this.getFlavor = function() {          return flavor;      };    }    if( typeof this.serveCoffee === "function" ){      this.serveCoffee = function( context ) {        console.log("Serving Coffee flavor "+ flavor+" to table number "+ context.getTable());      };    }}CoffeeFlavor.implementsFor( CoffeeOrder );function CoffeeOrderContext( tableNumber ) {   return{      getTable: function() {         return tableNumber;     }   };}function CoffeeFlavorFactory() {    let flavors = {},    length = 0;    return {        getCoffeeFlavor: function (flavorName) {            let flavor = flavors[flavorName];            if (flavor === undefined) {                flavor = new CoffeeFlavor(flavorName);                flavors[flavorName] = flavor;                length++;            }            return flavor;        },        getTotalCoffeeFlavorsMade: function () {            return length;        }    };}function testFlyweight(){  let flavors = new CoffeeFlavor(),    tables = new CoffeeOrderContext(),    ordersMade = 0,    flavorFactory;  function takeOrders( flavorIn, table) {     flavors[ordersMade] = flavorFactory.getCoffeeFlavor( flavorIn );     tables[ordersMade++] = new CoffeeOrderContext( table );  }   flavorFactory = new CoffeeFlavorFactory();   takeOrders("Cappuccino", 2);   takeOrders("Cappuccino", 2);   takeOrders("Frappe", 1);   takeOrders("Frappe", 1);   takeOrders("Xpresso", 1);   takeOrders("Frappe", 897);   takeOrders("Cappuccino", 97);   takeOrders("Cappuccino", 97);   takeOrders("Frappe", 3);   takeOrders("Xpresso", 3);   takeOrders("Cappuccino", 3);   takeOrders("Xpresso", 96);   takeOrders("Frappe", 552);   takeOrders("Cappuccino", 121);   takeOrders("Xpresso", 121);   for (var i = 0; i < ordersMade; ++i) {       flavors[i].serveCoffee(tables[i]);   }   console.log("total CoffeeFlavor objects made: " +  flavorFactory.getTotalCoffeeFlavorsMade());}

转换代码为使用享元模式

接下来,让我们通过实现一个管理一个图书馆中所有书籍的系统来继续观察享元。分析得知每一本书的重要元数据如下:

  • ID

  • 标题

  • 作者

  • 类型

  • 总页数

  • 出版商ID

  • ISBN

我们也将需要下面一些属性,来跟踪哪一个成员是被借出的一本特定的书,借出它们的日期,还有预计的归还日期。

  • 借出日期

  • 借出的成员

  • 规定归还时间

  • 可用性

let Book = function( id, title, author, genre, pageCount,publisherID, ISBN, checkoutDate, checkoutMember, dueReturnDate,availability ){   this.id = id;   this.title = title;   this.author = author;   this.genre = genre;   this.pageCount = pageCount;   this.publisherID = publisherID;   this.ISBN = ISBN;   this.checkoutDate = checkoutDate;   this.checkoutMember = checkoutMember;   this.dueReturnDate = dueReturnDate;   this.availability = availability;};
Book.prototype = { getTitle: function () { return this.title; }, getAuthor: function () { return this.author; }, getISBN: function (){ return this.ISBN; }, updateCheckoutStatus: function( bookID, newStatus, checkoutDate , checkoutMember, newReturnDate ){ this.id = bookID; this.availability = newStatus; this.checkoutDate = checkoutDate; this.checkoutMember = checkoutMember; this.dueReturnDate = newReturnDate; }, extendCheckoutPeriod: function( bookID, newReturnDate ){ this.id = bookID; this.dueReturnDate = newReturnDate; }, isPastDue: function(bookID){ let currentDate = new Date(); return currentDate.getTime() > Date.parse( this.dueReturnDate ); }};

这对于最初小规模的藏书可能工作得还好,然而当图书馆扩充至每一本书的多个版本和可用的备份,这样一个大型的库存,我们会发现管理系统的运行随着时间的推移会越来越慢。使用成千上万的书籍对象可能会压倒内存,而我们可以通过享元模式的提升来优化我们的系统。

现在我们可以像下面这样将我们的数据分离成为内在和外在的状态:同书籍对象(标题,版权归属)相关的数据是内在的,而借出数据(借出成员,规定归还日期)则被看做是外在的。这实际上意味着对于每一种书籍属性的组合仅需要一个书籍对象。这仍然具有相当大的数量,但相比之前已经得到大大的缩减了。

下面的书籍元数据组合的单一实体将在所有带有一个特定标题的书籍拷贝中共享。

let Book = function ( title, author, genre, pageCount, publisherID, ISBN ) {    this.title = title;    this.author = author;    this.genre = genre;    this.pageCount = pageCount;    this.publisherID = publisherID;    this.ISBN = ISBN;};

如我们所见,外在状态已经被移除了。从图书馆借出所要做的一切都被转移到一个管理器中,由于对象数据现在是分段的,工厂可以被用来做实例化。

一个基本工厂

现在让我们定义一个非常基本的工厂。我们用它做的工作是,执行一个检查来看看一本给定标题的书是不是之前已经在系统内创建过了;如果创建过了,我们就返回它 - 如果没有,一本新书就会被创建并保存,使得以后可以访问它。

这确保了为每一条本质上唯一的数据,我们只创建了一份单一的拷贝:

let BookFactory = (function () {  let existingBooks = {}, existingBook;  return {    createBook: function ( title, author, genre, pageCount, publisherID, ISBN ) {      existingBook = existingBooks[ISBN];      if ( !!existingBook ) {        return existingBook;      } else {        let book = new Book( title, author, genre, pageCount, publisherID, ISBN );        existingBooks[ISBN] = book;        return book;      }    }  };});

管理外在状态

下一步,我们需要将那些从Book对象中移除的状态存储到某一个地方——幸运的是一个管理器(我们会将其定义成一个单例)可以被用来封装它们。书籍对象和借出这些书籍的图书馆成员的组合将被称作书籍借出记录。

这些我们的管理器都将会存储,并且也包含我们在对Book类进行享元优化期间剥离的同借出相关的逻辑。

let BookRecordManager = (function () {  let bookRecordDatabase = {};  return {    addBookRecord: function ( id, title, author, genre, pageCount, publisherID, ISBN, checkoutDate, checkoutMember, dueReturnDate, availability ) {      let book = bookFactory.createBook( title, author, genre, pageCount, publisherID, ISBN );      bookRecordDatabase[id] = {        checkoutMember: checkoutMember,        checkoutDate: checkoutDate,        dueReturnDate: dueReturnDate,        availability: availability,        book: book      };    },    updateCheckoutStatus: function ( bookID, newStatus, checkoutDate, checkoutMember, newReturnDate ) {      let record = bookRecordDatabase[bookID];      record.availability = newStatus;      record.checkoutDate = checkoutDate;      record.checkoutMember = checkoutMember;      record.dueReturnDate = newReturnDate;    },    extendCheckoutPeriod: function ( bookID, newReturnDate ) {      bookRecordDatabase[bookID].dueReturnDate = newReturnDate;    },    isPastDue: function ( bookID ) {      let currentDate = new Date();      return currentDate.getTime() > Date.parse( bookRecordDatabase[bookID].dueReturnDate );    }  };});

这些改变的结果是所有从Book类中撷取的数据现在被存储到了BookManager单例(BookDatabase)的一个属性之中——与我们以前使用大量对象相比可以被认为是更加高效的东西。同书籍借出相关的方法也被设置在这里,因为它们处理的数据是外在的而不内在的。

这个过程确实给我们最终的解决方法增加了一点点复杂性,然而同已经明智解决的数据性能问题相比,这只是一个小担忧,如果我们有同一本书的30份拷贝,现在我们只需要存储它一次就够了。

每一个函数也会占用内存。使用享元模式这些函数只在一个地方存在(就是在管理器上),并且不是在每一个对象上面,这节约了内存上的使用。

本文完〜


专注分享当下最实用的前端技术。关注前端达人,与达人一起学习进步!

长按关注"前端达人"

浏览 18
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报