观察者模式(Observer)

观察者模式定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。又称为发布-订阅模式。

观察者模式最主要的就是完美的将观察者与被观察的对象分离开来。由于面向对象的单一职责原则,使得系统中的每一个类将重点放在某一个功能上,而不是其他方面。观察者模式在模块之间划定了情绪的界限,提高了应用程序的可维护性和重用性。

发布-订阅模式的作用

发布-订阅模式可以广泛应用于异步编程之中,这是一种替代传递回调函数的方案。比如,我们可以订阅 ajax 请求的 error、succ 等事件。或者如果想在动画的每一帧完成之后做一些事情,那么我们可以订阅一个事件,然后在动画的每一帧完成之后发布这个事件。在异步编程中使用发布-订阅模式,我们就无需过多关注对象在异步运行期间的内部状态,而只需要订阅感兴趣的事件发生点。

第二点说明发布-订阅模式可以取代对象之间硬编码的通知机制,一个对象不再显试的调用另外一个对象的某个接口。发布 -订阅模式让两个怼系那个松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由地改变它们。

DOM 事件

实际上,只要我们曾经在 DOM 节点上面绑定过事件函数,那我们就曾经使用过发布-订阅模式,来看看如下两句简单代码发生了什么事情:

document.body.addEventListener(
  "click",
  function() {
    alert(2);
  },
  false
);

document.body.click(); // 模拟用户点击

这里需要监控用户点击 document.body 的动作,但是我们没办法预知用户将在什么时候点击。所以我们订阅 document.body 上面的 click 事件,当 body 节点被点击时,body 节点便会向订阅者发送这个消息。

我们还可以随意增加或者删除订阅者,增加任何订阅者都不会影响发布者代码的编写:

document.body.addEventListener(
  "click",
  function() {
    alert(2);
  },
  false
);
document.body.addEventListener(
  "click",
  function() {
    alert(3);
  },
  false
);
document.body.addEventListener(
  "click",
  function() {
    alert(4);
  },
  false
);

document.body.click(); // 模拟用户点击

注意,手动触发事件更好的做法是 IE 下用 fireEvent,标准浏览器下用 dispatchEvent 实现。

自定义事件

除了 DOM 事件,我们还会经常实现一些自定义的事件,这种依靠自定义事件完成的发布-订阅模式可以用于任何 JavaScript 代码中。

现在看看如何一步步实现发布-订阅模式。

  • 首先要指定好谁充当发布者;
  • 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者;
  • 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数。

另外,我们还可以往回调函数里填入一些参数,订阅者可以接收这些参数。这是很有必要的,订阅者接收到这些信息之后可以进行各自的处理:

var salesOffices = {}; // 定义售楼处

salesOffices.clientList = []; // 缓存列表,存放订阅者的回调函数

salesOffices.listen = function(fn) {
  // 增加订阅者
  this.clientList.push(fn); // 订阅的消息添加进缓存列表
};

salesOffices.trigger = function() {
  //发布消息
  for (var i = 0, fn; (fn = this.clientList[i++]); ) {
    fn.apply(this, arguments); // (2) // arguments是发布消息时带上的参数
  }
};

下面进行一些简单测试:

salesOffices.listen(function(price, squareMeter) {
  // 小明订阅消息
  console.log("价格=" + price);
  console.log("squareMeter=" + squareMeter);
});

salesOffices.listen(function(price, squareMeter) {
  // 小红订阅消息
  console.log("价格=" + price);
  console.log("squareMeter=" + squareMeter);
});

salesOffices.trigger(2000000, 88);
salesOffices.trigger(3000000, 110);

至此,我们已经实现了一个最简单的发布-订阅模式,但这里还存在一定的问题。我们看到订阅者接收到了发布者的每个消息,虽然小明只想买 88 平的房子,但是发布者把所有的信息都推送给了小明,这对小明来说是不必要的困然。所以我们有必要添加一个标示 key,让订阅者只订阅自己感兴趣的消息。改写后的代码如下:

var salesOffices = {}; // 定义售楼处

salesOffices.clientList = {}; // 缓存列表,存放订阅者的回调函数

salesOffices.listen = function(key, fn) {
  if (!this.clientList[key]) {
    this.clientList[key] = []; // 如果还没有过此类消息,给该类消息创建一个缓存列表
  }
  this.clientList[key].push(fn); // 订阅的消息添加进缓存列表
};

salesOffices.trigger = function() {
  //发布消息
  var key = Array.prototype.shift.call(arguments), // 取出消息类型
    fns = this.clientList[key]; // 取出该消息对应的回调函数集合
  if (!fns || fns.length === 0) {
    // 如果没有订阅该消息,则返回
    return false;
  }
  for (var i = 0, fn; (fn = fns[i++]); ) {
    fn.apply(this, arguments); // (2) // arguments是发布消息时带上的参数
  }
};

