JavaScript 常见设计模式实战解析

作者:半夏

时间:2026-10-29

浏览:199

本文系统总结了 JavaScript 中常用的设计模式,包括单例、工厂、观察者、适配器、代理、桥接、外观、访问者、策略、模板方法、中介者、迭代器、组合、备忘录、职责链、享元及状态模式。通过实际代码案例,解析各模式的适用场景与实现细节,帮助开发者提升代码质量与可维护性。

文章目录

引言与语言特性

《Practical Common Lisp》的作者 Peter Seibel 曾指出,若需依赖某种模式,往往意味着语言本身存在缺陷。这种观点针对的是因语言先天不足而不得不寻求通用解决方案的情况。

无论是弱类型还是强类型,静态或动态,命令式或声明式语言,每种语言都有其固有的优缺点。这就像一名牙买加短跑或拳击运动员,在特定领域具有优势,但在瑜伽等需要柔韧性的项目上可能表现平平。

在游戏角色中,术士和暗影牧师是优秀的辅助,而背着梅肯满地图飞的敌法则显得格格不入。映射到编程中,静态语言实现装饰者模式可能较为繁琐,而 JavaScript 允许随时向对象添加方法,使得装饰者模式在 JS 中显得不那么必要。

关于 JavaScript 设计模式的书籍相对较少,《Pro JavaScript Design Patterns》是其中较为经典的一本,但其示例较为冗长。本文结合工作中的实际代码,总结个人理解。若存在偏差,欢迎指正。

单例模式

一 单例模式

单例模式旨在产生类的唯一实例,但 JavaScript 是一种“无类”语言。许多文章将 {} 视为单例使用,虽勉强说得通,但 JavaScript 生成对象的方式多样,我们来看一种更有意义的单例实现。

常见需求是点击按钮时弹出遮罩层,例如 web.qq.com 的登录界面。

 引言与语言特性

生成灰色背景遮罩层的代码易于编写。


var createMask = function(){
return document,body.appendChild( document.createElement(div) );
}

$( 'button' ).click( function(){
Var mask = createMask();
mask.show();
})

问题在于,该遮罩层是全局唯一的。每次调用 createMask 都会创建新的 div,虽然可以在隐藏时移除它,但这显然不合理。

第二种方案是在页面初始化时创建好 div,并用变量引用它。


