每位开发者都努力写出可维护的、易读的、可复用的代码。随着应用变得越来越大,代码的结构也越来越重要。设计模式验证了解决这个挑战的重点——在特定环境中,对同类事物提供相同的组织结构。
JavaScript web开发者们在开发应用的时候经常会不知不觉的与设计模式打交道。
尽管在某些环境中使用了不同的设计模式列表,JavaScript的开发者们通常比其他开发者更常用到一些设计模式。
在这篇文章里,我想通过讨论这些常见的设计模式来提升你的编程能力,并且深入到JavaScript的结构当中去。
要讨论的设计模式包括如下几种:
- 模块模式(Module)
- 原型模式(Prototype)
- 观察者模式(Observer)
- 单例模式(Singleton)
每种模式都由几个属性构成,然而,我将会着重强调如下几个关键点:
- Context: 应该在什么环境下使用模式
- Problem: 我们要通过模式去解决什么问题
- Solution: 如果用模式去解决我们提出的问题
- Implementation: 如何实现
模块设计模式(Module Design Pattern)
JavaScript 模块是最被经常使用的设计模式,它可以使特定部分的代码与其他部分相独立。其提供的松散耦合支撑了良好的代码结构。
对于那些熟悉面向对象语言的开发者额,模块就是JavaScript中的”类”。类的好处之一就是”封装”(encapsulation)——避免自身的状态和行为被其他”类”所获取。模块模式允许公有和私有(加上更少知道的受保护的和特权)访问级别。
模块应该是立即执行函数(IIFE)从而允许对私有作用域(即被闭包保护的变量和方法)的访问。看起来是这样的:
( function() { // declare private variables and/or functions return { // declare public variables and/or functions } })();
这里,在返回我们想要返回的对象之前,实例化了私有变量和方法。闭包之外的代码不能获取这些私有变量,因为他们不在同一个作用域中。来看一个更具体的实现方法。
var HTMLChanger = ( function() { var contents = 'contents' var changeHTML = function() { var element = document.getElementById( 'attribute-to-change'); element.innerHTML = contents; } return { callChangeHTML: function() { changeHTML(); console.log(contents); } }; })(); HTMLChanger.callChangeHTML(); // Outputs: 'contents' console.log(HTMLChanger.contents); // undefined
注意到callChangeHTML
绑定到了返回的对象向上,并且能够在HTMLChanger
命名空间内被引用。然而,在模块之外,内容就不能被引用了。
揭示模块模式(Revealing Module Pattern)
揭示模块模式
是模块模式的一个变种。其目的是在保有封装的前提下,在返回的对象字面量中暴露特定的变量和方法。像下面这样去直接实现:
var Exposer = ( function() { var privateVariable = 10; var privateMethod = function() { console.log( 'Inside a private method!'); privateVariable++; } var methodToExpose = function() { console.log( 'This is a method I want to expose!'); } var otherMethodIWantToExpose = function() { privateMethod(); } return { first: methodToExpose, second: otherMethodIWantToExpose }; })(); Exposer.first(); // Output: This is a method I want to expose! Exposer.second(); // Output: Inside a private method! Exposer.methodToExpose; // undefined
尽管这看起来更加清晰了,但是仍有一个显著的缺点,就是不能够引用私有的方法。这可能会给单元测试带来挑战。同样的,公共行为也是不可覆盖的。
原型设计模式(Prototype Design Pattern)
任何JavaScript开发者都看到了关键字prototype
,他们被原型继承所困扰,或者在代码中实现原型。原型设计模式依赖于JavaScript原型继承。原型模型主要被用来在性能密集的条件下创建对象。
通过传递原始对象的克隆(浅克隆)来创建对象。原型模式的一个使用案例是执行一个广泛的数据库操作去创建一个对象,被用作应用的其他部分。如果另一个进程需要使用这个对象,它可以直接去克隆之前的对象而不需要再执行一遍整个数据库操作。
为了克隆一个对象,构造器必须去实例化第一个对象。然后通过使用关键字prototype
,变量和方法被绑定到对象的结构中。让我们看一个基本的例子:
var TeslaModelS = function() { this.numWheels = 4; this.manufacturer = 'Tesla'; this.make = 'Model S'; } TeslaModelS.prototype.go = function() { // Rotate wheels } TeslaModelS.prototype.stop = function() { // Apply brake pads }
构造函数允许创建一个TeslaModelS
对象。在创建一个TeslaModelS
对象的时候,它会在构造器中保持初始状态。另外,维护go
和stop
方法是很容易的,因为我们是在prototype
中声明的他们。一个同样用来在原型上扩展函数的方法如下:
var TeslaModelS = function() { this.numWheels = 4; this.manufacturer = 'Tesla'; this.make = 'Model S'; } TeslaModelS.prototype = { go: function() { // Rotate wheels }, stop: function() { // Apply brake pads } }
揭示原型模式(Revealing prototype pattern)
与模块模式相似,原型模式也有揭示的变体。揭示原型模式在它返回一个对象字面量的时候,提空了公有和私有成员的封装。
在我们返回一个对象的时候,我们将会在原型对象前加一个function
。通过扩展我们的上一个例子,我们能够选择我们在当前原型中希望暴露的内容,以保持其访问级别。
var TeslaModelS = function() { this.numWheels = 4; this.manufacturer = 'Tesla'; this.make = 'Model S'; } TeslaModelS.prototype = function() { var go = function() { // Rotate wheels }; var stop = function() { // Apply brake pads }; return { pressBrakePedal: stop, pressGasPedal: go } }();
留意stop和go函数将在返回的对象中被屏蔽,由于在返回的对象作用域之外。因为JavaScript原生就支持对象的继承,因此不需要再重写底层细节了。
观察者设计模式
有很多次当我们的应用的一部分改变的时候,另一部分需要被更新。在AngularJS里面,如果$scope
对象更新了,一个事件就能被触发,同时去提醒另一个部分。观察者模式就是对这种情况的合并——如果一个对象被更新,它会对依赖他的对象进行广播,告知它有变化发生了。
另一个很好的例子就是MVC架构;当数据模型更新时,视图发生改变。其中一个好处就是从数据模型中将视图解耦出来,从而减少了依赖。
正如UML图表所示,subject
,observer
和concrete
对象都是必要的对象。subject
包含了到concrete observer
的引用,从而能够通知任何改变。observer
对象是一个抽象的类,它允许实际的观察者实现通知方法。
让我们看一个AngularJS的例子,其通过事件管理实现了观察者模式
// Controller 1 $scope.$on( 'nameChanged', function(event, args) { $scope.name = args.name; }); ... // Controller 2 $scope.userNameChanged = function(name) { $scope.$emit( 'nameChanged', { name: name}); };
在观察者模式中,区分独立的对象或者subject
是很重要的。
值得一提的是,尽管观察者模式有很多优势,但是其中一个缺点就是当观察者的数量上升时,性能会有显著的下降。其中最恼人的观察者就是watchers
。在AngularJS里,我们能够监视变量,函数和对象。$$digest
循环运行并且在作用域对象更新时通知每一个监视器新的值。
我们能够在JavaScript中创建我们自己的Subject
和Observer
。让我们看一下这是如何实现的:
var Subject = function() { this.observers = []; return { subscribeObserver: function(observer) { this.observers.push(observer); }, unsubscribeObserver: function(observer) { var index = this.observers.indexOf(observer); if(index > -1) { this.observers.splice(index, 1); } }, notifyObserver: function(observer) { var index = this.observers.indexOf(observer); if(index > -1) { this.observers[index].notify(index); } }, notifyAllObservers: function() { for( var i = 0; i < this.observers.length; i++){ this.observers[i].notify(i); }; } }; }; var Observer = function() { return { notify: function(index) { console.log( "Observer " + index + " is notified!"); } } } var subject = new Subject(); var observer1 = new Observer(); var observer2 = new Observer(); var observer3 = new Observer(); var observer4 = new Observer(); subject.subscribeObserver(observer1); subject.subscribeObserver(observer2); subject.subscribeObserver(observer3); subject.subscribeObserver(observer4); subject.notifyObserver(observer2); // Observer 2 is notified! subject.notifyAllObservers(); // Observer 1 is notified! // Observer 2 is notified! // Observer 3 is notified! // Observer 4 is notified!
发布/订阅
然而,发布/订阅模式使用了一种主题/事件的渠道,这个渠道架设在希望接收到通知的对象(订阅者)和触发事件的对象(发布者)之间。这个事件系统允许代码定义特定的应用事件,并且能够传递包含着订阅者所需的值的定制化参数。其中的想法就是避免订阅者和发布者之间的依赖。
这与观察者模式有所区别,因为任何一个订阅者都实现了一个适当的事件处理机制去注册和接收来自发布者的主题通知广播。
许多开发者选择将发布/订阅模式与观察者模式相结合,尽管两者存在差异。订阅器在发布/订阅模式中通过消息媒介被通知,但是观察者通过实现一个与subject类似的处理程序而获得通知。
在AngularJS里,一个订阅者订阅
一个事件需要使用$on('event', callback)
,发布者发布
一个事件需要使用$emit('event',args)
或者$broadcast('event', args)
。
单例模式(Singleton)
单例模式只允许单次实例化,但是同一个对象可以有许多实例。单例模式阻止了客户端创建多个实例,在第一个对象被创建之后,他将会返回它自己的实例。
对于大多数之前还没有使用过单例模式的人来说,找到单例模式的用例是不容易的。一个例子是使用一个办公室的打印机。如果有十个人在办公室里,并且他们都要用一台打印机,十台电脑共享了打印机(实例)。通过共享一个打印机,他们分享了相同的资源。
var printer = ( function () { var printerInstance; function create () { function print() { // underlying printer mechanics } function turnOn() { // warm up // check for paper } return { // public + private states and behaviors print: print, turnOn: turnOn }; } return { getInstance: function() { if(!printerInstance) { printerInstance = create(); } return printerInstance; } }; function Singleton () { if(!printerInstance) { printerInstance = intialize(); } }; })();
create
方法是私有的,因为我们不想让客户端去访问他,然而,getInstance
方法是公开的。每一个办公室的员工都能通过与getInstance
方法进行交互从而生成一个打印机的实例。向下面这样:
var officePrinter = printer.getInstance();
在AngularJS里面,单例是很普遍的,最值得注意的是services,factories和providers。因为他们都保留了状态并且提供了资源的获取,创建两个实例破坏了service/factory/provider共享的关键。
当超过一个线程想要获取相同资源的时候,竞争这种情况就会发生在多线程程序里。单例模式很容易受到竞争的影响,因此如果没有实例在一开始被初始化,两个线程可能就会创建两个对象而不是返回实例。这违背了单例模式的设计原则。因此,当开发者在多线程应用中实现单例时,必须要保证同步。
总结
设计模式通常在更大的应用中被使用,然而要去理解哪一个比另一个更有优势,需要从实践中得到。
在构建任何应用之前,你应该全盘思考每一个行为以及他们如何与另一个进行交互。在回顾Module
,Protoytpe
,Observer
和Singleton
设计模式之后,你应该能够认识这些模式并且广泛应用他们。