观察者模式

观察者模式被广泛地应用于JavaScript客户端编程中。所有的浏览器事件(mouseoverkeypress等)都是使用观察者模式的例子。这种模式的另一个名字叫“自定义事件”,意思是这些事件是被编写出来的,和浏览器触发的事件相对。它还有另外一个名字叫“订阅者/发布者”模式(Pub/Sub)。

使用这个模式的最主要目的就是促进代码解耦。在观察者模式中,一个对象订阅另一个对象的指定活动并得到通知,而不是调用另一个对象的方法。订阅者也被叫作观察者,被观察的对象叫作发布者或者被观察者。当一个特定的事件发生的时候,发布者会通知(调用)所有的订阅者,同时还可能以事件对象的形式传递一些消息。

例1:杂志订阅

为了理解观察者模式的实现方式,我们来看一个具体的例子。我们假设有一个发布者paper,它发行一份日报和一份月刊。无论是日报还是月刊发行,有一个名叫joe的订阅者都会收到通知。

paper对象有一个subscribers属性,它是一个数组,用来保存所有的订阅者。订阅的过程就仅仅是将订阅者放到这个数组中而已。当一个事件发生时,paper遍历这个订阅者列表,然后通知它们。通知的意思也就是调用订阅者对象的一个方法。因此,在订阅过程中,订阅者需要提供一个方法给paper对象的subscribe()

paper对象也可以提供unsubscribe()方法,它可以将订阅者从数组中移除。paper对象的最后一个重要的方法是publish(),它负责调用订阅者的方法。总结一下,一个发布者对象需要有这些成员:

  • subscribers

    一个数组

  • subscribe()

    将订阅者加入数组

  • unsubscribe()

    从数组中移除订阅者

  • publish()

    遍历订阅者并调用它们订阅时提供的方法

所有三个方法都需要一个type参数,因为一个发布者可能触发好几种事件(比如同时发布杂志和报纸),而订阅者可以选择性地订阅其中的一种或几种。

因为这些成员对任何对象来说都是通用的,因此将它们作为一个单独的对象提取出来是有意义的。然后,我们可以(通过混元模式)将它们复制到任何一个对象中,将这些对象转换为订阅者。

下面是这些发布者通用功能的一个示例实现,它定义了上面列出来的所有成员,还有一个辅助的visitSubscribers()方法:

  1. var publisher = {
  2. subscribers: {
  3. any: [] // 对应事件类型的订阅者
  4. },
  5. subscribe: function (fn, type) {
  6. type = type || 'any';
  7. if (typeof this.subscribers[type] === "undefined") {
  8. this.subscribers[type] = [];
  9. }
  10. this.subscribers[type].push(fn);
  11. },
  12. unsubscribe: function (fn, type) {
  13. this.visitSubscribers('unsubscribe', fn, type);
  14. },
  15. publish: function (publication, type) {
  16. this.visitSubscribers('publish', publication, type);
  17. },
  18. visitSubscribers: function (action, arg, type) {
  19. var pubtype = type || 'any',
  20. subscribers = this.subscribers[pubtype],
  21. i,
  22. max = subscribers.length;
  23. for (i = 0; i < max; i += 1) {
  24. if (action === 'publish') {
  25. subscribers[i](arg);
  26. } else {
  27. if (subscribers[i] === arg) {
  28. subscribers.splice(i, 1);
  29. }
  30. }
  31. }
  32. }
  33. };

下面这个函数接受一个对象作为参数,并通过复制通用发布者的方法将这个对象转变成发布者:

  1. function makePublisher(o) {
  2. var i;
  3. for (i in publisher) {
  4. if (publisher.hasOwnProperty(i) && typeof publisher[i] === "function") {
  5. o[i] = publisher[i];
  6. }
  7. }
  8. o.subscribers = {any: []};
  9. }

现在我们来实现paper对象,它能做的事情就是发布日报和月刊:

  1. var paper = {
  2. daily: function () {
  3. this.publish("big news today");
  4. },
  5. monthly: function () {
  6. this.publish("interesting analysis", "monthly");
  7. }
  8. };

paper对象变成发布者:

  1. makePublisher(paper);

现在我们有了一个发布者,让我们再来看一下订阅者对象joe,它有两个方法:

  1. var joe = {
  2. drinkCoffee: function (paper) {
  3. console.log('Just read ' + paper);
  4. },
  5. sundayPreNap: function (monthly) {
  6. console.log('About to fall asleep reading this ' + monthly);
  7. }
  8. };

现在让joe来订阅paper

  1. paper.subscribe(joe.drinkCoffee);
  2. paper.subscribe(joe.sundayPreNap, 'monthly');

如你所见,joe提供了一个当默认的any事件发生时被调用的方法,还提供了另一个当monthly事件发生时被调用的方法。现在让我们来触发一些事件:

  1. paper.daily();
  2. paper.daily();
  3. paper.daily();
  4. paper.monthly();

这些发布行为都会调用joe的对应方法,控制台中输出的结果是:

  1. Just read big news today
  2. Just read big news today
  3. Just read big news today
  4. About to fall asleep reading this interesting analysis

这里值得称道的地方就是paper对象并没有硬编码写上joe,而joe也同样没有硬编码写上paper。这里也没有知道所有事情的中介者对象。所有涉及到的对象都是松耦合的,而且在不修改代码的前提下,我们可以给paper添加更多的订阅者,同时joe也可以在任何时候取消订阅。