var mask = document.body.appendChild( document.createElement( ''div' ) );
$( ''button' ).click( function(){
mask.show();
} )

这确实保证了页面只创建一个遮罩层 div,但另一个问题是,如果永远不需要这个遮罩层,就会浪费一个 div 节点。对 DOM 节点的操作应尽可能吝啬。

如果能借助变量判断 div 是否已创建,是否更好?


var mask;
var createMask = function(){
if ( mask ) return mask;
else{
mask = document,body.appendChild( document.createElement(div) );
return mask;
}
}

这看起来不错,完成了一个产生单例对象的函数。但仔细检查代码,仍有不妥之处。

首先,该函数存在副作用,改变了外部变量 mask 的引用,在多人协作项目中,createMask 是不安全的。此外,mask 全局变量并非必需。让我们改进一下。


var createMask = function(){
var mask;
return function(){
return mask || ( mask = document.body.appendChild( document.createElement('div') ) )
}
}()

使用简单的闭包将变量 mask 包裹起来,至少对 createMask 函数而言,它是封闭的。

或许有人觉得单例模式过于简单。确实,一些设计模式非常简单,即使未关注过设计模式概念,日常代码中也不自觉使用了一些模式。就像多年前明白老汉推车原理时,感叹“原来这就是老汉推车”。

GOF 的 23 种设计模式,在软件开发中早已存在并被反复使用。如果程序员未明确意识到使用过某些模式,下次可能会错过更合适的设计(这段话来自《松本行弘的程序世界》)。

回到正题,前面的单例仍有缺点,仅适用于创建遮罩层。如果需要创建一个唯一的 xhr 对象,能否找到一个通用的 singleton 包装器?

JavaScript 中函数是第一型,意味着函数可作为参数传递。看看最终代码。


var singleton = function( fn ){
var result;
return function(){
return result || ( result = fn .apply( this, arguments ) );
}
}
var createMask = singleton( function(){
return document.body.appendChild( document.createElement('div') );
})

用一个变量保存第一次的返回值,若已赋值,后续调用优先返回该变量。真正创建遮罩层的代码通过回调函数传入 singleton 包装器。这种方式其实叫桥接模式,关于桥接模式,稍后讨论。

然而 singleton 函数并非完美,仍需一个变量 result 寄存 div 引用。遗憾的是,JavaScript 的函数式特性尚不足以完全消除声明和语句。

简单工厂模式

二 简单工厂模式

简单工厂模式由一个方法决定创建哪个类的实例,这些实例通常拥有相同接口。该模式主要用于实例化类型在编译期不确定,而在执行期决定的情况。通俗地说,就像公司茶水间的饮料机,选咖啡还是牛奶取决于按哪个按钮。

简单工厂模式在创建 ajax 对象时也非常有用。

之前我写了一个处理 ajax 异步嵌套的库,地址在 https://github.com/AlloyTeam/DanceRequest。

该库提供了多种 ajax 请求方式,包括 xhr 对象的 get、post,以及跨域用的 jsonp 和 iframe。为方便使用,这些方式都抽象到了同一个接口中。


var request1 = Request('cgi.xx.com/xxx' , ''get' );
request1.start();
request1.done( fn );
var request2 = Request('cgi.xx.com/xxx' , ''jsonp' );
request2.start();
request2.done( fn );

Request 实际上就是一个工厂方法,具体产生 xhr 实例还是 jsonp 实例,由后续代码决定。

实际上在 JavaScript 中,所谓的构造函数也是一个简单工厂,只是披了一件 new 的外衣。我们扒掉这件衣服看看里面。

通过这段代码,在 firefox、chrome 等浏览器里,可以完美模拟 new。


function A( name ){
this.name = name;
}
function ObjectFactory(){
var obj = {},
Constructor = Array.prototype.shift.call( arguments );
obj.__proto__ = typeof Constructor .prototype === 'number' ? Object.prototype
: Constructor .prototype;
var ret = Constructor.apply( obj, arguments );
return typeof ret === 'object' ? ret : obj;
}
var a = ObjectFactory( A, 'svenzeng' );
alert ( a.name ); 
//svenzeng

这段代码来自 es5 的 new 和构造器的相关说明,可以看到,所谓的 new,本身只是一个对象的复制和改写过程,具体生成什么由调用 ObjectFactory 时传进去的参数决定。

观察者模式

三 观察者模式

观察者模式(又叫发布者-订阅者模式)应该是最常用的模式之一,在许多语言里都得到大量应用,包括我们平时接触的 dom 事件,也是 js 和 dom 之间实现的一种观察者模式。


div.onclick = function click (){
alert ( ''click' )
}

只要订阅了 div 的 click 事件,当点击 div 的时候,function click 就会被触发。

那么到底什么是观察者模式呢?先看看生活中的观察者模式。

好莱坞有句名言:“不要给我打电话,我会给你打电话”。这句话解释了一个观察者模式的来龙去脉。其中“我”是发布者,“你”是订阅者。

再举个例子,我来公司面试的时候,完事之后每个面试官都会对我说:“请留下你的联系方式,有消息我们会通知你”。在这里“我”是订阅者,面试官是发布者。所以我不用每天或者每小时都去询问面试结果,通讯的主动权掌握在了面试官手上。而我只需要提供一个联系方式。

观察者模式可以很好的实现 2 个模块之间的解耦。假如我正在一个团队里开发一个 html5 游戏。当游戏开始的时候,需要加载一些图片素材。加载好这些图片之后开始才执行游戏逻辑。假设这是一个需要多人合作的项目。我完成了 Gamer 和 Map 模块,而我的同事 A 写了一个图片加载器 loadImage。

loadImage 的代码如下


loadImage( imgAry, function(){
Map.init();
Gamer.init();
} )

当图片加载好之后,再渲染地图,执行游戏逻辑。嗯,这个程序运行良好。突然有一天,我想起应该给游戏加上声音功能。我应该让图片加载器添上一行代码。


loadImage( imgAry, function(){
Map.init();
Gamer.init();
Sount.init();
} )

可是写这个模块的同事 A 去了外地旅游。于是我打电话给他,喂。你的 loadImage 函数在哪,我能不能改一下,改了之后有没有副作用。如你所想,各种不淡定的事发生了。如果当初我们能这样写呢:


loadImage.listen( ''ready', function(){
Map.init();
})
loadImage.listen( ''ready', function(){
Gamer.init();
})
loadImage.listen( ''ready', function(){
Sount.init();
})

loadImage 完成之后,它根本不关心将来会发生什么,因为它的工作已经完成了。接下来它只要发布一个信号。


loadImage.trigger( ”ready' );

那么监听了 loadImage 的'ready'事件的对象都会收到通知。就像上个面试的例子。面试官根本不关心面试者们收到面试结果后会去哪吃饭。他只负责把面试者的简历搜集到一起。当面试结果出来时照着简历上的电话挨个通知。

说了这么多概念,来一个具体的实现。实现过程其实很简单。面试者把简历扔到一个盒子里,然后面试官在合适的时机拿着盒子里的简历挨个打电话通知结果。


Events = function() {
var listen, log, obj, one, remove, trigger, __this;
obj = {};
__this = this;
listen = function( key, eventfn ) { 
//把简历扔盒子, key就是联系方式.
var stack, _ref; 
//stack是盒子
stack = ( _ref = obj[key] ) != null ? _ref : obj[ key ] = [];
return stack.push( eventfn );
};
one = function( key, eventfn ) {
remove( key );
return listen( key, eventfn );
};
remove = function( key ) {
var _ref;
return ( _ref = obj[key] ) != null ? _ref.length = 0 : void 0;
};
trigger = function() { 
//面试官打电话通知面试者
var fn, stack, _i, _len, _ref, key;
key = Array.prototype.shift.call( arguments );
stack = ( _ref = obj[ key ] ) != null ? _ref : obj[ key ] = [];
for ( _i = 0, _len = stack.length; _i < _len; _i++ ) {
fn = stack[ _i ];
if ( fn.apply( __this, arguments ) === false) {
return false;
}
}
return {
listen: listen,
one: one,
remove: remove,
trigger: trigger
}
}

最后用观察者模式来做一个成人电视台的小应用。


//订阅者

var adultTv = Event();
adultTv .listen( ''play', function( data ){
alert ( "今天是谁的电影" + data.name );
});
//发布者
adultTv .trigger( ''play', { 'name': '麻生希' } )

适配器模式

四 适配器模式

去年年前当时正在开发 dev.qplus.com,有个存储应用分类 id 的 js 文件,分类 id 的结构最开始设计的比较笨重。于是我决定重构它。我把它定义成一个 json 树的形式,大概是这样:


var category = {
music: {
id: 1,
children: [ , , , , ]
}
}

dev.qplus.com 里大概有 4,5 个页面都调用这个 category 对象。春节前我休了 1 个星期假。过年来之后发现邮箱里有封邮件,设计数据库的同学把 category..js 也重构了一份,并且其他几个项目里都是用了这份 category.js,我拿过来一看就傻眼了,和我之前定的数据结构完全不一样。

当然这是一个沟通上的反面例子。但接下来的重点是我已经在 N 个文件里用到了之前我定的 category.js。而且惹上了一些复杂的相关逻辑。怎么改掉我之前的代码呢。全部重写肯定是不愿意。所以现在适配器就派上用场了。

只需要把同事的 category 用一个函数转成跟我之前定义的一样。


my.category = adapterCategory ( afu.category );

适配器模式的作用很像一个转接口。本来 iphone 的充电器是不能直接插在电脑机箱上的,而通过一个 usb 转接口就可以了。

所以,在程序里适配器模式也经常用来适配 2 个接口,比如你现在正在用一个自定义的 js 库。里面有个根据 id 获取节点的方法$id()。有天你觉得 jquery 里的$实现得更酷,但你又不想让你的工程师去学习新的库和语法。那一个适配器就能让你完成这件事情。


$id = function( id ){
return jQuery( '#' + id )[0];
}

代理模式

五 代理模式

代理模式的定义是把对一个对象的访问,交给另一个代理对象来操作。

举一个例子,我在追一个 MM 想给她送一束花,但是我因为我性格比较腼腆,所以我托付了 MM 的一个好朋友来送。

这个例子不是非常好,至少我们没看出代理模式有什么大的用处,因为追 MM 更好的方式是送一台宝马。

再举个例子,假如我每天都得写工作日报(其实没有这么惨)。我的日报最后会让总监审阅。如果我们都直接把日报发给总监,那可能总监就没法工作了。所以通常的做法是把日报发给我的组长,组长把所有组员一周的日报都汇总后再发给总监。

实际的编程中,这种因为性能问题使用代理模式的机会是非常多的。比如频繁的访问 dom 节点,频繁的请求远程资源。可以把操作先存到一个缓冲区,然后自己选择真正的触发时机。

再来个详细的例子,之前我写了一个街头霸王的游戏,地址在 http://alloyteam.github.com/StreetFighter/

 引言与语言特性

游戏中隆需要接受键盘的事件,来完成相应动作。

于是我写了一个 keyManage 类。其中在游戏主线程里监听 keyManage 的变化。


var keyMgr = keyManage();
keyMgr.listen( ''change', function( keyCode ){
console.log( keyCode );
});

图片里面隆正在放升龙拳,升龙拳的操作是前下前+拳。但是这个 keyManage 类只要发生键盘事件就会触发之前监听的 change 函数。这意味着永远只能取得前,后,前,拳这样单独的按键事件,而无法得到一个按键组合。

好吧,我决定改写我的 keyManage 类,让它也支持传递按键组合。但是如果我以后写个 html5 版双截龙,意味着我每次都得改写 keyManage。我总是觉得,这种函数应该可以抽象成一个更底层的方法,让任何游戏都可以用上它。

所以最后的 keyManage 只负责映射键盘事件。而隆接受到的动作是通过一个代理对象处理之后的。


var keyMgr = keyManage();
keyMgr.listen( ''change', proxy( function( keyCode ){
console.log( keyCode ); 
//前下前+拳
)} );

