【JS设计模式笔记】-观察者模式(即发布-订阅模式)(结构型)

发布-订阅模式的作用

比如常见的发送短信就是一个典型的发布-订阅模式,例如,小明、小红去售楼处购买房子,但是售楼处的工作人员告诉小明、小红当前楼盘已经售罄,新楼盘还没有开售,这个时候,小明、小红把自己的手机号告诉工作人员,当有新楼盘开售的时候让工作人员短信通知到他们。

那么小明、小红等购买者就是订阅者,他们订阅了新楼盘开售的消息,售楼处作为发布者,当楼盘开售的时候,售楼处就会短信通知到购房者(即是订阅者)。

代码中的发布-订阅(示例)

DOM事件

我们在DOM元素上绑定过事件函数,就是发布-订阅模式,如下示例代码:

document.body.addEventListener('click',function() {
    alert(1);
},false);

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

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

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

document.body.addEventListener('click',function() {
    alert(1);
},false);

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

document.body.addEventListener('click',function() {
    alert(3);
},false);

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

自定义事件

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

实现发布-订阅模式需要清楚以下内容:

  • 首先指定好谁是发布者(售楼处)
  • 然后给发布者添加一个缓存列表,用于存放回调函数来通知订阅者(售楼处收集的电话号码)
  • 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数(遍历订阅者,依次发短信)

我们还可以给回调函数中加入一些参数,订阅者可以接受这些参数,比如售楼处发短信给订阅者的时候,可以加上房子的单价、面积等信息,订阅者接收到这些消息后进行各自处理:

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

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

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

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

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

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


salesOffices.trigger(100000,88);
salesOffices.trigger(3000000,120);

从上面代码运行得出结果:

价格=100000
面积=88
价格=100000
面积=88
价格=3000000
面积=120
价格=3000000
面积=120

从上面代码看,一个最简单的发布-订阅模式已经实现,但是这儿存在一些问题,就是订阅者接收到了发布者发布的每条信息,所以我们需要增加一个标识key,让订阅者只订阅自己感兴趣的消息。改后的代码如下:

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

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

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

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

salesOffices.listen('squareMeter88',function(price) { // 小明订阅88平米房子的消息
	console.log('价格=' + price); // 输出 价格=1000000
})

salesOffices.listen('squareMeter120',function(price) { // 小红订阅120平米房子的消息
	console.log('价格=' + price); // 输出 价格=3000000
})


salesOffices.trigger('squareMeter88',1000000); // 发布88平米房子的价格
salesOffices.trigger('squareMeter120',3000000,); // 发布120平米房子的价格

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

上面代码实现了让售楼处拥有接受订阅和发布事件的功能。假如小明又去另外一个售楼处买房子,那么这段代码又必须在另外一个售楼处对象上重新一次,怎样让所有对象拥有发布-订阅功能呢?

我们把发布-订阅模式功能提取出来,放到一个单独的对象中:

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);
		var fns = this.clientList[key];
		
		if(!fns || fns.length === 0) { // 如果没有绑定对应的消息
			return false;
		}
		
		for(var i = 0, fn; fn = fns [ i++ ];) {
			fn.apply(this,arguments);
		}
	}
}

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

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

我们给售楼处对象salesOffices动态添加发布-订阅功能:

var salesOffices = {}; 
installEvent(salesOffices);


salesOffices.listen('squareMeter88',function(price) { // 小明订阅88平米房子的消息
	console.log('价格=' + price); // 输出 价格=1000000
})

salesOffices.listen('squareMeter120',function(price) { // 小红订阅120平米房子的消息
	console.log('价格=' + price); // 输出 价格=3000000
})


salesOffices.trigger('squareMeter88',1000000); // 发布88平米房子的价格
salesOffices.trigger('squareMeter120',3000000,); // 发布120平米房子的价格

取消订阅模式

有时候,我们也需要取消订阅事件的功能。例如小明不想买房子,为了不收到售楼处的短信,小明需要取消之前订阅的消息,我们给event对象添加remove方法:

