观察者模式
观察者模式被广泛地应用于JavaScript客户端编程中。所有的浏览器事件(mouseover
,keypress
等)都是使用观察者模式的例子。这种模式的另一个名字叫“自定义事件”,意思是这些事件是被编写出来的,和浏览器触发的事件相对。它还有另外一个名字叫“订阅者/发布者”模式(Pub/Sub)。
使用这个模式的最主要目的就是促进代码解耦。在观察者模式中,一个对象订阅另一个对象的指定活动并得到通知,而不是调用另一个对象的方法。订阅者也被叫作观察者,被观察的对象叫作发布者或者被观察者。当一个特定的事件发生的时候,发布者会通知(调用)所有的订阅者,同时还可能以事件对象的形式传递一些消息。
例1:杂志订阅
为了理解观察者模式的实现方式,我们来看一个具体的例子。我们假设有一个发布者paper
,它发行一份日报和一份月刊。无论是日报还是月刊发行,有一个名叫joe
的订阅者都会收到通知。
paper
对象有一个subscribers
属性,它是一个数组,用来保存所有的订阅者。订阅的过程就仅仅是将订阅者放到这个数组中而已。当一个事件发生时,paper
遍历这个订阅者列表,然后通知它们。通知的意思也就是调用订阅者对象的一个方法。因此,在订阅过程中,订阅者需要提供一个方法给paper
对象的subscribe()
。
paper
对象也可以提供unsubscribe()
方法,它可以将订阅者从数组中移除。paper
对象的最后一个重要的方法是publish()
,它负责调用订阅者的方法。总结一下,一个发布者对象需要有这些成员:
subscribers
一个数组
subscribe()
将订阅者加入数组
unsubscribe()
从数组中移除订阅者
publish()
遍历订阅者并调用它们订阅时提供的方法
所有三个方法都需要一个type
参数,因为一个发布者可能触发好几种事件(比如同时发布杂志和报纸),而订阅者可以选择性地订阅其中的一种或几种。
因为这些成员对任何对象来说都是通用的,因此将它们作为一个单独的对象提取出来是有意义的。然后,我们可以(通过混元模式)将它们复制到任何一个对象中,将这些对象转换为订阅者。
下面是这些发布者通用功能的一个示例实现,它定义了上面列出来的所有成员,还有一个辅助的visitSubscribers()
方法:
var publisher = {
subscribers: {
any: [] // 对应事件类型的订阅者
},
subscribe: function (fn, type) {
type = type || 'any';
if (typeof this.subscribers[type] === "undefined") {
this.subscribers[type] = [];
}
this.subscribers[type].push(fn);
},
unsubscribe: function (fn, type) {
this.visitSubscribers('unsubscribe', fn, type);
},
publish: function (publication, type) {
this.visitSubscribers('publish', publication, type);
},
visitSubscribers: function (action, arg, type) {
var pubtype = type || 'any',
subscribers = this.subscribers[pubtype],
i,
max = subscribers.length;
for (i = 0; i < max; i += 1) {
if (action === 'publish') {
subscribers[i](arg);
} else {
if (subscribers[i] === arg) {
subscribers.splice(i, 1);
}
}
}
}
};
下面这个函数接受一个对象作为参数,并通过复制通用发布者的方法将这个对象转变成发布者:
function makePublisher(o) {
var i;
for (i in publisher) {
if (publisher.hasOwnProperty(i) && typeof publisher[i] === "function") {
o[i] = publisher[i];
}
}
o.subscribers = {any: []};
}
现在我们来实现paper
对象,它能做的事情就是发布日报和月刊:
var paper = {
daily: function () {
this.publish("big news today");
},
monthly: function () {
this.publish("interesting analysis", "monthly");
}
};
将paper
对象变成发布者:
makePublisher(paper);
现在我们有了一个发布者,让我们再来看一下订阅者对象joe
,它有两个方法:
var joe = {
drinkCoffee: function (paper) {
console.log('Just read ' + paper);
},
sundayPreNap: function (monthly) {
console.log('About to fall asleep reading this ' + monthly);
}
};
现在让joe
来订阅paper
:
paper.subscribe(joe.drinkCoffee);
paper.subscribe(joe.sundayPreNap, 'monthly');
如你所见,joe
提供了一个当默认的any
事件发生时被调用的方法,还提供了另一个当monthly
事件发生时被调用的方法。现在让我们来触发一些事件:
paper.daily();
paper.daily();
paper.daily();
paper.monthly();
这些发布行为都会调用joe的对应方法,控制台中输出的结果是:
Just read big news today
Just read big news today
Just read big news today
About to fall asleep reading this interesting analysis
这里值得称道的地方就是paper
对象并没有硬编码写上joe
,而joe
也同样没有硬编码写上paper
。这里也没有知道所有事情的中介者对象。所有涉及到的对象都是松耦合的,而且在不修改代码的前提下,我们可以给paper
添加更多的订阅者,同时joe
也可以在任何时候取消订阅。
让我们更进一步,将joe
也变成一个发布者。(毕竟,在博客和微博上,任何人都可以是发布者。)这样,joe
变成发布者之后就可以在Twitter上更新状态:
makePublisher(joe);
joe.tweet = function (msg) {
this.publish(msg);
};
现在假设paper
的公关部门准备通过Twitter
收集读者反馈,于是它订阅了joe
,提供了一个方法readTweets()
:
paper.readTweets = function (tweet) {
alert('Call big meeting! Someone ' + tweet);
};
joe.subscribe(paper.readTweets);
这样每当joe
发出消息时,paper
就会弹出警告窗口:
joe.tweet("hated the paper today");
结果是一个警告窗口:“Call big meeting! Someone hated the paper today”。
你可以在http://jspatterns.com/book/7/observer.html看到完整的源代码,并且在控制台中运行这个实例。
例2:按键游戏
我们来看另一个例子。我们将实现一个和中介者模式的示例一样的按钮游戏,但这次使用观察者模式。为了让它看起来更高档,我们允许接受无限个玩家,而不限于2个。我们仍然保留用来产生玩家的Player()
构造函数,也保留scoreboard
对象,只有mediator
会变成game
对象。
在中介者模式中,mediator
对象知道所有涉及到的对象,并且调用它们的方法。而观察者模式中的game
对象不是这样,它会让对象来订阅它们感兴趣的事件。比如,scoreboard
会订阅game
对象的scorechange
事件。
首先我们重新看一下通用的publisher
对象,并且将它的接口做一点小修改以更贴近浏览器的情况:
- 将
publish()
,subscribe()
,unsubscribe()
分别改为fire()
,on()
,remove()
- 事件的
type
每次都会被用到,所以把它变成三个方法的第一个参数 - 可以给订阅者的方法额外加一个
context
参数,以便回调方法可以用this
指向它自己所属的对象
新的publisher
对象是这样:
var publisher = {
subscribers: {
any: []
},
on: function (type, fn, context) {
type = type || 'any';
fn = typeof fn === "function" ? fn : context[fn];
if (typeof this.subscribers[type] === "undefined") {
this.subscribers[type] = [];
}
this.subscribers[type].push({fn: fn, context: context || this});
},
remove: function (type, fn, context) {
this.visitSubscribers('unsubscribe', type, fn, context);
},
fire: function (type, publication) {
this.visitSubscribers('publish', type, publication);
},
visitSubscribers: function (action, type, arg, context) {
var pubtype = type || 'any',
subscribers = this.subscribers[pubtype],
i,
max = subscribers ? subscribers.length : 0;
for (i = 0; i < max; i += 1) {
if (action === 'publish') {
subscribers[i].fn.call(subscribers[i].context, arg);
} else {
if (subscribers[i].fn === arg && subscribers[i].context === context) {
subscribers.splice(i, 1);
}
}
}
}
};
新的Player()
构造函数是这样:
function Player(name, key) {
this.points = 0;
this.name = name;
this.key = key;
this.fire('newplayer', this);
}
Player.prototype.play = function () {
this.points += 1;
this.fire('play', this);
};
变动的部分是这个构造函数接受key
,代表这个玩家在键盘上用来按之后得分的按键。(这些键预先被硬编码过。)每次创建一个新玩家的时候,一个newplayer
事件也会被触发。类似的,每次有一个玩家玩的时候,会触发play
事件。
scoreboard
对象和原来一样,它只是简单地将当前分数显示出来。
game
对象会关注所有的玩家,这样它就可以给出分数并且触发scorechange
事件。它也会订阅浏览器中所有的·keypress·事件,这样它就会知道按钮对应的玩家:
var game = {
keys: {},
addPlayer: function (player) {
var key = player.key.toString().charCodeAt(0);
this.keys[key] = player;
},
handleKeypress: function (e) {
e = e || window.event; // IE
if (game.keys[e.which]) {
game.keys[e.which].play();
}
},
handlePlay: function (player) {
var i,
players = this.keys,
score = {};
for (i in players) {
if (players.hasOwnProperty(i)) {
score[players[i].name] = players[i].points;
}
}
this.fire('scorechange', score);
}
};
用于将任意对象转变为订阅者的makePublisher()
还是和之前一样。game
对象会变成发布者(这样它才可以触发scorechange
事件),Player.prototype
也会变成发布者,以使得每个玩家对象可以触发play
和newplayer
事件:
makePublisher(Player.prototype);
makePublisher(game);
game
对象订阅play
和newplayer
事件(以及浏览器的keypress
事件),scoreboard
订阅scorechange
事件:
Player.prototype.on("newplayer", "addPlayer", game);
Player.prototype.on("play", "handlePlay", game);
game.on("scorechange", scoreboard.update, scoreboard);
window.onkeypress = game.handleKeypress;
如你所见,on()
方法允许订阅者通过函数(scoreboard.update
)或者是字符串("addPlayer"
)来指定回调函数。当有提供context
(如game
)时,才能通过字符串来指定回调函数。
初始化的最后一点工作就是动态地创建玩家对象(以及它们对象的按键),用户想要多少个就可以创建多少个:
var playername, key;
while (1) {
playername = prompt("Add player (name)");
if (!playername) {
break;
}
while (1) {
key = prompt("Key for " + playername + "?");
if (key) {
break;
}
}
new Player(playername, key);
}
这就是游戏的全部。你可以在http://www.jspatterns.com/book/7/observer-game.html看到完整的源代码并且试玩一下。
值得注意的是,在中介者模式中,mediator
对象必须知道所有的对象,然后在适当的时机去调用对应的方法。而这个例子中,game
对象会显得笨一些(译注:指知道的信息少一些),游戏依赖于对象去观察特定的事件然后触发相应的动作:如scoreboard
观察scorechange
事件。这使得对象之间的耦合更松了(对象间知道彼此的信息越少越好),而代价则是弄清事件和订阅者之间的对应关系会更困难一些。在这个例子中,所有的订阅行为都发生在代码中的同一个地方,而随着应用规模的境长,on()
可能会被在各个地方调用(如在每个对象的初始化代码中)。这使得调试更困难一些,因为没有一个集中的地方来看这些代码并理解正在发生什么事情。在观察者模式中,你将不再能看到那种从开头一直跟到结尾的顺序执行方式。