至于 proxy 里面怎么实现,完全可以自由发挥。

还有个例子就是在调用 ajax 请求的时候,无论是各种开源库,还是自己写的 Ajax 类,都会给 xhr 对象设置一个代理。我们不可能频繁的去操作 xhr 对象发请求,而应该是这样。


var request = Ajax.get( 'cgi.xx.com/xxx' );
request.send();
request.done(function(){
});

桥接模式

六 桥接模式

桥接模式的作用在于将实现部分和抽象部分分离开来,以便两者可以独立的变化。在实现 api 的时候,桥接模式特别有用。比如最开始的 singleton 的例子。


var singleton = function( fn ){
var result;
return function(){
return result || ( result = fn .apply( this, arguments ) );
}
}
var createMask = singleton( function(){
return document.body.appendChild( document.createElement('div') );
})

singleton 是抽象部分,而 createMask 是实现部分。他们完全可以独自变化互不影响。如果需要再写一个单例的 createScript 就一点也不费力。


var createScript = singleton( function(){
return document.body.appendChild( document.createElement('script') );
})

另外一个常见的例子就是 forEach 函数的实现,用来迭代一个数组。


forEach = function( ary, fn ){
for ( var i = 0, l = ary.length; i < l; i++ ){
var c = ary[ i ];
if ( fn.call( c, i, c ) === false ){
return false;
}
}
}