var event = {
	clientList: {},
	listen: function(key,fn) {
		if(!this.clientList[key]) {
			this.clientList[key] = [];
		}
		this.clientList[key].push(fn); // 订阅的消息添加进缓存列表
	},
	remove: function(key,fn) {
		var fns = this.clientList[key];
		
		if(!fns) { // 如果key对应的消息没有被人订阅,则直接返回
			return false;
		}
		
		if(!fn) { // 如果没有传入具体的回调函数,表示需求取消key对应消息的所有订阅者
			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); // 删除订阅者的回调函数
				}
			}
		}
		
	},
	trigger: function() {
		var key = Array.prototype.shift.call(arguments);
		var fns = this.clientList[key];
		
		if(!fns || fns.length === 0) { // 如果没有绑定对应的消息
			return false;
		}
		
		for(var i = 0, fn; fn = fns [ i++ ];) {
			fn.apply(this,arguments);
		}
	}
}


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

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

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


salesOffices.remove('squareMeter88',fn1); // 删除小明的订阅
salesOffices.trigger('squareMeter88',1000000); // 输出: 1000000

真实例子---网站登录

加入我们要开发一个商城网站,网站有header头部、nav导航、消息列表、购物车等模块,这几个模块有一个共同的前提条件,就是必须先ajax异步请求获取用户的登录信息。

var login = {
	success: function(callback) {
		// 模拟请求返回的用户信息结果对象
		const userInfo = {
			name: '张三',
			age: 24,
			uuid: '12124234435421',
			avatar: 'base64...'
		}
		callback && callback(userInfo);
	}
}

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

登录模块由我们负责编写,我们还必须了解header模块中设置头像的方法setAvatar,消息模块的刷新方法refresh,购物车模块里的刷新方法refresh,这种耦合性会使程序变得僵硬,header模块不能随意改变setAvatar的方法名。

等有一天项目新增加了收货地址管理的模块,这个模块由另外一个同事编写,而此时你正在休假,他不得不给你打电话告诉你,登录之后麻烦刷新下收货地址列表,于是你在登录模块中添加了如下部分代码:

login.success(function(data){
	header.setAvatar(data.avatar); 
	nav.setAvatar(data.avatar); 
	message.refresh(); 
	cart.refresh(); 
	address.refresh(); // 增加这行代码
})

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

login.success(function(data){ 
	login.trigger('loginSuccess',data); // 发布登录成功的消息
})

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

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

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

注:上面代码要成功执行,得各个模块先订阅再发布才能执行。

全局的发布-订阅对象

回忆上面实现的发布-订阅模式,我们给售楼处对象和登录对象都添加了订阅和发布的功能,这儿存在两个小问题:

  • 我们需要给每个发布者对象都得添加订阅(listen)和发布(trigger)方法,以及一个缓存列表clientList,只是一种资源浪费。
  • 我们要订阅消息,得清楚发布者对象是谁,如小明只是要知道售楼处对象的名字是salesOffices,才能顺利的订阅到事件,如下代码:
salesOffices.listen('squareMeter88',fn1 = function(price) { // 小明订阅消息
 	console.log('价格=' + price); 
})

如果小明还想订阅另外一个售楼处300平米的房子,则小明要知道这个售楼处对象是salesOffices2,代码如下:

salesOffices2.listen('squareMeter300',fn1 = 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);
		var 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);
		}
		
		for(var l = fns.length; l >= 0; l--) {
			var _fn = fns[l];
			if(_fn === fn) {
				fns.splice(l,1);
			}
		}
	}
	return {
		listen,
		trigger,
		remove
	}
})();

	

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

Event.trigger('squareMeter88',1000000); // 输出: 1000000

模块间通信

比如我们现在有两个模块,a模块里面有一个按钮,每次点击按钮之后,b模块里的div中会显示按钮的总点击次数,我们用全局发布-订阅模式完成下面代码,使得a模块和b模块可以在保持封装性的前提下进行通信。

<button id="count">点击我</button>
<div id="show"></div>
<script type="text/javascript">
// Event对象代码省略

var a = (function(){
	var count = 0;
	var btn = document.getElementById('count');
	btn.addEventListener('click',function(){
		Event.trigger('add',++count);
	},false);
})();

var b = (function(){
	var dom = document.getElementById('show');
	Event.listen('add',function(count) {
		dom.innerHTML = count;
	})
})();
<script>

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

必须先订阅再发布吗