salesOffices.listen("squareMeter88", function(price) {
  // 小明订阅消息
  console.log("价格=" + price);
});

salesOffices.listen("squareMeter110", function(price) {
  // 小红订阅消息
  console.log("价格=" + price);
});

salesOffices.trigger("squareMeter88", 2000000);
salesOffices.trigger("squareMeter110", 2000000);

很明显,现在订阅者可以只订阅自己感兴趣的事件了。

发布-订阅模式的通用实现

现在我们已经看到了如何让售楼处拥有订阅和发布事件的能力。如果现在小明去另一个售楼处买房子,那么这段代码是否必须重写一次呢??我们要考虑如何实现所有对象都可以拥有发布-订阅功能。

所以我们把发布-订阅的功能提取出来,放在一个单独的对象内:

var event = {
  clientList: [],
  listen: function(key, fn) {
    if (!this.clientList[key]) {
      this.clientList[key] = [];
    }
    this.clientList[key].push(fn); // 订阅的消息添加进缓存列表
  },
  trigger: function() {
    var key = Array.prototype.shift.call(arguments), // (1)
      fns = this.clientList[key];
    if (!fns || fns.length === 0) {
      return fasle;
    }
    for (var i = 0, fn; (fn = fns[i++]); ) {
      fn.apply(this.arguments); // (2) // arguments是trigger时带上的参数
    }
  }
};

再定义一个 installEvent 函数,这个函数可以给所有的对象都动态的安装发布-订阅功能:

var installEvent = function(obj) {
  for (var i in event) {
    obj[i] = event[i];
  }
};

再来测试一下,我们来动态添加发布-订阅功能:

var salesOffices = {};
installEvent(salesOffices);

salesOffices.listen("squareMeter88", function(price) {
  // 小明订阅消息
  console.log("价格=" + price);
});

salesOffices.listen("squareMeter110", function(price) {
  // 小红订阅消息
  console.log("价格=" + price);
});

salesOffices.trigger("squareMeter88", 2000000);
salesOffices.trigger("squareMeter110", 2000000);

取消订阅事件

有时候,我们也许需要取消订阅事件的功能。比如小明突然不想房子了,为了避免继续收到短信,小明需要取消之前的订阅事件。现在我们给 event 对象添加了 remove 方法:

event.remove = function(key, fn) {
  var fns = this.clentList[key];

  if (!fns) {
    // 如果key对应的消息没有被人订阅,则直接返回
    return false;
  }
  if (!fn) {
    // 如果没有传入具体的回调函数,表示需要取消key对应消息的所有订阅
    fn && (fns.length = 0);
  } else {
    for (var l = fns.length - 1; l >= 0; l--) {
      // 反向遍历订阅的回调函数列表
      var _fn = fns[l];
      if (_fn === fn) {
        fns.splice(l, 1); // 删除订阅者的回调函数
      }
    }
  }
};

var salesOffices = {};
var installEvent = function(obj) {
  for (var i in event) {
    obj[i] = event[i];
  }
};

installEvent(salesOffices);

salesOffices.listen(
  "squareMeter88",
  (fn1 = function(price) {
    // 小明订阅消息
    console.log("价格=" + price);
  })
);

salesOffices.listen(
  "squareMeter110",
  (fn2 = function(price) {
    // 小红订阅消息
    console.log("价格=" + price);
  })
);

salesOffices.remove("squareMeter88", fn1); // 删除小明的订阅
salesOffices.trigger("squareMeter110", 2000000);

真实的例子 - 网站登录

假设我们现在正在开发一个商城网站,网站里有 header、nav、消息列表、购物车等模块。这几个模块的渲染有一个共同的前提条件,就是必须先用 ajax 异步请求获取用户的登录信息。

至于 ajax 请求什么时候能成功返回用户信息,这点我们没法确定,这像极了售楼处的例子。但是这点还不足以说服我们使用发布-订阅模式,因为异步的问题通常也可以用回调函数来解决。更重要的一点是,我们不知道除了 header、nav、消息列表和购物车之外,将来还有哪些模块需要用户信息。如果它们和用户信息模块产生了强耦合,比如下面这样的形式:

login.succ(function(data) {
  header.setAvatar(data.avatar); // 设置header模块的头像
  nav.setAvatar(data.avatar); // 设置导航模块的头像
  message.refresh(); // 刷新消息列表
  cart.refresh(); // 刷新购物车列表
});