可以看到,forEach 函数并不关心 fn 里面的具体实现。fn 里面的逻辑也不会被 forEach 函数的改写影响。


forEach( [1,2,3], function( i, n ){
alert ( n*2 )
} )
forEach( [1,2,3], function( i, n ){
alert ( n*3 )
} )

外观模式

七 外观模式

外观模式(门面模式),是一种相对简单而又无处不在的模式。外观模式提供一个高层接口,这个接口使得客户端或子系统更加方便调用。 用一段再简单不过的代码来表示


var getName = function(){
return ''svenzeng"
}
var getSex = function(){
return 'man'
}

如果你需要分别调用 getName 和 getSex 函数。那可以用一个更高层的接口 getUserInfo 来调用。


var getUserInfo = function(){
var info = a() + b();
return info;
}

也许你会问为什么一开始不把 getName 和 getSex 的代码写到一起,比如这样


var getNameAndSex = function(){
return 'svenzeng" + "man";
}

答案是显而易见的,饭堂的炒菜师傅不会因为你预定了一份烧鸭和一份白菜就把这两样菜炒在一个锅里。他更愿意给你提供一个烧鸭饭套餐。同样在程序设计中,我们需要保证函数或者对象尽可能的处在一个合理粒度,毕竟不是每个人喜欢吃烧鸭的同时又刚好喜欢吃白菜。 外观模式还有一个好处是可以对用户隐藏真正的实现细节,用户只关心最高层的接口。比如在烧鸭饭套餐的故事中,你并不关心师傅是先做烧鸭还是先炒白菜,你也不关心那只鸭子是在哪里成长的。

最后写个我们都用过的外观模式例子


var stopEvent = function( e ){ 
//同时阻止事件默认行为和冒泡
e.stopPropagation();
e.preventDefault();
}

访问者模式

八 访问者模式

GOF 官方定义:访问者模式是表示一个作用于某个对象结构中的各元素的操作。它使可以在不改变各元素的类的前提下定义作用于这些元素的新操作。我们在使用一些操作对不同的对象进行处理时,往往会根据不同的对象选择不同的处理方法和过程。在实际的代码过程中,我们可以发现,如果让所有的操作分散到各个对象中,整个系统会变得难以维护和修改。且增加新的操作通常都要重新编译所有的类。因此,为了解决这个问题,我们可以将每一个类中的相关操作提取出来,包装成一个独立的对象,这个对象我们就称为访问者(Visitor)。利用访问者,对访问的元素进行某些操作时,只需将此对象作为参数传递给当前访问者,然后,访问者会依据被访问者的具体信息,进行相关的操作。

据统计,上面这段话只有 5% 的人会看到最后一句。那么通俗点讲,访问者模式先把一些可复用的行为抽象到一个函数(对象)里,这个函数我们就称为访问者(Visitor)。如果另外一些对象要调用这个函数,只需要把那些对象当作参数传给这个函数,在 js 里我们经常通过 call 或者 apply 的方式传递 this 对象给一个 Visitor 函数。 访问者模式也被称为 GOF 总结的 23 种设计模式中最难理解的一种。不过这有很大一部分原因是因为《设计模式》基于 C++ 和 Smalltalk 写成。在强类型语言中需要通过多次重载来实现访问者的接口匹配。