从前面的代码看,我们的发布-订阅模式,都是订阅者必须先订阅一个消息,随后才能接收到发布者发布的消息。如果把顺序反过来,发布者先发布了一条消息,而在此前并没有对象来订阅它,这条消息就不会被后订阅的对象接收到。

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

这种需求在实际项目中是存在的,比如之前的商城网站,获取用户信息之后才能渲染用户导航模块,而获取用户信息是异步请求,当ajax请求成功返回之后发发布一个事件,在此之前的订阅了此事件的用户导航模块可以接收到这些用户信息,但是这是理想状态,我们有可能先发布了此消息,但是还没有提前订阅,那么我们就需要一个先发布后订阅的能力。

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

全局事件的命名冲突

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

var Event = (function(){
  var global = this,
      Event,
      _default = 'default';
  Event = function() {
    var _listen,
        _trigger,
        _remove,
        _create,
        _shift = Array.prototype.shift;
        _unshift = Array.prototype.unshift,
        namespaceCache= {},
        each = function(ary,fn) {
          var ret;
          for(var i = 0, l = ary.length; i < l; i++) {
            var n = ary[i];
            ret = fn.call(n,i,n);
          }
          return ret;
        };

    _listen = function(key,fn,cache) {
      if(!cache[key]) {
        cache[key] = [];
      }
      cache[key].push(fn);
    };

    _remove = function(key,cache,fn) {
      if(cache[key]) {
        if(fn) {
          for(var i  = cache[key].length - 1; i >= 0; i--) {
            if(cache[key][i] === fn) {
              cache[key].splice(i,1);
            }
          }
        } else {
          cache[key] = [];
        }
      }
    };

    _trigger = function() {
      var cache = _shift.call(arguments),
          key = _shift.call(arguments),
          args = arguments,
          _self = this,
          stack = cache[key];
      
      if(!stack || !stack.length) {
        return;
      }

      return each(stack,function(){
        return this.apply(_self,args);
      })
    };

    _create = function(namespace) {
      var namespace = namespace || _default;
      var cache = {},
          offlineStack = [], // 离线事件
          ret = {
            listen: function(key,fn,last) {
              _listen(key,fn,cache);
              if(offlineStack === null) {
                return;
              }
              if(last === 'last') {
                offlineStack.length && offlineStack.pop()();
              } else {
                each(offlineStack,function() {
                  this();
                })
              }
              offlineStack = null;
            },
            one: function(key,fn,last) {
              _remove(key,cache);
              this.listen(key,fn,last);
            },
            remove: function(key,fn) {
              _remove(key,cache,fn);
            },
            trigger: function() {
              var fn,
                  args,
                  _self = this;
              _unshift.call(arguments,cache);
              args = arguments;
              fn = function() {
                return _trigger.apply(_self,args);
              }
              if(offlineStack) {
                return offlineStack.push(fn);
              }
            return fn();
            }
          };
          return namespace ?
                (namespaceCache[namespace] ? namespaceCache[namespace] :
                    namespaceCache[namespace] = ret)
                      : ret;
    };

    return {
      create: _create,
      one: function(key,fn,last) {
        var event = this.create();
        event.one(key,fn,last);
      },
      remove: function(key,fn) {
        var event = this.create();
        event.remove(key,fn);
      },
      listen: function(key,fn,last) {
        var event = this.create();
        event.listen(key,fn,last);
      },
      trigger: function() {
        var event = this.create();
        event.trigger.apply(this,arguments);
      }
    }
  }();
  
  return Event;
  
})();

调用示例:

/**************** 先发布后订阅 *******************/
Event.trigger('click',1);
Event.listen('click',function(a) {
  console.log(a); // 输出:1
})

/****************** 使用命名空间 *************************/
Event.create('namespace1').trigger('click',2)
Event.create('namespace1').listen('click',function(a){
  console.log(a); // 输出:2
})


/******************* 使用one方法,订阅一次 *************************/
Event.create('namespace2').listen('click',function(a) {
  console.log(a);
})
Event.create('namespace2').one('click',function(a) {
  console.log('只执行了一次')
  console.log(a) //输出:3
})

Event.create('namespace2').trigger('click',3);
posted @ 2022-10-09 21:01  风雨后见彩虹  阅读(101)  评论(0编辑  收藏  举报