《Practical Common Lisp》的作者 Peter Seibel 曾说,如果你需要一种模式,那一定是哪里出了问题。他所说的问题是指因为语言的天生缺陷,不得不去寻求和总结一种通用的解决方案。
不管是弱类型或强类型,静态或动态语言,命令式或说明式语言、每种语言都有天生的优缺点。一个牙买加运动员, 在短跑甚至拳击方面有一些优势,在练瑜伽上就欠缺一些。
术士和暗影牧师很容易成为一个出色的辅助,而一个背着梅肯满地图飞的敌法就会略显尴尬。 换到程序中, 静态语言里可能需要花很多功夫来实现装饰者,而js由于能随时往对象上面扔方法,以至于装饰者模式在js里成了鸡肋。
讲 Javascript 设计模式的书还比较少,《Pro javaScript Design Patterns》是比较经典的一本,但是它里面的例子举得比较啰嗦,所以结合我在工作中写过的代码,把我的理解总结一下。如果我的理解出现了偏差,请不吝指正。
一 单例模式
单例模式的定义是产生一个类的唯一实例,但js本身是一种“无类”语言。很多讲js设计模式的文章把{}当成一个单例来使用也勉强说得通。因为js生成对象的方式有很多种,我们来看下另一种更有意义的单例。
有这样一个常见的需求,点击某个按钮的时候需要在页面弹出一个遮罩层。比如web.qq.com点击登录的时候.
这个生成灰色背景遮罩层的代码是很好写的.
JavaScript
1
2
3
4
5
|
var
createMask
=
function
(
)
{
return
document
,
body
.
appendChild
(
document
.
createElement
(
div
)
)
;
}
|
JavaScript
1
2
3
4
5
6
7
|
$
(
'button'
)
.
click
(
function
(
)
{
Var
mask
=
createMask
(
)
;
mask
.
show
(
)
;
}
)
|
问题是, 这个遮罩层是全局唯一的, 那么每次调用createMask都会创建一个新的div, 虽然可以在隐藏遮罩层的把它remove掉. 但显然这样做不合理.
再看下第二种方案, 在页面的一开始就创建好这个div. 然后用一个变量引用它.
JavaScript
1
2
3
4
5
6
7
|
var
mask
=
document
.
body
.
appendChild
(
document
.
createElement
(
''
div
' ) );
$( '
'button'
)
.
click
(
function
(
)
{
mask
.
show
(
)
;
}
)
|
这样确实在页面只会创建一个遮罩层div, 但是另外一个问题随之而来, 也许我们永远都不需要这个遮罩层, 那又浪费掉一个div, 对dom节点的任何操作都应该非常吝啬.
如果可以借助一个变量. 来判断是否已经创建过div呢?
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
var
mask
;
var
createMask
=
function
(
)
{
if
(
mask
)
return
mask
;
else
{
mask
=
document
,
body
.
appendChild
(
document
.
createElement
(
div
)
)
;
return
mask
;
}
}
|
看起来不错, 到这里的确完成了一个产生单列对象的函数. 我们再仔细看这段代码有什么不妥.
首先这个函数是存在一定副作用的, 函数体内改变了外界变量mask的引用, 在多人协作的项目中, createMask是个不安全的函数. 另一方面, mask这个全局变量并不是非需不可. 再来改进一下.
JavaScript
1
2
3
4
5
6
|
var
createMask
=
function
(
)
{
var
mask
;
return
function
(
)
{
return
mask
||
(
mask
=
document
.
body
.
appendChild
(
document
.
createElement
(
'div'
)
)
)
}
}
(
)
|
用了个简单的闭包把变量mask包起来, 至少对于createMask函数来讲, 它是封闭的.
可能看到这里, 会觉得单例模式也太简单了. 的确一些设计模式都是非常简单的, 即使从没关注过设计模式的概念, 在平时的代码中也不知不觉用到了一些设计模式. 就像多年前我明白老汉推车是什么回事的时候也想过尼玛原来这就是老汉推车.
GOF里的23种设计模式, 也是在软件开发中早就存在并反复使用的模式. 如果程序员没有明确意识到他使用过某些模式, 那么下次他也许会错过更合适的设计 (这段话来自《松本行弘的程序世界》).
再回来正题, 前面那个单例还是有缺点. 它只能用于创建遮罩层. 假如我又需要写一个函数, 用来创建一个唯一的xhr对象呢? 能不能找到一个通用的singleton包装器.
js中函数是第一型, 意味着函数也可以当参数传递. 看看最终的代码.
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
|
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的引用. 遗憾的是js的函数式特性还不足以完全的消除声明和语句.
二 简单工厂模式
简单工厂模式是由一个方法来决定到底要创建哪个类的实例, 而这些实例经常都拥有相同的接口. 这种模式主要用在所实例化的类型在编译期并不能确定, 而是在执行期决定的情况。 说的通俗点,就像公司茶水间的饮料机,要咖啡还是牛奶取决于你按哪个按钮。
简单工厂模式在创建ajax对象的时候也非常有用.
之前我写了一个处理ajax异步嵌套的库,地址在https://github.com/AlloyTeam/DanceRequest.
这个库里提供了几种ajax请求的方式,包括xhr对象的get, post, 也包括跨域用的jsonp和iframe. 为了方便使用, 这几种方式都抽象到了同一个接口里面.
JavaScript
1
2
3
4
5
6
7
8
9
10
11
|
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的实例. 是由后来的代码决定的。
实际上在js里面,所谓的构造函数也是一个简单工厂。只是批了一件new的衣服. 我们扒掉这件衣服看看里面。
通过这段代码, 在firefox, chrome等浏览器里,可以完美模拟new.
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
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之间实现的一种观察者模式.
JavaScript
1
2
3
4
5
|
div
.
onclick
=
function
click
(
)
{
alert
(
''
click'
)
}
|
只要订阅了div的click事件. 当点击div的时候, function click就会被触发.
那么到底什么是观察者模式呢. 先看看生活中的观察者模式。
好莱坞有句名言. “不要给我打电话, 我会给你打电话”. 这句话就解释了一个观察者模式的来龙去脉。 其中“我”是发布者, “你”是订阅者。
再举个例子,我来公司面试的时候,完事之后每个面试官都会对我说:“请留下你的联系方式, 有消息我们会通知你”。 在这里“我”是订阅者, 面试官是发布者。所以我不用每天或者每小时都去询问面试结果, 通讯的主动权掌握在了面试官手上。而我只需要提供一个联系方式。
观察者模式可以很好的实现2个模块之间的解耦。 假如我正在一个团队里开发一个html5游戏. 当游戏开始的时候,需要加载一些图片素材。加载好这些图片之后开始才执行游戏逻辑. 假设这是一个需要多人合作的项目. 我完成了Gamer和Map模块, 而我的同事A写了一个图片加载器loadImage.
loadImage的代码如下
JavaScript
1
2
3
4
5
6
7
|
loadImage
(
imgAry
,
function
(
)
{
Map
.
init
(
)
;
Gamer
.
init
(
)
;
}
)
|
当图片加载好之后, 再渲染地图, 执行游戏逻辑. 嗯, 这个程序运行良好. 突然有一天, 我想起应该给游戏加上声音功能. 我应该让图片加载器添上一行代码.
JavaScript
1
2
3
4
5
6
7
8
9
|
loadImage
(
imgAry
,
function
(
)
{
Map
.
init
(
)
;
Gamer
.
init
(
)
;
Sount
.
init
(
)
;
}
)
|
可是写这个模块的同事A去了外地旅游. 于是我打电话给他, 喂. 你的loadImage函数在哪, 我能不能改一下, 改了之后有没有副作用. 如你所想, 各种不淡定的事发生了. 如果当初我们能这样写呢:
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
loadImage
.
listen
(
''
ready
', function(){
Map.init();
})
loadImage.listen( '
'ready'
,
function
(
)
{
Gamer
.
init
(
)
;
}
)
loadImage
.
listen
(
''
ready'
,
function
(
)
{
Sount
.
init
(
)
;
}
)
|
loadImage完成之后, 它根本不关心将来会发生什么, 因为它的工作已经完成了. 接下来它只要发布一个信号.
loadImage.trigger( ”ready’ );
那么监听了loadImage的’ready’事件的对象都会收到通知. 就像上个面试的例子. 面试官根本不关心面试者们收到面试结果后会去哪吃饭. 他只负责把面试者的简历搜集到一起. 当面试结果出来时照着简历上的电话挨个通知.
说了这么多概念, 来一个具体的实现. 实现过程其实很简单. 面试者把简历扔到一个盒子里, 然后面试官在合适的时机拿着盒子里的简历挨个打电话通知结果.
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
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
}
}
|
最后用观察者模式来做一个成人电视台的小应用.
//订阅者
JavaScript
1
2
3
4
5
6
7
8
9
10
11
|
var
adultTv
=
Event
(
)
;
adultTv
.
listen
(
''
play
', function( data ){
alert ( "今天是谁的电影" + data.name );
});
//发布者
adultTv .trigger( '
'play'
,
{
'name'
:
'麻生希'
}
)
|
四 适配器模式
去年年前当时正在开发dev.qplus.com, 有个存储应用分类id的js文件, 分类id的结构最开始设计的比较笨重. 于是我决定重构它. 我把它定义成一个json树的形式, 大概是这样:
JavaScript
1
2
3
4
5
6
7
8
9
10
11
|
var
category
=
{
music
:
{
id
:
1
,
children
:
[
,
,
,
,
]
}
}
|
dev.qplus.com里大概有4,5个页面都调用这个category对象. 春节前我休了1个星期假. 过年来之后发现邮箱里有封邮件, 设计数据库的同学把category..js也重构了一份, 并且其他几个项目里都是用了这份category.js, 我拿过来一看就傻眼了, 和我之前定的数据结构完全不一样.
当然这是一个沟通上的反面例子. 但接下来的重点是我已经在N个文件里用到了之前我定的category.js. 而且惹上了一些复杂的相关逻辑. 怎么改掉我之前的代码呢. 全部重写肯定是不愿意. 所以现在适配器就派上用场了.
只需要把同事的category用一个函数转成跟我之前定义的一样.
JavaScript
1
|
my
.
category
=
adapterCategory
(
afu
.
category
)
;
|
适配器模式的作用很像一个转接口. 本来iphone的充电器是不能直接插在电脑机箱上的, 而通过一个usb转接口就可以了.
所以, 在程序里适配器模式也经常用来适配2个接口, 比如你现在正在用一个自定义的js库. 里面有个根据id获取节点的方法$id(). 有天你觉得jquery里的$实现得更酷, 但你又不想让你的工程师去学习新的库和语法. 那一个适配器就能让你完成这件事情.
JavaScript
1
2
3
4
5
|
$
id
=
function
(
id
)
{
return
jQuery
(
'#'
+
id
)
[
0
]
;
}
|
五 代理模式
代理模式的定义是把对一个对象的访问, 交给另一个代理对象来操作.
举一个例子, 我在追一个MM想给她送一束花,但是我因为我性格比较腼腆,所以我托付了MM的一个好朋友来送。
这个例子不是非常好, 至少我们没看出代理模式有什么大的用处,因为追MM更好的方式是送一台宝马。
再举个例子,假如我每天都得写工作日报( 其实没有这么惨 ). 我的日报最后会让总监审阅. 如果我们都直接把日报发给 总监 , 那可能 总监 就没法工作了. 所以通常的做法是把日报发给我的组长 , 组长把所有组员一周的日报都汇总后再发给总监 .
实际的编程中, 这种因为性能问题使用代理模式的机会是非常多的。比如频繁的访问dom节点, 频繁的请求远程资源. 可以把操作先存到一个缓冲区, 然后自己选择真正的触发时机.
再来个详细的例子,之前我写了一个街头霸王的游戏, 地址在http://alloyteam.github.com/StreetFighter/
游戏中隆需要接受键盘的事件, 来完成相应动作.
于是我写了一个keyManage类. 其中在游戏主线程里监听keyManage的变化.
JavaScript
1
2
3
4
5
6
7
|
var
keyMgr
=
keyManage
(
)
;
keyMgr
.
listen
(
''
change'
,
function
(
keyCode
)
{
console
.
log
(
keyCode
)
;
}
)
;
|
图片里面隆正在放升龙拳, 升龙拳的操作是前下前+拳. 但是这个keyManage类只要发生键盘事件就会触发之前监听的change函数. 这意味着永远只能取得前,后,前,拳这样单独的按键事件,而无法得到一个按键组合。
好吧,我决定改写我的keyManage类, 让它也支持传递按键组合. 但是如果我以后写个html5版双截龙,意味着我每次都得改写keyManage. 我总是觉得, 这种函数应该可以抽象成一个更底层的方法, 让任何游戏都可以用上它.
所以最后的keyManage只负责映射键盘事件. 而隆接受到的动作是通过一个代理对象处理之后的.
JavaScript
1
2
3
4
5
6
7
|
var
keyMgr
=
keyManage
(
)
;
keyMgr
.
listen
(
''
change'
,
proxy
(
function
(
keyCode
)
{
console
.
log
(
keyCode
)
;
//前下前+拳
)
}
)
;
|
至于proxy里面怎么实现,完全可以自由发挥。
还有个例子就是在调用ajax请求的时候,无论是各种开源库,还是自己写的Ajax类, 都会给xhr对象设置一个代理. 我们不可能频繁的去操作xhr对象发请求, 而应该是这样.
JavaScript
1
2
3
4
5
6
7
|
var
request
=
Ajax
.
get
(
'cgi.xx.com/xxx'
)
;
request
.
send
(
)
;
request
.
done
(
function
(
)
{
}
)
;
|
六 桥接模式
桥接模式的作用在于将实现部分和抽象部分分离开来, 以便两者可以独立的变化。在实现api的时候, 桥接模式特别有用。比如最开始的singleton的例子.
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
|
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就一点也不费力.
JavaScript
1
2
3
4
5
|
var
createScript
=
singleton
(
function
(
)
{
return
document
.
body
.
appendChild
(
document
.
createElement
(
'script'
)
)
;
}
)
|
另外一个常见的例子就是forEach函数的实现, 用来迭代一个数组.
JavaScript
1
2
3
4
5
6
7
8
|
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函数的改写影响.
JavaScript
1
2
3
4
5
6
7
8
9
10
11
|
forEach
(
[
1
,
2
,
3
]
,
function
(
i
,
n
)
{
alert
(
n
*
2
)
}
)
forEach
(
[
1
,
2
,
3
]
,
function
(
i
,
n
)
{
alert
(
n
*
3
)
}
)
|
七 外观模式
外观模式(门面模式),是一种相对简单而又无处不在的模式。外观模式提供一个高层接口,这个接口使得客户端或子系统更加方便调用。
用一段再简单不过的代码来表示
JavaScript
1
2
3
4
5
6
|
var
getName
=
function
(
)
{
return
''
svenzeng"
}
var
getSex
=
function
(
)
{
return
'man'
}
|
如果你需要分别调用getName和getSex函数. 那可以用一个更高层的接口getUserInfo来调用.
JavaScript
1
2
3
4
|
var
getUserInfo
=
function
(
)
{
var
info
=
a
(
)
+
b
(
)
;
return
info
;
}
|
也许你会问为什么一开始不把getName和getSex的代码写到一起, 比如这样
JavaScript
1
2
3
|
var
getNameAndSex
=
function
(
)
{
return
'
svenzeng
" + "
man"
;
}
|
答案是显而易见的,饭堂的炒菜师傅不会因为你预定了一份烧鸭和一份白菜就把这两样菜炒在一个锅里。他更愿意给你提供一个烧鸭饭套餐。同样在程序设计中,我们需要保证函数或者对象尽可能的处在一个合理粒度,毕竟不是每个人喜欢吃烧鸭的同时又刚好喜欢吃白菜。
外观模式还有一个好处是可以对用户隐藏真正的实现细节,用户只关心最高层的接口。比如在烧鸭饭套餐的故事中,你并不关心师傅是先做烧鸭还是先炒白菜,你也不关心那只鸭子是在哪里成长的。
最后写个我们都用过的外观模式例子
JavaScript
1
2
3
4
|
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的代码:
JavaScript