而在 js 这种基于鸭子类型的语言中,访问者模式几乎是原生的实现,所以我们可以利用 apply 和 call 毫不费力的使用访问者模式,这一小节更关心的是这种模式的思想以及在 js 引擎中的实现。

我们先来了解一下什么是鸭子类型,说个故事: 很久以前有个皇帝喜欢听鸭子呱呱叫,于是他召集大臣组建一个一千只鸭子的合唱团。大臣把全国的鸭子都抓来了,最后始终还差一只。有天终于来了一只自告奋勇的鸡,这只鸡说它也会呱呱叫,好吧在这个故事的设定里,它确实会呱呱叫。后来故事的发展很明显,这只鸡混到了鸭子的合唱团中。— 皇帝只是想听呱呱叫,他才不在乎你是鸭子还是鸡呢。

这个就是鸭子类型的概念,在 js 这种弱类型语言里,很多方法里都不做对象的类型检测,而是只关心这些对象能做什么。 Array 构造器和 String 构造器的 prototype 上的方法就被特意设计成了访问者。这些方法不对 this 的数据类型做任何校验。这也就是为什么 arguments 能冒充 array 调用 push 方法。

看下 v8 引擎里面 Array.prototype.push 的代码:


function ArrayPush() { var n = TO_UINT32( this.length );
var m = %_ArgumentsLength(); for (var i = 0; i < m; i++) { this[i+n] = %_Arguments(i); 
//属性拷贝 } this.length = n + m; //修正length return this.length;}

可以看到,ArrayPush 方法没有对 this 的类型做任何显示的限制,所以理论上任何对象都可以被传入 ArrayPush 这个访问者。

不过在代码的执行期,还是会受到一些隐式限制,在上面的例子很容易看出要求:

1、this 对象上面可储存属性。//反例:值类型的数据 2、this 的 length 属性可写。//反例:function 对象,function 有一个只读的 length 属性,表示形参个数。

如果不符合这 2 条规则的话,代码在执行期会报错。也就是说,Array.prototype.push.call(1, 'first') 和 Array.prototoype.push.call(function(){}, 'first') 都达不到预期的效果。

利用访问者,我们来做个有趣的事情。给一个 object 对象增加 push 方法。


var Visitor = {}
Visitor .push = function(){
return Array.prototype.push.apply( this, arguments );
}
var obj = {};
obj.push = Visitor .push;
obj.push( '"first" );
alert ( obj[0] ) 
//"first"
alert ( obj.length ); 
//1

策略模式

九 策略模式

策略模式的意义是定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换。 一个小例子就能让我们一目了然。

回忆下 jquery 里的 animate 方法。


$( div ).animate( {"left: 200px"}, 1000, 'linear' ); 
//匀速运动
$( div ).animate( {"left: 200px"}, 1000, 'cubic' ); 
//三次方的缓动

这 2 句代码都是让 div 在 1000ms 内往右移动 200 个像素。linear(匀速)和 cubic(三次方缓动)就是一种策略模式的封装。 再来一个例子。上半年我写的 dev.qplus.com,很多页面都会有个即时验证的表单。表单的每个成员都会有一些不同的验证规则。

 引言与语言特性

比如姓名框里面,需要验证非空,敏感词,字符过长这几种情况。当然是可以写 3 个 if else 来解决,不过这样写代码的扩展性和维护性可想而知。如果表单里面的元素多一点,需要校验的情况多一点,加起来写上百个 if else 也不是没有可能。 所以更好的做法是把每种验证规则都用策略模式单独的封装起来。需要哪种验证的时候只需要提供这个策略的名字。就像这样:


nameInput.addValidata({
notNull: true,
dirtyWords: true,
maxLength: 30
})
而notNull,maxLength等方法只需要统一的返回true或者false,来表示是否通过了验证。
validataList = {
notNull: function( value ){
return value !== '';
},
maxLength: function( value, maxLen ){
return value.length() > maxLen;
}
}

可以看到,各种验证规则很容易被修改和相互替换。如果某天产品经理建议字符过长的限制改成 60 个字符。那只需要 0.5 秒完成这次工作。

模板方法模式

十 模版方法模式

模式方法是预先定义一组算法,先把算法的不变部分抽象到父类,再将另外一些可变的步骤延迟到子类去实现。听起来有点像工厂模式(非前面说过的简单工厂模式)。 最大的区别是,工厂模式的意图是根据子类的实现最终获得一种对象。而模版方法模式着重于父类对子类的控制。

按 GOF 的描叙,模版方法导致一种反向的控制结构,这种结构有时被称为“好莱坞法则”,即“别找我们,我们找你”。这指的是一个父类调用一个子类的操作,而不是相反。 一个很常用的场景是在一个公司的项目中,经常由架构师搭好架构,声明出抽象方法。下面的程序员再去分头重写这些抽象方法。

在深入了解之前,容许我先扯远一点。 作为一个进化论的反对者,假设这个世界是上帝用代码创造的。那么上帝创造生命的时候可能就用到了模版方法模式。看看他是怎么在生命构造器中声明模版方法的:


var Life = function(){
}
Life.prototype.init = function(){
this.DNA复制();
this.出生();
this.成长();
this.衰老();
this.死亡();
}
this.prototype.DNA复制 = function(){
&*$%&^%^&(&(&(&&(^^(*) 
//看不懂的代码
}
Life.prototype.出生 = function(){
}
Life.prototype.成长 = function(){
}
Life.prototype.衰老 = function(){
}
Life.prototype.死亡 = function(){
}

其中 DNA 复制是预先定义的算法中不变部分。所有子类都不能改写它。如果需要我们可以写成 protected 的类型。 而其他的函数在父类中会被先定义成一个空函数(钩子)。然后被子类重写,这就是模版方法中所谓的可变的步骤。 假设有个子类哺乳动物类继承了 Life 类。


var Mammal = function(){
}
Mammal.prototype = Life.prototype; 
//继承Life

然后重写出生和衰老这两个钩子函数。


Mammal.prototope.出生 = function(){
'胎生()
}
Mammal.prototype.成长 = function(){
//再留给子类去实现
}
Mammal.prototope.衰老 = function(){
自由基的过氧化反应()
}
Life.prototype.死亡 = function(){
//再留给子类去实现
}
//再实现一个Dog类
var = Dog = function(){
}
//Dog继承自哺乳动物.
Dog.prototype = Mammal.prototype;
var dog = new Dog();
dog.init();

至此,一只小狗的生命会依次经历 DNA 复制,出生,成长,衰老,死亡这几个过程。这些步骤早在它出生前就决定了。所幸的是,上帝没有安排好它生命的所有细节。它还是能通过对成长函数的重写,来成为一只与众不同的小狗。

举个稍微现实点的例子,游戏大厅中的所有游戏都有登录,游戏中,游戏结束这几个过程,而登录和游戏结束之后弹出提示这些函数都是应该公用的。 那么首先需要的是一个父类。


var gameCenter = function(){
}
gameCenter.ptototype.init = function(){
this.login();
this.gameStart();
this.end();
}
gameCenter.prototype.login= function(){
//do something
}
gameCenter.prototype.gameStart= function(){
//空函数, 留给子类去重写
}
gameCenter.prototype.end= function(){
alert ( "欢迎下次再来玩" );
}

接下来创建一个斗地主的新游戏,只需要继承 gameCenter 然后重写它的 gameStart 函数。


var 斗地主 = function(){
}
斗地主.prototype = gameCenter.prototype; 
//继承
斗地主.prototype.gameStart = function(){
//do something
}
(new 斗地主).init();

这样一局新的游戏就开始了。

中介者模式

十一 中介者模式

中介者对象可以让各个对象之间不需要显示的相互引用,从而使其耦合松散,而且可以独立的改变它们之间的交互。

打个比方,军火买卖双方为了安全起见,找了一个信任的中介来进行交易。买家 A 把钱交给中介 B,然后从中介手中得到军火,卖家 C 把军火卖给中介,然后从中介手中拿回钱。一场交易完毕,A 甚至不知道 C 是一只猴子还是一只猛犸。因为中介的存在,A 也未必一定要买 C 的军火,也可能是 D,E,F。

银行在存款人和贷款人之间也能看成一个中介。存款人 A 并不关心他的钱最后被谁借走。贷款人 B 也不关心他借来的钱来自谁的存款。因为有中介的存在,这场交易才变得如此方便。

中介者模式和代理模式有一点点相似。都是第三者对象来连接 2 个对象的通信。具体差别可以从下图中区别。

代理模式:

引言与语言特性

中介者模式

引言与语言特性

代理模式中 A 必然是知道 B 的一切,而中介者模式中 A,B,C 对 E,F,G 的实现并不关心。而且中介者模式可以连接任意多种对象。

切回到程序世界里的 mvc,无论是 j2ee 中 struts 的 Action。还是 js 中 backbone.js 和 spine.js 里的 Controler。都起到了一个中介者的作用。 拿 backbone 举例。一个 mode 里的数据并不确定最后被哪些 view 使用。view 需要的数据也可以来自任意一个 mode。所有的绑定关系都是在 controler 里决定。中介者把复杂的多对多关系,变成了 2 个相对简单的 1 对多关系。

引言与语言特性

一段简单的示例代码:


var mode1 = Mode.create(), mode2 = Mode.create();
var view1 = View.create(), view2 = View.create();
var controler1 = Controler.create( mode1, view1, function(){
view1.el.find( ''div' ).bind( ''click', function(){
this.innerHTML = mode1.find( 'data' );
} )
})
var controler2 = Controler.create( mode2 view2, function(){
view1.el.find( ''div' ).bind( ''click', function(){
this.innerHTML = mode2.find( 'data' );
} )
})

迭代器模式

十二 迭代器模式

迭代器模式提供一种方法顺序访问一个聚合对象中各个元素,而又不需要暴露该方法中的内部表示。 js 中我们经常会封装一个 each 函数用来实现迭代器。

array 的迭代器:


forEach = function( ary, fn ){ for ( var i = 0, l = ary.length; i < l; i++ ){ var c = ary[ i ]; if ( fn.call( c, i , c ) === false ){ return false; } }}
forEach( [ 1, 2, 3 ], function( i, n ){
alert ( i );
})

obejct 的迭代器:


forEach = function( obj, fn ){ for ( var i in obj ){ var c = obj[ i ]; if ( fn.call( c, i, c ) === false ){ return false; } }}
forEach( {"a": 1,"b": 2}, function( i, n ){
alert ( i );
})

组合模式

十三 组合模式

组合模式又叫部分-整体模式,它将所有对象组合成树形结构。使得用户只需要操作最上层的接口,就可以对所有成员做相同的操作。

一个再好不过的例子就是 jquery 对象,大家都知道 1 个 jquery 对象其实是一组对象集合。比如在这样一个 HTML 页面


<div>
<span></span>
<span></span>
</div>

我们想取消所有节点上绑定的事件,需要这样写


var allNodes = document.getElementsByTagName("*");
var len = allNodes.length;
while( len-- ){
allNodes.unbind("*");
}

但既然用了 jquery,就肯定不会再做这么搓的事情。我们只需要$( 'body' ).unbind( '*' ); 当每个元素都实现 unbind 接口,那么只需调用最上层对象$( 'body' )的 unbind,便可自动迭代并调用所有组合元素的 unbind 方法。 再来个具体点的例子,还是 dev.qplus.com 这个网站的即时验证表单。

引言与语言特性

注意下面那个修改资料的按钮,如果有任意一个 field 的验证没有通过,修改资料的按钮都将是灰色不可点的状态。这意味着我们重新填写了表单内容后,都得去校验每个 field,保证它们全部 OK。 这代码不难实现。


if ( nameField.validata() && idCard.validata() && email.validata() && phone.validata() ){
alert ( "验证OK" );
}

似乎我们用一个外观模式也能勉强解决这里条件分支堆砌的问题,但真正的问题是,我们并不能保证表单里 field 的数量,也许明天产品经理就让你删掉一个或者增加两个。那么这样的维护方式显然不能被接受。 更好的实现是有一个 form.validata 函数,它负责把真正的 validata 操作分发给每个组合对象。 form.validata 函数里面会依次遍历所有需要校验的 field。若有一个 field 校验未通过,form.validata 都会返回 false。伪代码如下。


form.validata = function(){
forEach( fields, function( index, field ){
if ( field.validata() === false ){
return false;
}
})
return true;
}

备忘录模式

十四 备忘录模式

备忘录模式在 js 中经常用于数据缓存。比如一个分页控件,从服务器获得某一页的数据后可以存入缓存。以后再翻回这一页的时候,可以直接使用缓存里的数据而无需再次请求服务器。 实现比较简单,伪代码:


var Page = function(){
var page = 1,
cache = {},
data;
return function( page ){
if ( cache[ page ] ){
data = cache[ page ];
render( data );
}else{
Ajax.send( 'cgi.xx.com/xxx', function( data ){
cache[ page ] = data;
render( data );
})
}
}
}()

职责链模式

十五 职责链模式

职责链模式是一个对象 A 向另一个对象 B 发起请求,如果 B 不处理,可以把请求转给 C,如果 C 不处理,又可以把请求转给 D。一直到有一个对象愿意处理这个请求为止。

打个比方,客户让老板写个 php 程序。老板肯定不写,然后老板交给了部门经理。部门经理不愿意写,又交给项目经理。项目经理不会写,又交给程序员。最后由码农来完成。

在这个假设里,有几条职责链模式的特点。

1 老板只跟部门经理打交道,部门经理只联系项目经理,项目经理只找码农的麻烦。 2 如果码农也不写,这个项目将会流产。 3 客户并不清楚这个程序最后是由谁写出来的。 js 中的事件冒泡就是作为一个职责链来实现的。一个事件在某个节点上被触发,然后向根节点传递,直到被节点捕获。

享元模式

十六 享元模式

享元模式主要用来减少程序所需的对象个数。有一个例子,我们这边的前端同学几乎人手一本《javascript 权威指南》。从省钱的角度讲,大约三本就够了。放在部门的书柜里,谁需要看的时候就去拿,看完了还回去。如果同时有 4 个同学需要看,此时再去多买一本。

在 webqq 里面,打开 QQ 好友列表往下拉的时候,会为每个好友创建一个 div(如果算上 div 中的子节点,还远不只 1 个元素)。

引言与语言特性

如果有 1000 个 QQ 好友,意味着如果从头拉到尾,会创建 1000 个 div,这时候有些浏览器也许已经假死了。这还只是一个随便翻翻好友列表的操作。

所以我们想到了一种解决办法,当滚动条滚动的时候,把已经消失在视线外的 div 都删除掉。这样页面可以保持只有一定数量的节点。问题是这样频繁的添加与删除节点,也会造成很大的性能开销,而且这种感觉很不对味。

现在享元模式可以登场了。顾名思义,享元模式可以提供一些共享的对象以便重复利用。仔细看下上图,其实我们一共只需要 10 个 div 来显示好友信息,也就是出现在用户视线中的 10 个 div。这 10 个 div 就可以写成享元。 伪代码如下。


var getDiv = (function(){
var created = [];
var create = function(){
return document.body.appendChild( document.createElement( 'div' ) );
}
var get = function(){
if ( created.length ){
return created.shift();
}else{
return create();
}
}
/* 一个假设的事件,用来监听刚消失在视线外的div,实际上可以通过监听滚 动条位置来实现 */
userInfoContainer.disappear(function( div ){
created.push( div );
})
})()
var div = getDiv();
div.innerHTML = "${userinfo}";

原理其实很简单,把刚隐藏起来的 div 放到一个数组中,当需要 div 的时候,先从该数组中取,如果数组中已经没有了,再重新创建一个。这个数组里的 div 就是享元,它们每一个都可以当作任何用户信息的载体。

当然这只是个示例,实际的情况要复杂一些,比如快速拖动的时候,我们可能还得为节点设置一个缓冲区。

状态模式

十七 状态模式

状态模式主要可以用于这种场景 1 一个对象的行为取决于它的状态 2 一个操作中含有庞大的条件分支语句

回想下街头霸王的游戏。

引言与语言特性

隆有走动,攻击,防御,跌倒,跳跃等等多种状态,而这些状态之间既有联系又互相约束。比如跳跃的时候是不能攻击和防御的。跌倒的时候既不能攻击又不能防御,而走动的时候既可以攻击也可以跳跃。

要完成这样一系列逻辑,常理下 if else 是少不了的。而且数量无法估计,特别是增加一种新状态的时候,可能要从代码的第 10 行一直改到 900 行。


if ( state === 'jump' ){
if ( currState === 'attack' || currState === 'defense' ){
return false;
}
}else if ( state === 'wait' ){
if ( currState === 'attack' || currState === 'defense' ){
return true;
}
}

为了消灭这些 if else,并且方便修改和维护,我们引入一个状态类。


var StateManager = function(){
var currState = 'wait';
var states = {
jump: function( state ){
},
wait: function( state ){
},
attack: function( state ){
},
crouch: function( state ){
},
defense: function( state ){
if ( currState === 'jump' ){
return false; 
//不成功,跳跃的时候不能防御
}
//do something; //防御的真正逻辑代码, 为了防止状态类的代码过多, 应该把这些逻辑继续扔给真正的fight类来执行.
currState = 'defense'; 
// 切换状态
}
}
var changeState = function( state ){
states[ state ] && states[ state ]();
}
return {
changeState : changeState
}
}
var stateManager = StateManager();
stateManager.changeState( 'defense' );

通过这个状态类,可以把散落在世界各地的条件分支集中管理到一个类里,并且可以很容易的添加一种新的状态。而作为调用者,只需要通过暴露的 changeState 接口来切换人物的状态。

总结

/***************************分界线1******************************************/

GOF 提出的 23 种设计模式,至此已经写完大半。还有一些要么是 js 里不太适用,要么是 js 中已有原生自带的实现,所以就没再去深究。这 2 篇文章里的大部分例子都来自或改写自工作和学习中的代码。我对设计模式的看法是不用刻意去学习设计模式,平时我们接触的很多代码里已经包含了一些设计模式的实现。我的过程是读过 prototype 和 jquery 的源码后,回头翻设计模式的书,发现不知觉中已经接触过十之六七。

同样在实际的编码中也没有必要刻意去使用一些设计模式。就如同 tokyo hot 32 式一样,在一场友好的 papapa 过程中,没有必要去刻意使用某种姿势。一切还是看需求和感觉。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章