现在登录模块是我们负责编写的,但我们还必须了解 header 模块里面设置头像的方法叫 setAvatar,这种耦合性导致 header 模块不能再随意改变 setAvatar 的方法名,他自身的名字也不能被改为 header1、header2。这是针对具体实现编程的典型例子,针对具体实现编程是不被赞同的。

等到有一天,项目中要添加一个收货地址管理的模块,这个模块本来是另一个同事写的,但是你此时需要放下手头的事情,打开 3 个月前写的登录模块,在最后部分加上代码。这样我们就会越来越疲于应付这些突如其来的业务要求,要么跳槽了事,要么必须来重构这些代码。

用发布-订阅模式重写之后,对用户信息感兴趣的业务模块将自行订阅登录成功的消息事件。当登录成功时,登录模块只需要发布登录成功的消息,而业务方接受到消息之后,就会开始进行各自的业务处理,登录模块并不关心业务方究竟要做什么,也不想去了解它们的内部细节。改善后的代码如下:

$.ajax("http://xxx.com?login", function(data) {
  // 登录成功
  login.trigger("loginSucc", data); // 发布登录成功的消息
});

各模块监听登录成功的消息:

var header = (function() {
  login.listen("loginSucc", function(data) {
    header.setAvatar(data.avatar);
  });
  return {
    setAvatar: function(data) {
      console.log("设置header模块的头像");
    }
  };
})();

var nav = (function() {
  login.listen("loginSucc", function(data) {
    nav.setAvatar(data.avatar);
  });
  return {
    setAvatar: function(avatar) {
      console.log("设置nav模块的头像");
    }
  };
})();

这样,我们就随时可以把 setAvatar 的方法名改为 setTouxiang。如果有一天登录模块完成后,又要增加一个刷新收货地址的行为,那么只要在收获地址模块上年加上监听消息的方法即可,这可以让开发该模块的同事自行完成。

全局的发布-订阅对象

回想一下刚实现的发布-订阅模式,我们给售楼处对象和登录对象都添加了订阅和发布的功能,这里还有两个小问题:

  • 我们给每个发布者对象都添加了 listen 和 trigger 方法,以及一个缓存列表 clientList,这其实是一种资源浪费。
  • 小明跟售楼处对象还是存在着一定的耦合性,小明至少要知道售楼处对象的名字是 salesOffices,才能顺利的订阅到事件。

代码如下:

salesOffices.listen("squareMeter100", function(price) {
  // 小红订阅消息
  console.log("价格=" + price);
});

如果小明还关心 300 平米的房子,而这个卖家是 salesOffices2,那么小明还需要订阅 salesOffices2 的对象。如下:

salesOffices2.listen("squareMeter300", function(price) {
  // 小红订阅消息
  console.log("价格=" + price);
});

现实中,买房子为必要自己亲自去售楼处,我们只需要把信息提交给中介公司即可,房产公司也只需要通过中介公司来发布房子信息。这样一来我们不需要关心消息来源于哪个房产公司,我们在意的是能否顺利的收到房屋提示的消息。当然为了保证发布和订阅的顺利通信,订阅者和发布者都需要知道这个中介公司。

同样在程序中,发布-订阅模式可以用一个全局的 Event 对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event 作为一个类似“中介者”的角色,把订阅者和发布者联系起来。代码如下:

var Event = (function() {
  var clientList = {},
    listen,
    trigger,
    remove;
  listen = function(key, fn) {
    if (!clientList[key]) {
      clientList[key] = [];
    }
    clientList[key].push(fn);
  };
  trigger = function() {
    var key = Array.prototype.shift.call(arguments),
      fns = clientList[key];
    if (!fns || fns.length === 0) {
      return false;
    }
    for (var i = 0, fn; (fn = fns[i++]); ) {
      fn.apply(this.arguments);
    }
  };
  remove = function(key, fn) {
    var fns = clientList[key];
    if (!fns) {
      return false;
    }
    if (!fn) {
      fns && (fns.length = 0);
    } else {
      for (var l = fns.length - 1; l >= 0; l--) {
        var _fn = fns[l];
        if (_fn === fn) {
          fns.splice(l, 1);
        }
      }
    }
  };

  return {
    listen: listen,
    trigger: trigger,
    remove: remove
  };
})();

Event.listen("squareMeter88", function(price) {
  console.log("价格=" + price); // 输出:价格=2000000
});

Event.trigger("squareMeter88", 2000000);

模块间的通信

上一节中实现的发布-订阅模式的实现,是基于一个全局的 Event 对象,我们利用它可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。比如现在有两个模块,a 模块中又一个按钮,每次点击后,b 模块里的 div 会显示按钮点击的总数,我们利用全局发布-订阅模式完成下面的代码,使得 a 模块和 b 模块可以在保持封装性的前提下进行通信。

