设计模式简介:
设计模式是可重用的用于解决软件设计中一般问题的方案。设计模式如此让人着迷,以至在任何编程语言中都有对其进行的探索。
其中一个原因是它可以让我们站在巨人的肩膀上,获得前人所有的经验,保证我们以优雅的方式组织我们的代码,满足我们解决问题所需要的条件。
设计模式同样也为我们描述问题提供了通用的词汇。这比我们通过代码来向别人传达语法和语义性的描述更为方便。
下面介绍一些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"; }; } // 使用: // 我们可以示例化一个Car let 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 objects console.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();// enabled myModule.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 namespace Ext.namespace("myNameSpace"); // create application myNameSpace.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使用它的时候,这是一种信号,表明我们可能需要去重新评估自己的设计。
这通常表明系统中的模块要么紧耦合要么逻辑过于分散在代码库的多个部分。单例模式更难测试,因为可能有多种多样的问题出现,例如隐藏的依赖关系,很难去创建多个实例,很难清理依赖关系,等等。