前言
- 每位程序开发者都在致力于编写可维护、可读性高以及可复用的代码。随着应用越来越大,代码的结构化也变得更加重要了。设计模式(design pattern)就是用来解决这个问题的关键,通过一些通用的代码组织结构来解决一些特定情况下的常见问题。
- JavaScript的web开发者在开发应用时,经常与设计模式打交道,尽管可能你并没有意识到~
- 虽然有一长串各种各样的设计模式(GOF四人帮提出了23种),但在JavaScript中可能通常只会用到其中几个。
- 本文将讨论一些常见的设计模式,希望能给你提供一些提升编程能力的方法,以及更加地深入js底层原理。
- 下面将介绍四种设计类型:
- 模块模式(Module)
- 原型模式(Prototype)
- 观察者模式(Observer)
- 单例模式(Singleton)
- 每个设计模式由许多元素组成,然而我会着重于以下几个关键点:
1 应用场景(Context):在哪里或者在什么情况下可以使用该模式
2 问题(Problem):我们要用来解决什么问题
3 解决方案(Solution):我们要如何应用设计模式来解决我们提出的问题
4 实现(Implementation):具体实现起来是怎么样的
模块化设计模式(Module Design Pattern)
- JS模块化也是一种设计模式,普遍用来使部分代码独立于其他组件代码。这样代码之间耦合性更弱,从而开发出更高质量的代码。
- 对于熟悉面向对象语言(比如Java)的开发者来说,js中的模块化就相当于"Class类"。类的众多优点之一就是封装encapsulation--保护自己的变量和方法不被其他类访问。模块化设计模式允许公共和私有两种访问级别。
- 模块代码应该是通过的立即执行函数(IIFE)来产生私有作用域,换句话说,使用一个闭包来产生私有变量和方法(然而最后还是会返回一个变量),代码大致像下面这样:
(function() {
// 在这里声明私有变量或方法
return {
// 在这里声明公有变量和方法
}
})();
- 在返回一个我们想要返回的对象之前,先声明好私有变量和方法。在闭包外部的代码无法访问闭包内部的私有变量和方法,因为他们不在同一个作用域内。下面来看一个更加具体的应用:
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(); // 输出: 'contents'
console.log(HTMLChanger.contents); // undefined
- 注意
callChangeHTML
方法被绑定到闭包返回的对象上了,在HTMLChanger
的作用域下可以被引用,但是在闭包外部,content
属性就不能被访问了。
揭示型模块模式
- 揭示型模块模式(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)
- 可能有的js开发者既搞不清楚原型继承和关键字prototype,也不会在代码中应用到原型。原型设计模式依赖于JavaScript的原型链继承。大多数情况下,原型主要用来创建对象。
-
对象创建的过程是对传递过去的原对象进行浅克隆。原型设计模式的其中一个使用实例就是:执行一个大量的数据操作来创建一个供应用其他部分使用的对象。如果另一个程序要使用这个对象,和执行大量的数据操作相比,直接克隆之前创建好的对象就有利的多了。
image - 上图介绍了一个原型接口如何用来克隆的具体应用
- 克隆一个对象时,必须要有一个构造函数来实例化第一个对象。然后,再通过使用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对象时,将会保持构造函数里声明的变量。另外,我们通过关键字
prototype
声明时,方法go
和stop
也会保留给对象实例。使用下面的代码也是同一个意思:
var TeslaModelS = function() {
this.numWheels = 4;
this.manufacturer = 'Tesla';
this.make = 'Model S';
}
TeslaModelS.prototype = {
go: function() {
// Rotate wheels
},
stop: function() {
// Apply brake pads
}
}
揭示型原型模式
- 和模块模式类似,原型模式也有揭示型的变种。揭示型原型模式提供一个对私有成员和公有成员的封装,同时返回一个字面量对象
- 因为我们是要返回一个对象,所以我们要用一个函数来表达原型对象。通过对上述例子的扩展,在当前原型内部,可以选择想要暴露的属性或方法:
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
}
}();
- 注意
go
和stop
方法是被保护了的,因为他们不在所返回的对象所在的作用域内。因为js本来就支持原型继承,所以也不需要再重写底部特性了。
观察者模式
- 很多时候当应用中的一部分代码变化时,要求其他部分也进行更新。在AngularJS中,当
$scope
对象更新时,就会触发一个事件去通知其他组件。观察者模式包含的内容就是:如果一个对象发生改变了,那么它就会广播(broadcasts)通知所有的依赖于此的对象。 -
另一个最简单的例子就是MVC结构(比如backbone框架);model改变时,view视图层就会更新。有个好处就在于解耦view和model,使代码依赖性降低。
image - 从上面的图表可以看出,必须要的对象是
subject
,observer
, 和concrete
对象。subject包含了各个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中,我们可以watch监控变量、函数和对象。当作用域中的一个对象改变时,$$digest循环执行同时通知每一个相关观察者。
- 我们可以在JavaScript代码中创建观察者(Observers)和目标对象(Subjects)。我们来看下是如何实现的:
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!
发布/订阅(Publish/Subscribe)
- 发布/订阅模式:在希望接收通知的对象(订阅者)和触发事件的对象(发布者)之间,搭建一个主题/事件的通道。这个事件系统运行代码定义具体的应用事件,来传递订阅者需要的参数值。这里的思想是在于,避免发布者和订阅者相依赖(即他们是通过事件来联系的,并没有直接的依赖)。
- 这和观察者模式的区别在于:订阅者执行一个适当的事件处理函数来进行注册,然后接收发布者的广播通知。
- 许多开发者将发布/订阅模式与观察者相结合,尽管两者可能有些区别。在发布/订阅模式中的订阅者,通过一些媒介信息得到通知,然而观察者是通过执行一个处理函数来得到通知。
单例模式(Singleton)
- 一个单例(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。因为他们在保持状态、提供可访问资源,如果创建两个实例就违背唯一共享services/factories/providers的设计理念了。
- 在多线程的应用中,当多个线程同时访问一个资源时就会产生竞态条件(race condition)。单例模式就很容易受到竞态条件的影响,比如一开始并没有创建实例对象,两个进程同时访问,然后同时创建了一个实例,就会产生了两个实例对象,而并不是先创建一个,然后再返回之前创建好的。这就违背了单例模式的本意了。所以开发者在多线程应用中使用单例时,要了解同步性。(然而js只是单线程的~~~)
结论
- 设计模式在大型应用中经常会用到,理解其中一两种也是很有好处的,伴随着实践效果更佳。
- 在构造一个应用之前,你应该彻底思考清楚,一个模块应该如何和其他模块相联系。回顾熟悉上面四个设计模式之后,你应该能够在项目中识别出他们,并广泛的应用。