<!DOCTYPE html>
<html>
  <body>
    <button id="count">点我</button>
    <div id="show"></div>
  </body>
  <script>
    var a = (function() {
      var count = 0;
      var button = document.getElementById("count");
      button.onclick = function() {
        Event.trigger("add", count++);
      };
    })();

    var b = (function() {
      var div = document.getElementById("show");
      Event.listen("add", function(count) {
        div.innerHTML = count;
      });
    })();
  </script>
</html>

这里我们要留意另一个问题,模块之间如果用了太多的全局发布-订阅模式来通信,那么模块与模块之间的联系就会被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,这又会给我们的维护带来一些麻烦,也许某个模块的作用就是暴露一些接口给其他模块调用。

必须先订阅再发布吗

以上我们所了解的发布-订阅模式,都是订阅者必须先订阅一个消息,随后才能接收到发布者发布的消息。如果把顺序反过来,发布者先发布一条消息,而在此之前并没有对象来订阅它,这条消息无疑会丢失。

在某些情况下我们必须先把这条消息保存下来,等到有对象来订阅它的时候,再重新把消息发布给订阅者。就如同 QQ 的离线消息一样,离线消息被保存在服务器中,接收人下次登录上线之后,可以重新收到这条消息。

这种需求再实际项目中是存在的,比如之前的商城网站中,获取用户信息之后才能渲染用户导航模块,而获取用户信息操作是一个 ajax 异步请求。当 ajax 请求成功返回之后会发布一个事件,在此之前订阅了此事件的用户导航模块可以接收到这些用户信息。

但这指示理想状态,因为异步的原因,我们不能保证 ajax 请求返回的时间,有时候它返回得比较快,而此时用户导航模块的代码还没有加载好(还没有订阅相应事件),特别是在用了一些模块化惰性加载的技术后,这是很可能发生的事情。也许我们还需要一个方案,使得我们的发布-订阅对象拥有先发布后订阅的能力。

为了满足这个需求,我们要建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈并一次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,就像 QQ 的未读消息只会被重新阅读一次,所以刚才的操作我们只能进行一次。

全局事件的命名冲突

全局的发布-订阅对象里只有一个 clinetList 来存放消息名和回调函数,大家都通过它来订阅和发布各种消息,久而久之,难免会出现事件名冲突的情况,所以我们还可以给 Event 对象提供创建命名空间的功能。

在提供最终代码之前,我们来感受一下这么使用这两个新增的功能。

/********** 先发布后订阅 **********/
Event.trigger("click", 1);
Event.listen("click", function(a) {
  console.log(a);
});
/********** 使用命名空间 **********/
Event.create("namespace1").listen("click", function(a) {
  console.log(a);
});
Event.create("namespace1").trigger("click", 1);
Event.create("namespace2").listen("click", function(a) {
  console.log(a);
});
Event.create("namespace2").trigger("click", 2);

JavaScript 实现发布-订阅模式的便利性

这里要提出的是,我们一直讨论的发布-订阅模式,跟一些别的语言(比如 Java)中的实现还是有区别的。在 Java 中实现一个自己的发布-订阅模式,通常会把订阅者对象自身当成引用传入发布者对象时,同时订阅者对象还需提供一个名为诸如 update 的方法,供发布者对象在适合的时候调用。而在 JavaScript 中,我们用注册回调函数的形式来代替传统的发布-订阅模式,显得更加优雅和简单。

另外,在 JavaScript 中,我们无需去选择使用推模型还是拉模型。推模型是指在事件发生时,发布者一次性把所有更改的状态和数据都推送给订阅者。拉模型不同的地方在于,发布者仅仅通知订阅者时间已经发生了,此外发布者需要提供一些公开的接口供订阅者来主动拉取数据。拉模型的好处是可以让订阅者“按需获取”,但同时有可能让发布者变成一个“门户大开”的对象,同时增加了代码量和复杂度。

刚好在 JS 中,arguments 可以很方便的表示参数列表,所以我们一般都会选择推模型,使用 Function.prototype.apply 方法把所有参数都推送给订阅者。

小结

发布-订阅模式(观察者模式)在实际开发中非常有用。

其优点非常明显:

  • 时间上的解耦
  • 对象之间的解耦

应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。发布-订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是 MVC 还是 MVVM,都少不了发布-订阅模式的参与,而且 JS 本身也是一门基于事件驱动的语言。

当然,发布-订阅模式也不是完全没有缺点。创建订阅者本身要消耗一定的时间和内存,而且你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外,发布-订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。特别是有多个发布者和订阅者嵌套到一起的时候,要跟踪一个 BUG 不是一件轻松的事情。