让我们更进一步,将joe也变成一个发布者。(毕竟,在博客和微博上,任何人都可以是发布者。)这样,joe变成发布者之后就可以在Twitter上更新状态:

  1. makePublisher(joe);
  2. joe.tweet = function (msg) {
  3. this.publish(msg);
  4. };

现在假设paper的公关部门准备通过Twitter收集读者反馈,于是它订阅了joe,提供了一个方法readTweets()

  1. paper.readTweets = function (tweet) {
  2. alert('Call big meeting! Someone ' + tweet);
  3. };
  4. joe.subscribe(paper.readTweets);

这样每当joe发出消息时,paper就会弹出警告窗口:

  1. 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对象是这样:

  1. var publisher = {
  2. subscribers: {
  3. any: []
  4. },
  5. on: function (type, fn, context) {
  6. type = type || 'any';
  7. fn = typeof fn === "function" ? fn : context[fn];
  8. if (typeof this.subscribers[type] === "undefined") {
  9. this.subscribers[type] = [];
  10. }
  11. this.subscribers[type].push({fn: fn, context: context || this});
  12. },
  13. remove: function (type, fn, context) {
  14. this.visitSubscribers('unsubscribe', type, fn, context);
  15. },
  16. fire: function (type, publication) {
  17. this.visitSubscribers('publish', type, publication);
  18. },
  19. visitSubscribers: function (action, type, arg, context) {
  20. var pubtype = type || 'any',
  21. subscribers = this.subscribers[pubtype],
  22. i,
  23. max = subscribers ? subscribers.length : 0;
  24. for (i = 0; i < max; i += 1) {
  25. if (action === 'publish') {
  26. subscribers[i].fn.call(subscribers[i].context, arg);
  27. } else {
  28. if (subscribers[i].fn === arg && subscribers[i].context === context) {
  29. subscribers.splice(i, 1);
  30. }
  31. }
  32. }
  33. }
  34. };

新的Player()构造函数是这样:

  1. function Player(name, key) {
  2. this.points = 0;
  3. this.name = name;
  4. this.key = key;
  5. this.fire('newplayer', this);
  6. }
  7. Player.prototype.play = function () {
  8. this.points += 1;
  9. this.fire('play', this);
  10. };

变动的部分是这个构造函数接受key,代表这个玩家在键盘上用来按之后得分的按键。(这些键预先被硬编码过。)每次创建一个新玩家的时候,一个newplayer事件也会被触发。类似的,每次有一个玩家玩的时候,会触发play事件。

scoreboard对象和原来一样,它只是简单地将当前分数显示出来。

game对象会关注所有的玩家,这样它就可以给出分数并且触发scorechange事件。它也会订阅浏览器中所有的·keypress·事件,这样它就会知道按钮对应的玩家:

  1. var game = {
  2. keys: {},
  3. addPlayer: function (player) {
  4. var key = player.key.toString().charCodeAt(0);
  5. this.keys[key] = player;
  6. },
  7. handleKeypress: function (e) {
  8. e = e || window.event; // IE
  9. if (game.keys[e.which]) {
  10. game.keys[e.which].play();
  11. }
  12. },
  13. handlePlay: function (player) {
  14. var i,
  15. players = this.keys,
  16. score = {};
  17. for (i in players) {
  18. if (players.hasOwnProperty(i)) {
  19. score[players[i].name] = players[i].points;
  20. }
  21. }
  22. this.fire('scorechange', score);
  23. }
  24. };

用于将任意对象转变为订阅者的makePublisher()还是和之前一样。game对象会变成发布者(这样它才可以触发scorechange事件),Player.prototype也会变成发布者,以使得每个玩家对象可以触发playnewplayer事件:

  1. makePublisher(Player.prototype);
  2. makePublisher(game);

game对象订阅playnewplayer事件(以及浏览器的keypress事件),scoreboard订阅scorechange事件:

  1. Player.prototype.on("newplayer", "addPlayer", game);
  2. Player.prototype.on("play", "handlePlay", game);
  3. game.on("scorechange", scoreboard.update, scoreboard);
  4. window.onkeypress = game.handleKeypress;

如你所见,on()方法允许订阅者通过函数(scoreboard.update)或者是字符串("addPlayer")来指定回调函数。当有提供context(如game)时,才能通过字符串来指定回调函数。

初始化的最后一点工作就是动态地创建玩家对象(以及它们对象的按键),用户想要多少个就可以创建多少个:

  1. var playername, key;
  2. while (1) {
  3. playername = prompt("Add player (name)");
  4. if (!playername) {
  5. break;
  6. }
  7. while (1) {
  8. key = prompt("Key for " + playername + "?");
  9. if (key) {
  10. break;
  11. }
  12. }
  13. new Player(playername, key);
  14. }

这就是游戏的全部。你可以在http://www.jspatterns.com/book/7/observer-game.html看到完整的源代码并且试玩一下。

值得注意的是,在中介者模式中,mediator对象必须知道所有的对象,然后在适当的时机去调用对应的方法。而这个例子中,game对象会显得笨一些(译注:指知道的信息少一些),游戏依赖于对象去观察特定的事件然后触发相应的动作:如scoreboard观察scorechange事件。这使得对象之间的耦合更松了(对象间知道彼此的信息越少越好),而代价则是弄清事件和订阅者之间的对应关系会更困难一些。在这个例子中,所有的订阅行为都发生在代码中的同一个地方,而随着应用规模的境长,on()可能会被在各个地方调用(如在每个对象的初始化代码中)。这使得调试更困难一些,因为没有一个集中的地方来看这些代码并理解正在发生什么事情。在观察者模式中,你将不再能看到那种从开头一直跟到结尾的顺序执行方式。