这本书总结了23种在以往的C++开发中针对某些特定问题的优雅的解决方案,后来在Java中也得到了广泛应用。不过在1996年,Google总工程师Peter Norvig曾在演讲中尖锐地指出,设计模式某种意义上是为解决面向对象语言(当时主要是指 C++ )本身缺陷的一种权宜之计。换句话说,设计模式在一定程度上是为了弥补编程语言的缺陷。他还提到,在像Lisp这样的动态语言中,有至少16种设计模式可以直接用语言本身的机制代替或很容易就可以实现。而同样作为动态语言的JavaScript,受益于其高阶函数特性,也可以轻松实现大多数设计模式。
本文我们将参考曾探著的《JavaScript设计模式和开发实践》一书,来归纳一下JavaScript中常用的14种设计模式(门面模式和AOP模式在JavaScript中并不常用,书中也只是简要概述,本文最后会介绍其核心概念),然后我们简单介绍一下JavaScript中的设计原则。
单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
比如页面上有一个登录按钮,点击之后会动态创建一个登录框。那么无论点击该按钮多少次,系统应该只创建一个登录框,这就是适用单例模式的一个典型场景。
不同于Java,实现一个基本的单例模式在JavaScript中再简单不过了,就是创建一个全局变量:
function LoginDialog () {
this.init();
}
LoginDialog.prototype.init = function () {
...
}
loginButton.onclick = function () {
// 只有登录框实例不存在才创建它
if (!window.loginDialog) {
window.loginDialog = new LoginDialog();
}
}
这种实现单例模式的方案虽然简单,但会引入全局变量,从而严重污染全局环境,应该尽可能避免这种方案。
我们可以通过闭包来解决这个问题。我们将构造函数封装到一个闭包里,该闭包内的变量instance只能被返回的构造函数LoginDialog访问:
var LoginDialog = (function () {
// 闭包
// 在闭包内保存单一实例
var instance;
var LoginDialog = function () {
// 如果单例存在,则直接返回它
if (instance) {
return instance;
}
// 否则就创建单例并保存到instance
this.init();
return instance = this;
}
LoginDialog.prototype.init = function () {
... }
return LoginDialog;
})();
现在无论在何处调用new LoginDialog()
,返回的都是闭包中的唯一单例。关于单例模式,书中还有更多变体和应用场景,感兴趣的可以参考原作。
策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。通俗地说,就是把一系列的算法(或者叫策略)封装成一个算法库,根据需要从算法库中取出对应的算法调用。
我们举一个例子,假设小明的爸妈跟小明约定:
这里针对每个成绩段所定义的操作就是一个策略,为此我们可能会定义这样一个处理函数:
function processGrade (level) {
if (level === 'A') {
console.log('奖励10元');
} else if (level === 'B') {
console.log('奖励一颗糖');
} else if (level === 'C') {
console.log('做一天家务');
} else if (level === 'D') {
console.log('做一星期家务');
}
}
processGrade('B'); // 奖励一颗糖
这个函数看上去似乎没有任何问题,它使用if else
语句对不同成绩段执行不同的策略。
然而几天后,小明拿着一张59分(评级为F
)的试卷回来了。小明的爸妈这时才发现,之前的约定里并没有规定考F
该如何处理,于是临时商议,那就打一顿吧!为了应对这种新情况,上面的处理函数需要再加一个额外的else
分支:
else if (level === 'F') {
console.log('打一顿');
}
在实际开发中,需求变动是很常见的,因此像上面这样的函数可能需要经常改动。频繁的改动会导致函数本身极不稳定,如果这个函数本身又特别复杂,某些改动甚至可能会给系统引入新的bug。这时我们就应该使用策略模式来重构上述代码了。
策略模式的本质是把策略单独封装起来,按需调用,看下面的例子:
var strategies = {
'A': function () {
console.log('奖励10元');
},
'B': function () {
console.log('奖励一颗糖');
},
'C': function () {
console.log('做一天家务');
},
'D': function () {
console.log('做一周家务');
}
}
function processGrade (level) {
strategies[level]();
}
processGrade('B'); // 奖励一颗糖
我们在这里封装了一个策略对象,它目前定义了四种策略,分别对应成绩段A、B、C、D
,我们只需要根据传入的成绩调用对应的策略函数即可。
这种模式带给我们的好处是,当我们需要新加一个成绩段F
时,我们只需要向strategies
对象新增一个策略即可:
var strategies = {
... // A、B、C、D对应的策略
'F': function () {
console.log('打一顿');
}
}
function processGrade (level) {
strategies[level]();
}
processGrade('F'); // 打一顿
根据开放-闭合原则,软件实体(类、模块、函数等)应该是可以扩展的,但是不可修改。而我们给strategies对象新增F
策略就是在扩展策略库,因此它是符合开发-闭合原则的,最初版本中新增else
分支则是在修改函数本身,它会导致函数的不稳定。
通过把策略抽取出来,我们可以方便地扩展和修改策略,而不用担心影响到系统的主逻辑。得益于JavaScript函数的灵活性,实现策略模式是非常简单的。
代理模式的定义为:为一个对象提供一个代用品或占位符,以便控制对它的访问。
举个例子,假设某个导演想要找一位电影明星出演一部电影,那么他一般会去联系该明星的经纪人,两者商议好报酬和档期后,再由明星本人签字。在这个例子中,经纪人就是明星本人的代理,导演想邀请明星出演必须通过联系他的经纪人。
再比如,由于网络运营商的限制,国内的计算机不能直接访问国外的网站。于是有人就会在国内搭建一台不受网络运营商安全限制的服务器,当需要访问国外的网站时,只需把请求发送给该服务器,再由它转发到国外的服务器上。国内这台不受安全限制的服务器现在就负责帮用户转发请求,因此也叫代理服务器,而这种为客户端做代理的模式被称为“正向代理”。
综合以上两个例子,代理模式就是在访问者和被访问者之间新加一个代理角色,来帮助访问者访问不可直接访问的目标对象,或帮助被访问者拦截某些操作,以下示意图来自《JavaScript设计模式和开发实践》:
JavaScript中的代理主要分为两类:保护代理和虚拟代理。
保护代理的作用是帮助被代理者过滤或处理某些访问,如经纪人可以直接为明星拒绝调某些报价过低的邀请,此时经纪人扮演的就是保护代理的角色。虚拟代理的作用是把一些开销很大的对象,延迟到真正需要的时候才去创建它。我们举一个书中的例子来理解这两种代理模式(为了节约篇幅,这里进行了一些删改)。
小明有一个暗恋很久的女神,我们暂且称为A。某天小明决定要给A送99朵玫瑰表白,但是小明不好意思直接表白,于是找到了女神A的好闺蜜,我们暂且称为B,希望B帮自己转送玫瑰。我们先来看一下小明直接向女神A送花的过程:
// 玫瑰花构造函数
var Flower = function () {
};
// 小明
var xiaoming = {
sendFlower: function (target) {
// 买玫瑰
var flower = new Flower();
// 把玫瑰送出去
target.receiveFlower(flower);
}
}
// 女神A
var A = {
receiveFlower: function (flower) {
console.log('收到玫瑰花:' + flower);
}
}
xiaoming.sendFlower(A); // 收到玫瑰花
然后我们引入代理B,由B向A转送玫瑰花,此时小明不再把花直接交给A,而是交给B:
// 女神的闺蜜B
var B = {
receiveFlower: function (flower) {
A.receiveFlower(flower);
}
}
// 小明把花给B,B再转交给A
xiaoming.sendFlower(B);
目前来看代理B的作用,除了增加代码的复杂性外,似乎没有任何好处。
现在我们新增一个设定:当女神心情好的时候送花,成功的概率是60%;心情不好的时候送花,成功的概率为10%。然而小明跟女神见面的机会很少,很难知道女神何时心情好,但是女神的闺蜜B对此则了如指掌。于是我们把闺蜜B和女神A进行如下修改,此时闺蜜B会监听女神A的心情,择机送花:
var B = {
receiveFlower: function( flower ){
// 监听A的好心情,心情好的时候再送花
A.listenGoodMood(function(){
A.receiveFlower( flower );
});
}
};
// A会在某个时间后心情变好
var A = {
receiveFlower: function( flower ){
console.log( '收到花 ' + flower );
},
// 模拟女神心情变好的策略
listenGoodMood: function( fn ){
// 假设10秒之后A的心情变好
setTimeout(function(){
fn();
}, 10000 );
}
};
xiaoming.sendFlower( B );
现在代理B就扮演了保护代理的角色。当小明把玫瑰花交给B时,B并没有直接交给A,而是从中施加了拦截,只有等到A心情变好的时候,才会把玫瑰花交给A。
现在我们再新增一个设定:假设玫瑰花价格昂贵,对小明来说开销很大,而买来的玫瑰花如果不尽快送出去,很快就会枯萎。于是小明决定不直接买玫瑰花给B,而是把买玫瑰花的钱给B,让B等A心情好的时候再买玫瑰花送给A,这个过程如下:
var xiaoming = {
giveMoney: function (money){
target.giveMoney(money);
}
};
var B = {
receiveMoney: function (money) {
A.listenGoodMood(function(){
var flower = new Flower(money);
A.receiveFlower( flower );
});
}
}
xiaoming.giveMoney(B);
现在这束开销很大的玫瑰花不是由小明直接购买,而是由B等到A心情好的时候才去购买,因此B就扮演了虚拟代理的角色。这是一种惰性思维的体现。
ES6的Proxy就是代理模式的一种标准实现,它既可以作为保护代理,也可以作为虚拟代理,关键看在Proxy中配置何种拦截策略。
迭代器模式的定义是:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象
的内部表示。
标准的ES6语法中新增了Iterator
接口,这就是迭代器模式的官方实现,它用于规定以何种规则来访问一个对象的各个属性。而像数组原型上的forEach、map
等,则是数组对象特有的原生迭代器。实现一个简单的迭代器并不难,如forEach:
function forEach (arr, callback) {
for (var i = 0; i < arr.length; i++) {
// 把当前元素、索引值和原数组作为参数传递给回调函数
// 这和数组原型上的forEach行为是一致的
callback.call(arr[i], arr[i], i, arr);
}
}
forEach([1, 2, 3], (item, index) => {
console.log(item, index);
})
显然上面的规则对下面的对象也是适用的:
var arrLike = {
'0': '张三',
'1': '李四',
'2': '王二',
length: 3
}
这是因为这个对象可以通过下标来访问成员属性,并且有length属性,因此可以被for循环遍历。我们称这样的对象为类数组。
迭代器分为两类:内部迭代器和外部迭代器。内部迭代器指的是对一个对象内部的各个属性进行迭代的迭代器;外部迭代器指的是可以同时迭代多个对象的迭代器,外部迭代器必须显式地请求下次迭代。
上面的例子就是一个内部迭代器,它可以把数组或类数组的元素迭代出来。下面我们举一个外部迭代器的例子。
假设我们现在有两个数组arr1和arr2,我们需要检查这两个数组对应位置的各个元素是不是完全相等,用普通函数实现如下:
function checkEqual (arr1, arr2) {
if (arr1.length !== arr2.length) return false;
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) return false;
}
return true;
}
这个函数可以很好地对两个数组进行比较,但是它的灵活性很差,只要需求发生任何小的改动,都必须重构这个函数。现在我们看外部迭代器如何解决这个问题,首先定义一个迭代器:
var Iterator = function( obj ){
var current = 0;
var next = function(){
current += 1;
};
var isDone = function(){
return current >= obj.length;
};
var getCurrItem = function(){
return obj[ current ];
};
return {
next: next,
isDone: isDone,
getCurrItem: getCurrItem
}
};
这个迭代器从下标0开始输出,每次调用next方法就会继续输出下一个元素,isDone用来判断迭代是否结束,getCurrItem用来获取当前元素。现在我们用它来为两个数组构造迭代器:
var iterator1 = Iterator([1, 2, 3]);
var iterator2 = Iterator([1, 2, 3]);
然后我们定义compare函数来对数组元素进行比对:
var compare = function( iterator1, iterator2 ){
while( !iterator1.isDone() && !iterator2.isDone() ){
if ( iterator1.getCurrItem() !== iterator2.getCurrItem() ){
throw new Error ( 'iterator1 和iterator2 不相等' );
}
iterator1.next();
iterator2.next();
}
console.log( 'iterator1 和iterator2 相等' );
}
while循环会依次调用两个迭代器的next方法,对数组元素进行迭代,从而判断两个数组是否相等。
从代码量上看,使用迭代器似乎使代码量增加了,但这种增加是值得的。因为从业务逻辑中分理出的迭代器可复用性非常强,可以直接纳入标准库作为统一的迭代接口。如果项目中有数十种类似的迭代需求,使用迭代器时迭代器本身只需要定义一次,然后为每个需求编写相应的操作规则即可;而原始的for循环则需要为每个需求重新编写迭代规则和操作规则。迭代器模式无论是从可复用性、可维护性还是代码规范化的角度,都远优于传统的循环语句。
也叫发布-订阅者模式。观察者模式的定义是:定义对象间的一种一对多的依赖关系,当一个对象的状态改变时,所有依赖的对象都将得到通知。
举个发布-订阅者模式最经典的例子:如果我向某个报亭订阅了一份报纸,那么每到发报时间,报亭就会把报纸送到我的报纸箱。与此同时,也会有其他人向该报亭订阅报纸,他们也会在发报时收到相同的报纸。我和其他订阅报纸的人此时就是订阅者,而报亭就是发布者,报亭和订阅者之间形成的就是一对多的关系,这也是发布-订阅者模式名字的由来。
观察者模式则是从另一个角度来看待上述模式的。在上述例子中,报亭负责时刻监听(也叫观察)报社的发报事件,因此报亭被称为该事件的“观察者”。而我向报亭订阅报纸,意味着我也对报社的发报事件“感兴趣”,但我并不希望时刻关注报社何时发报,于是我把“观察”报社发报的任务委托给报亭。对报亭来说,我现在就是它的一个依赖者。
观察者模式的核心是任务委托。当多个对象对一个事件感兴趣时,由它们各自对事件进行观察的代价很大。为此应该生成一个专有的观察者对象,由它负责对事件观察,而那些感兴趣的对象则向它注册依赖,当事件发生时,由观察者通知这些依赖者。
JavaScript中处处都是观察者模式的影子。比如我们给某个按钮注册回调函数,就是向浏览器订阅了该按钮的点击事件;当发送ajax请求时,我们会注册成功和失败的处理函数,这是订阅了ajax请求成功和失败的事件。这些事件的观察者就是浏览器(或者说是它的事件循环系统)本身。
Vue的响应式系统也是典型的观察者模式的应用。Vue通过observe方法向每个组件实例的data添加一个__ob__
属性,它负责观察data中每个属性值的变化。__ob__
内包含一个dep属性,它负责管理对data的依赖,而dep属性的的subs属性是一个数组,它保存的就是对data数据变化感兴趣的订阅者。当data数据变化时,观察者会收到通知,它会调用dep的notify方法,来通知各个订阅者执行操作。
下面我们实现一个简单的事件系统,来阐释观察者模式的具体用法。
var Event = (function () {
var cb = {
}; // 各个事件对应的回调函数
var listen = function (eventName, fn) {
cb[eventName] = cb[eventName] || [];
// 将回调函数推送到回调队列
cb[key].push(fn);
}
var trigger = function () {
var eventName = Array.preototype.shift.call(arguments);
var cbs = cb[eventName];
if (!cbs || cbs.length === 0) {
return false;
}
// 依次执行对应的回调函数
cbs.forEach(cb => {
cb.apply(this, arguments);
})
}
var remove = function (eventName, cb) {
var cbs = cb[eventName];
if (!cbs) return false;
// 如果没有传cb,则删除该事件的所有回调,否则仅删除传入的回调
if (!cb) {
cbs[eventName] = [];
} else {
var index = cbs[eventName].indexOf(fn);
if (index > -1) {
cbs[eventName].splice(index, 1);
}
}
}
return {
listen: listen,
trigger: trigger,
remove: remove
}
})();
// 监听tick事件,并输出当前时间
Event.listen('tick', function (time) {
console.log('tick: ' + time);
})
// 每秒触发一次tick事件
setInterval(() => {
Event.trigger('tick', (+new Date));
}, 1000)
只要全局事件对象Event存在,在任何地方都可以对它注册监听或触发某个事件,这样便可以实现通信。这就是一个简易的观察者模式的实现,Event对象就是那个观察者。
命令模式的定义是:将执行某个操作的命令封装成对象,使它与命令的执行过程保持松耦合。
比如现在有一家客流量很小的餐厅,这家餐厅没有雇佣服务员,每个顾客进餐厅后直接告诉厨师自己要点的菜,厨师则立即开锅做菜。此时顾客点菜和厨师做菜这两个过程是紧密耦合在一起的。
但是在一家客流量很大的餐厅,这种模式会带来很严重的问题:厨师很难记住哪些顾客是先来的,也记不住每个顾客点了什么菜。为了解决这个问题,餐厅聘用了几个服务员,服务员将顾客的点菜情况写成一张张的清单,随后按顺序交给厨师,厨师就可以按照清单逐个做菜了。这家餐厅所采用的的就是典型的命令模式,这里的一张张清单就是命令模式中的一个个命令。这种模式使得顾客点菜和厨师做菜这两个过程得以解耦。
下面是上述过程的简要实现:
// 点菜命令对象
var OrderDishCommand = function (deskOrder, dishes) {
this.deskOrder = deskOrder;
this.dishes = dishes;
}
OrderDishCommand.prototype.excute = function () {
console.log('开始炒菜:' + this.dishes);
}
OrderDishCommand.prototype.cancel = function () {
console.log('取消点菜');
}
// 存储点菜清单
var cookCommands = [];
// 新增顾客时,创建一个清单,并push到清单数组
var addCommand = function (deskOrder, dishes) {
var cookCommand = new OrderDishCommand(deskOrder, dishes);
cookCommands.push(cookCommand);
}
addCommand ('1号桌', ['黄焖鸡']);
addCommand ('2号桌', ['黄焖大虾']);
上述就是顾客点菜生成清单的过程,有了这个清单,厨师就可以按照菜单顺序依次为每个顾客做出对应的菜(由于JavaScript是单线程的,而上述两个过程是相互独立的,所以这里厨师做菜的过程更适合由web worker来实现)。
所以,命令模式的本质就是将命令视为实体,将其与执行过程解耦。
又叫部分-整体模式。组合模式的定义是:将一组相似的对象组合成树结构,以构建一种部分-整体的层次关系,使得用户对单个对象和组合对象具有一致的访问方式。
组合模式的核心是,用多个相似的对象组合出一个更大的对象,并且组合出的对象与原对象的访问方式是一致的。
举个例子,假设我们在家里安装了很多智能家居,有智能空调,智能电视,智能电灯等。由于产自同一家公司,所以它们可以用同一个遥控器上的不同按钮直接唤醒。我们假设这个遥控器上目前只有唤醒这三个设备的命令:
var openAirConditioningCommand = {
excute: function () {
console.log('打开空调');
}
}
var openTVCommand = {
excute: function () {
console.log('打开电视');
}
}
var openLightCommand = {
excute: function () {
console.log('打开电灯');
}
}
openAirConditioningCommand.excute(); // 打开空调
openTVCommand.excute(); // 打开电视
openLightCommand.excute(); // 打开电灯
除了这些执行基本命令的按钮,遥控器上还有若干个可编程按钮,它允许我们自由组合若干个命令,形成一个更复杂的命令。比如我们发现,每次回到家总是要重复依次点击上面三个按钮,太麻烦了,于是我们决定把三个命令组合成一个命令,我们称之为openAll
命令。看一下openAll命令的实现:
var openAllCommand = {
excute: function () {
openAirConditioningCommand.excute(); // 打开空调
openTVCommand.excute(); // 打开电视
openLightCommand.excute(); // 打开电灯
}
}
这个命令本质上就是连续调用之前的三个命令,依次打开三个设备。
现在这四个命令的结构如下:
看似我们得到了四个命令,但全部打开
这个命令本质上是由之前三个基本命令组合而来的,我们称之为“宏命令”,即它是由一些小命令组合而成的“大命令”。现在我们的遥控器上有了四个可用的按钮,对用户来说,它们没有任何差别,这就是组合模式下部分和整体的访问一致性。
假如我们还安装了智能防盗门,点击遥控器的“关门”按钮就可以自动关门,那我们还可以把上述“全部打开”命令和“关门”命令组合到一起,通过编程得到一个一键“关门并打开全部设备”的按钮。现在每当我们回到家,只要按下这个按钮,就可以实现关门、打开空调、电视机和电灯的全流程操作,不可谓不方便!
这就是典型的组合模式:从若干小命令可以组合成较大的命令,较大的命令还可以继续组合,产生更大的命令,并且组合产生的所有命令和基本命令一样拥有相同的访问方式。
除了上面的例子,文件系统的文件夹结构也是典型的使用组合模式的例子。各个子文件夹相互组合,形成更大的树状文件夹结构,而树上的各个文件夹都有相同的的访问方式。如果为文件夹下的每个文件也实现相同的接口(如scan方法),那么程序也可以以一致的方式直接访问文件。虽然我们一般认为组合出的文件夹存在父子关系,但组合模式并不强调它的父子关系,相反,它认为树上的任意文件夹都是等价的。
模板方法模式的定义是:把多个相似子类的公共方法提取到它们的父类中,以避免重复定义。
单看这个描述,模板方法模式似乎就是继承啊。这种说法基本没错,但不完全准确,两者最主要的差别是设计目的。继承的目的是建立类与类之间的父子关系,从而构建清晰的类层次图。而模板方法模式的目标则简单得多,就是为了复用若干类对象的某些方法,减少重复定义。也就是说,只要两类对象有着任何相同的行为,都可以通过模板方法模式把这些行为抽取出来,以避免将这些行为定义两次。当然了,如果公共的行为不多,使用模板方法模式重构很多时候也是得不偿失的。
模板方法模式其实就是基于继承的,在JavaScript中,它是基于原型链实现的。下面我们举书上的咖啡和茶的例子来说明模板方法模式。
首先,泡一杯咖啡大致有以下四个步骤:
而泡一杯茶也是四个步骤:
现在分别来定义咖啡和茶这两个类:
var Coffee = function(){
};
Coffee.prototype = {
boilWater () {
... },
brewCoffeeGriends () {
... },
pourInCup () {
... },
addSugarAndMilk () {
... },
init () {
this.boilWater(); // 把水煮沸
this.brewCoffeeGriends(); // 用沸水冲泡咖啡
this.pourInCup(); // 倒进杯子
this.addSugarAndMilk(); // 加糖和牛奶
}
}
var coffee = new Coffee();
coffee.init(); // 冲泡咖啡
var Tea= function(){
};
Tea.prototype = {
boilWater () {
... },
steepTeaBag () {
... },
pourInCup () {
... },
addLemon () {
... },
init () {
this.boilWater(); // 把水煮沸
this.steepTeaBag (); // 用沸水浸泡茶叶
this.pourInCup(); // 倒进杯子
this.addLemon (); // 加柠檬
}
}
var tea = new Tea();
tea.init(); // 冲泡茶叶
我们可以明显看到,无论是冲泡咖啡还是茶叶,都有把水煮沸和倒入杯子这两个步骤,而且所做的时完全一样。更进一步的,用沸水冲泡咖啡和用沸水浸泡茶叶都可以抽象为“泡”,而加糖和牛奶,以及加柠檬,都可以认为是加“辅料”。同样的,我们还可以把咖啡和茶都抽象为饮料,这样,上面冲泡咖啡和茶的过程可以抽象为以下四个步骤:
于是我们现在抽象出一个新的类,我们称之为“饮料”:
var Beverage = function(){
};
Beverage.prototype = {
boilWater () {
... },
brew () {
... },
pourInCup () {
... },
addCondiments () {
... },
init () {
this.boilWater(); // 把水煮沸
this.brew(); // “泡”饮料
this.pourInCup(); // 倒入杯子
this.addCondiments(); // 加辅料
}
}
然后让咖啡和茶都继承这个类:
Coffee.prototype = new Beverage();
Tea.prototype = new Beverage();
由于“泡”饮料和加辅料这两个步骤对咖啡和茶来说是不一样的,所以它们需要各自添加自己的实现,来覆盖原型上的方法:
// 咖啡的冲泡方法
Coffee.prototype.brew = function(){
console.log( '用沸水冲泡咖啡' );
};
// 咖啡加辅料的方法
Coffee.prototype.addCondiments = function(){
console.log( '加糖和牛奶' );
};
// 茶叶的冲泡方法
Tea.prototype.brew = function(){
console.log( '用沸水浸泡茶叶' );
};
// 茶叶加辅料的方法
Tea.prototype.addCondiments = function(){
console.log( '加柠檬' );
};
原本咖啡和茶这两个类各自有5个方法,现在由于把init、brew
和pourInCup
这三个相同的方法提取到了它们的父类Beverage
上,咖啡和茶只需要实现各自不同的两个方法即可。
注意,在进行公共方法抽取之前,我们其实并不打算构造“饮料”、“咖啡”和“茶”这三个类的父子关系,但是当我们分别实现了“咖啡”和“茶”之后,我们发现它们有太多重复的方法,于是使用了模板方法模式把它们抽取出来,以减少重复代码。这也是模板方法模式与继承最大的差别,如果采用继承的思想,我们第一步就应该是抽象出这三个类,以更好地描述类与类之间的关系。
实际上,在JavaScript中要实现模板方法模式并不必须使用继承,直接把共有方法抽取到一个对象,再使用mixin策略混入子类即可:
let commonMethods = {
boilWater () {
... },
pourInCup () {
... },
init () {
... }
}
Coffee.prototype = Object.assign({
}, commonMethods, Coffee.prototype);
Tea.prototype = Object.assign({
}, commonMethods, Tea.prototype);
这得益于JavaScript构建对象的灵活性和mixin强大的能力。
享元模式的定义是:利用共享技术来减少大量的轻量级对象造成的资源消耗。
举个例子,假如某个内衣工厂生产50种男士内衣和50种女士内衣,现在需要分别给这100种内衣拍摄效果图上传到淘宝店铺。为了拍摄效果,工厂决定采购一批塑料模特。在不使用享元模式的情况下,工厂可能会一次采购50个男士模型和50个女士模型,依次穿上内衣拍摄效果图。
但我们很容易可以看出来,其实工厂只需要采购一个男士模型和一个女士模型即可,所有的男士内衣可以共享同一个男士模型,而女士内衣可以共享同一个女士模型,这就是享元模式的思维。采用享元模式后,工厂就可以节约购买98个塑料模特的资金。假如该工厂生产的是1000种内衣,那么资金的节约额就会变得很可观了。
我们来看一下如何实现上述享元模式,首先我们来定义模型对象:
var Model = function (sex) {
this.sex = sex;
}
Model.prototype.takePhoto = function () {
console.log('拍摄:' + this.sex + '_' + this.underwear);
}
接着构造两个模型对象:
var maleModel = new Model('male');
var femaleModel = new Model('female');
下面用这两个模型,依次穿上50种男士和女士内衣来拍摄照片:
// 依次给男士模型穿上内衣并拍摄
for (var i = 0; i < 50; i++) {
// 给男士模型依次穿上内衣
maleModel.underwear = maleUnderwears[i];
maleModel.takePhoto();
}
// 依次给女士模型穿上内衣并拍摄
for (var i = 0; i < 50; i++) {
// 给男士模型依次穿上内衣
femaleModel.underwear = femaleUnderwears[i];
femaleModel.takePhoto();
}
上述示例只是享元模式最简单的应用。除了这个我们刻意构造的例子,像后端的连接池概念,也是享元模式的典型应用。每当有客户端访问后端服务器时,服务器就会创建一个线程来处理这个连接;当用户请求结束时,后端并不会销毁这个线程,而是将它放入一个连接池,当下次有新的用户连接时,服务器会检查连接池中是否有空闲线程,如果有,那么就可以立即拿出来使用。在并发量很大的情况下,这种设计可以大大提升系统的并发能力。
责任链模式的定义是:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。
举个例子,假如你某天突然患病,需要请几天假,那么你就需要写张请假条交给班主任。班主任确认了情况后,如果批准了你的请假条,你就可以得到几天假期。
但是假如你的病比较严重,可能至少一个月都无法去学校上课,班主任此时就没有权利决定是否批准了,于是班主任找来了年级主任,年级主任核实情况后认为可以批准,那么请假成功。
再假如你可能一整个学年都无法来学校上课,需要申请保留一年学籍,这时候年级主任也无法处理这个申请了,于是找来了校长,由校长亲自决定是否要批准。
上面的例子中,请假
是要处理的任务,而班主任
、年级主任
和校长
这三者就是可能要处理这个任务的三个对象。请假任务需要依次经过班主任、年级主任和校长三者的确认和处理,于是三者就构成了一条责任链。
需要注意的是,请假任务必须按照特定的顺序流经责任链,比如该例子中,请假条必须经过班主任确认后才可以交给年级主任;同样,也只有经过年级主任确认,才可以交给校长。三者的顺序不可调换,也不允许越级上报。另外,责任链不一定要完整走完。比如如果学生只是请一天假,那么班主任自己就可以选择批准或者否决,而不需要再交给年级主任和校长处理。
下面我们简单实现上述责任链。假设班主任可以批准一周以内的请假,年级主任可以批准90天以内的请假,而大于90天的必须校长批准。我们先不使用责任链实现该过程:
function processLeave (days) {
var ok = false;
if (days <= 7) {
ok = headTeacher.confirm();
console.log('班主任意见:' + (ok ? '批准' : '驳回'));
} else if (days <= 90) {
ok = classManager.confirm();
console.log('年级主任意见:' + (ok ? '批准' : '驳回'));
} else{
ok = schoolMaster.confirm();
console.log('校长意见:' + (ok ? '批准' : '驳回'));
}
}
processLeave(12); // 年级主任意见:批准
显然,上述函数是不稳定的。假设我们新增一条规则:如果学生只请一节课的假,那么直接向带课老师申请即可,无需上报班主任。此时我们必须重构上面的条件语句,在if语句前面再加一条规则。再假如,最近年级主任刚刚离职,该职位暂时空缺,所以90天以内的请假直接由班主任审批,那么我们又必须合并前两条判断逻辑。这样,上面的函数将变得脆弱无比,程序的稳定性也会很差。
为了解决这个问题,我们把班主任、年级主任和校长分别独立出来:
var headTeacher = {
processLeave: function (days) {
if (days <= 7) {
console.log('班主任已处理')
} else {
classMaster.processLeave(days);
}
}
}
var classMaster= {
processLeave: function (days) {
if (days <= 7) {
console.log('年级主任已处理')
} else {
schoolMaster.processLeave(days);
}
}
}
var schoolMaster= {
processLeave: function (days) {
console.log('校长已处理')
}
}
headTeacher(12); // 年级主任已处理
headTeacher(120); // 校长已处理
现在三者已经形成了一条链式结构:
形成这条责任链之后,如果需求发生变动,那么该需求影响到了谁的职责,就修改谁的处理逻辑,而不受影响的对象则不用修改。
在服务端,责任链模式更常见。比如Java中的filter链,就是典型的责任链模式。请求在到达controller前,会依次经过配置的filter,如果符合filter的过滤条件,则处理它,否则继续向后传递。同样的,Nodejs常用的中间件模式,也是责任链模式的实现。请求到达Node服务后,会沿着中间件定义的顺序,依次向后传递,每个中间件可以选择处理它或是仅仅向后传递。
中介者模式的定义是:使用一个中间对象,来管理多个对象之间的复杂关系。
举个例子,假如我们刚刚来到一个城市上班,想在上班地附近租个房子。但是由于附近的小区很多,价位天差地别,我们一时间很难找到最合适的房子。同样的,对每个小区的管理者来说,吸引大量合适的房客来看房也是很困难的。找房者和小区之间形成了一种多对多的关系,使得找房变得极为困难。
为了解决这个问题,我们找到了中介。中介手中掌握了附近所有小区的信息,所以他们可以根据我们的需求,给我们列出一份合适的清单供我们选择。同样的,对小区来说,中介手里掌握了大量找房者的信息,像他们所能接受的价位,以及联系方式等。小区管理者可以快速联系到合适的看房者。
由于中介者的参与,无论是找房者,还是小区,都不用再维护与对方的关系,而只需要与中介保持联系即可。所以这个中介其实就可以看做是一个中央控制系统,这也是中介者模式的核心思想。
我们现在简单实现上述过程,首先来看找房者:
function Watcher (name, expectedPrice) {
this.name = name;
this.expectedPrice = expectedPrice;
}
Watcher.prototype.lookingForVillage = function () {
console.log('辛苦找房中...');
}
var watcher1 = new Watcher('张三', 2500);
watcher1.lookingForVillage(); // 辛苦找房中...
var watcher2 = new Watcher('李四', 1500);
watcher2.lookingForVillage(); // 辛苦找房中...
接着来看小区这边:
function Village (name, price) {
this.name = name;
this.price = price;
}
Village.prototype.lookingForWatchers = function () {
console.log('辛苦联系房客中...');
}
var village1 = new Village('东方诗苑小区', 2500);
village1.lookingForWatchers(); // 辛苦联系房客中...
var village2 = new Village('南湖小区', 1500);
village2.lookingForWatchers(); // 辛苦联系房客中...
找房者和小区现在都必须各自辛苦联系对方,我们来看引入中介者之后会带来哪些改变:
var intermediary = {
watchers: [watcher1, watcher2],
villages: [village1, village2],
lookingForVillage (watcher) {
console.log('为' + watcher.name + '寻找合适的房源...');
},
lookingForWatcher (village) {
console.log('为' + village.name + '寻找合适的房客...');
}
}
// 中介为所有的房客找房
intermediary.watchers.forEach(watcher => {
intermediary.lookingForVillage(watcher);
})
// 中介为所有的小区找房客
intermediary.villages.forEach(village => {
intermediary.lookingForVillage(village);
})
现在,看房者只需把自己推送到中介的找房者名单中,中介就会帮助看房者找到合适的房源;同样,小区只需要把自己推送到中介的小区列表中,也可以借助中介找到合适的房客。
装饰模式的定义是:向类的某个对象动态地添加一些职责,而不影响从该类派生的其他对象。
装饰模式的思想是“即加即用”。在传统的面向对象语言(如Java)中要实现装饰模式模式,需要把某个对象传入另一个构造函数来添加额外的装饰,我们用JavaScript来模拟一下。假设我们现在定义了一个“飞机”类:
var Plane = function (name) {
this.name = name;
};
Plane.prototype.fly = function () {
... }
var eagle1 = new Plane('雄鹰一号');
eagle1.fly();
这架“雄鹰一号”飞机现在只有普通的飞行能力。但是我们突然接到任务,要给它挂载上炸弹,让它执行轰炸任务。由于并不是所有飞机都有轰炸能力,所以我们不能给Plane
类添加轰炸功能。但是如果要新增一个轰炸机类,似乎代价又太大了,毕竟假如这个临时任务被取消,这个新的类就白白定义了。
为此,我们选择创建一个代价较小的装饰类:BombDecorator
,而经它装饰过的飞机都会增加一个新的方法:fire
,我们来定义这个类:
var BombDecorator = function (plane) {
this.plane = plane;
}
BombDecorator.prototype.fire = function () {
console.log('轰炸...');
}
现在我们用这个装饰类来装饰“雄鹰一号”:
eagle1 = new BombDecorator(eagle1);
eagle1.fire(); // 轰炸...
可以看到,我们把“雄鹰一号”作为参数传入了装饰器类,装饰器类为它装饰上fire
方法后,它就具备了“轰炸”能力。这就是装饰者模式的核心思路。
在JavaScript中实现装饰者模式则更简单,因为JavaScript本身并不介意直接给对象添加方法,所以上面的例子在JavaScript中可以这样简单实现:
eagle1.fire = function () {
console.log('轰炸...');
}
eagle1.fire(); // 轰炸...
这才叫真正的“即加即用”!
状态模式的定义是:一个对象会根据自身的状态,表现出不同的行为。
举个例子,现在有一个电灯,它只有开和关两个状态。在电灯开着的状态下,按下开关,电灯就会关闭;在电灯关闭的状态下,按下开关,电灯就会打开。根据电灯的状态不同,同一个开关按钮会表现出不同的行为,这就是状态模式所要描述的情况。
在讲解命令模式时,我们曾把命令封装成对象,现在,我们则是要把状态封装成对象。我们先看一下不使用状态模式如何实现上述逻辑:
// 定义电灯
var Light = function () {
this.state = 'off';
this.button = null;
}
// 初始化一个按钮,并定义点击事件
Light.prototype.init = function(){
var button = document.createElement( 'button' ),
self = this;
button.innerHTML = '开关';
this.button = document.body.appendChild( button );
this.button.onclick = function(){
self.pressButton();
}
};
// 处理开关按下的事件
Light.prototype.pressButton = function(){
if ( this.state === 'off' ){
console.log( '开灯' );
this.state = 'on';
} else if ( this.state === 'on' ){
console.log( '关灯' );
this.state = 'off';
}
};
var light = new Light();
light.init();
现在连续按下按钮,电灯的状态将在开和关之间来回切换。我们看到,当按钮按下时,状态如何切换是由pressButton
来定义的,它内部根据当前电灯的状态state
,判断下一步电灯是该开还是关。
上面的代码处理当前需求并没有问题,因为状态的数量很少,使用命令式的条件语句就可以应对。不过当状态非常多时,这种方式就很难应对了。比如我们的电灯不止开和关两种状态,而是“关闭、弱光、强光、超强光”这四个状态,那么pressButton
这个方法的条件语句就会迅速膨胀,因此pressButton
是很不稳定的。
即使对象的状态数量是固定的,它们之间也可能存在复杂的关系,这种关系有时候也相当难维护。例如我们现在要编写一个HTML5版的“街霸”游戏,游戏中的角色有“前进、站立、后退、踢腿、出拳、蹲下、跳跃”等。各个状态之间有以下限制:
这里只是列举了一小部分限制,要编写真正的“街霸”游戏,状态关系比这要复杂的多。现在再用一个字符串来表示角色状态,然后使用条件语句定义角色行为已经完全不适用了,必须要把每个状态封装成对象才能更好地厘清各个状态之间的关系。
由于“街霸”例子的状态关系比较复杂,我们仍以电灯的例子来演示如何使用状态模式。假设电灯共三种状态:关闭,弱光和强光,我们分别定义以下三个类:
// 关闭状态
var OffLightState = function( light ){
this.light = light;
};
OffLightState.prototype.pressButton = function(){
console.log( '弱光' ); // offLightState 对应的行为
this.light.setState( this.light.weakLightState ); // 切换状态到weakLightState
};
// 弱光状态
var WeakLightState = function( light ){
this.light = light;
};
WeakLightState.prototype.pressButton = function(){
console.log( '强光' ); // weakLightState 对应的行为
this.light.setState( this.light.strongLightState ); // 切换状态到strongLightState
};
// 强光状态
var StrongLightState = function( light ){
this.light = light;
};
StrongLightState.prototype.pressButton = function(){
console.log( '关灯' ); // strongLightState 对应的行为
this.light.setState( this.light.offLightState ); // 切换状态到 offLightState
};
每个电灯现在都拥有三个状态:
var Light = function(){
this.offLightState = new OffLightState( this );
this.weakLightState = new WeakLightState( this );
this.strongLightState = new StrongLightState( this );
this.button = null;
};
Light.prototype.init = function(){
...
var self = this;
// 设置当前状态
this.currState = this.offLightState;
this.button.onclick = function(){
self.currState.pressButton();
}
};
init方法中为当前电灯实例设置状态currState
为:offLightState
,即关闭状态。注意新的实现与之前的不同:现在电灯的状态不是用字符串表示,而是具体的状态对象;按钮按下事件也不是直接由电灯的pressButton
处理,而是由状态对象的pressButton
来处理。
现在当按下按钮时,当前的状态对象将处理这个事件。这个状态对象只需要考虑在自身状态下该执行何种操作,这样问题就大大简化了。
我们仔细思考状态模式是如何简化问题的。在不使用状态模式的情况下,我们只给电灯对象定义了一个processButton
方法,所有的状态转换都有它管理,此时我们要考虑的下面三个状态的转换关系:
而把状态单独变成对象后,我们分别立足于单个状态,只考虑该状态到其他状态的转换策略,这样我们就把上面的复杂问题分解成了三个子问题:
虽然问题的数量从一个变成了三个,但每个问题的难度却大大下降,这更加有利于问题的解决。在面对更复杂的问题时,这种模式的价值是不可估量的。
状态机其实就是对状态模式的一种实现,而我们知道,Promise就是一个状态机,所以Promise就是对状态模式的一种实现。
适配器模式的定义是:使两个不兼容的软件实体可以在一起工作。
先举个生活中的例子来说明适配器模式:我们知道,中国的家庭电压一般是220V,而手机的充电电压通常是5V,那么220V的电压如何给5V的电源充电呢?这就要感谢手机附带的充电头了,它本质上也是一个变压器,可以把220V的输入电压转换为手机充电所需的5V电压。这个充电头就是适配器模式的一个范例。
再举个开发中的例子,假如我们的系统现在需要接入一个第三方系统,但是这个第三方系统的接口所提供的数据和我们系统内的数据结构并不兼容。强迫第三方修改接口显然不太现实,而改动我们的代码代价又过大。为了解决这个问题,我们搭建了一个Node服务,我们现在直接把请求发给Node服务,由它调用第三方系统接口,取到数据之后,由Node把数据转换为我们需要的结构返回回来。通过新增一个Node服务,我们可以正确处理第三方系统的数据,这个Node服务就是一个适配器。
适配器模式的概念非常简单,也很容易理解,我们只举一个简单的例子来说明它。假如现有一个接口,返回的是一棵树的数据,不过接口返回的是一个数组,pId表示的是该节点父节点的id:
res = [
{
id: '1', pId: '' },
{
id: '11', pId: '1' },
...
]
但是前端需要的是一个树形对象,无法渲染上述一维数组,所以我们可以编写一个函数作为适配器,将其转化为树结构:
function convertToTree (arr) {
... // 实现略
}
var treeData = convertToTree(res);
这个convertToTree
函数就是一个适配器。
除了以上14中设计模式,书中还简要介绍了门面模式和AOP模式,我们简要说一下它们的概念:
门面模式(Facade pattern):对一个类的接口进行封装,使得它更易于使用。
AOP(Aspect Oriented Programming):面向切面编程,核心是把业务逻辑和通用模块分离开。
我们举一个门面模式的例子:假设有一台老式洗衣机,提供了“注水”、“漂洗”、“脱水”这三个功能按钮。按下注水按钮,洗衣机自动注水;注水完毕后按下漂洗按钮,洗衣机开始洗衣;洗完之后再按脱水,即可对衣物脱水。后来洗衣机生产商为了操作方便,提供了一键洗衣按钮,只要按下,即可启动默认的洗衣流程。这个一键洗衣按钮,就是上述三个按钮的“门面”,它使得用户使用洗衣机的方式更加简单了。
再举一个AOP模式的例子。在JavaScript中,AOP模式实现起来比较简单,这得益于JavaScript的高阶函数特性。比如我们在log.js
中定义了一个函数:
export function collectLog () {
console.log('收集日志');
}
在需要进行日志收集的时候可以这样使用它:
import {
collectLog } from './log.js';
function sendMsg (param, collectLog) {
...
collectLog(); // 收集日志
}
sendMsg(collectLog);
现在收集日志的函数被放在单独的文件里,可以很容易复用,这样就实现了业务逻辑和通用模块的分离。
最后我们还要简要介绍一下JavaScript中的设计原则。《设计模式》中罗列出了六大原则:
在JavaScript中常用的原则包括:单一职责原则、开放封闭原则和最少知识原则,其他三个我们这里暂不介绍,感兴趣的可以查阅相关资料。
单一职责原则说的是,就一个类而言,应该仅有一个引起它变化的原因。通俗的来说,就是一个类只做一件事。
在JavaScript中,类的使用较少,这一原则主要体现在对象和方法上,也就是一个对象只描述一个事物,一个函数只做一件事。一个对象描述的事物过多,就会导致它的逻辑变得很复杂,难以维护;一个函数所做的时过多,理解和修改它就会变得很困难,函数本身也难以复用。
开放-闭合原则说的是,一个软件实体应该对扩展开放,对修改封闭。通俗的讲,就是只能加,不能改。
开放-闭合原则是软件设计最重要的原则之一,不过它不是绝对的。它是要求我们只要能扩展,就尽量避免修改,除非扩展无法实现,才可以去修改。“策略模式”就是对这一原则的最佳实践之一,它把程序中用到的不稳定的算法提取到算法库,只留下稳定的业务逻辑,如果以后需要实现新的功能,直接扩充算法库即可。
最少知识原则说的是,一个软件实体应该尽可能少地与其他实体发生相互作用。
比如在中介者模式的例子中,每个找房者与小区之间都存在依赖关系,我们通过增加一个中介,使得所有找房者和小区都只与中介者产生依赖关系,这大大简化了问题的复杂度。
以上就是JavaScript中常用的设计模式。模式本身理解起来大都不算困难,但是真正把它们融入到软件设计中却并不容易,这需要一点点的经验积累。除了熟记这些设计模式外,我们还应该深刻体会六大基本原则的本质,把它们深深融入到日常开发中。