观察者模式(Observer):又称作发布-订阅模式或消息机制,定义了一种依赖关系,解决了主体对象与观察者之间功能的耦合。
发布订阅模式可以解决主体对象与观察者之间功能的耦合。
举个栗子,一架飞机要从沈阳飞到香港,当经过北京中转站时,需要向卫星发送位置信息,卫星接收到飞机的位置信息后将消息保存在消息容器中,向订阅了飞机信息的北京站和香港站发送信息,两个站点接收到飞机的消息并做相应的处理以避免飞机事故的发生
当飞机已经离开北京中转站,北京中转站就不需要再接收飞机的位置信息,因此北京中转站可以取消订阅飞机信息。
根据以上的例子,我们基本可以确定观察者对象的实现:
- 创建观察者对象
- 为观察者对象添加消息容器
- 新增消息订阅方法
- 新增发送订阅的消息方法
- 新增取消订阅的消息方法
var Observer = (function() {
// 防止消息队列暴露而被篡改故将消息容器作为静态私有变量保存
var _message = {};
return {
// 注册消息接口
regist: function() {},
// 发布消息接口
fire: function() {},
// 移除信息接口
remove: function() {}
}
})();
下面我们来一一实现这三个方法
注册消息接口
注册方法的作用是将订阅者注册的消息推入到消息队列中,因此我们需要接收两个参数:消息类型以及相应动作
在推入到消息队列时:
- 如果此消息不存在则应该创建一个该消息类型并将该消息放入消息队列中
- 如果此消息存在则应该将该消息执行方法推入该消息对应的执行方法队列中,这么做的目的也是保证多个模块注册同一则消息时能顺利执行
// 注册消息接口
regist: function(type, fn) {
// 如果此消息不存在则应该创建一个该消息类型
if (typeof _message[type] === 'undefined') {
// 将动作推入到该消息对应的动作执行队列中
_message[type] = [fn];
// 如果此消息存在
} else {
// 将动作方法推入该消息对应的动作执行序列中
_message[type].push(fn);
}
return this;
}
发布消息接口
对于发布消息方法,其功能是当观察者发布一个消息时将所有订阅者订阅的消息一次执行。
故应该接受两个参数,消息类型以及动作执行时需要传递的参数,当然在这里消息类型是必须的。在执行消息队列之前校验消息的存在是很有必要的。
然后遍历消息执行方法队列,并依此执行。然后将消息类别以及传递的参数打包后依次传入消息队列执行方法中。
// 发布消息接口
fire: function(type, args) {
// 如果该消息没有被注册,则返回
if (!_message[type]) return;
// 定义消息信息
var events = {
type: type, // 消息类型
args: args || {} // 消息携带数据
},
i = 0, // 消息动作循环变量
len = _message[type].length; // 消息动作长度
// 遍历消息动作
for(; i < len; i++) {
// 依次执行注册的消息对应的动作序列
_message[type][i].call(this, events);
}
},
消息注销接口
消息注销接口的作用是将订阅者注销的消息从消息队列清除,因此我们也需要两个参数,即消息类型以及执行的某一个动作。
当然为了避免删除消息动作时消息不存在情况的出现,对消息队列中消息的存在性校验也很有必要的。
// 移除信息接口
remove: function(type, fn) {
// 如果消息动作队列存在
if (_messages[type] instanceof Array) {
// 从最后一个消息动作遍历
var i = _messages[type].length - 1;
for (; i >= 0; i--) {
// 如果存在该动作则在消息动作中移除相应动作
_messages[type][i] === fn && _messages[type].splice(i, 1);
}
}
}
至此,我们的观察者对象(消息系统)已经创建成功,下面我们简单测试下
Observer.regist('test', function(e) {
console.log(e.type, e.args.msg);
});
Observer.fire('test', {
msg: '传递参数'
});
// test 传递参数
应用案例
假如我们有一个正在开发的新闻模块,需求大致如下:
- 当用户发布评论时,会在评论展示模块末尾处追加新的评论,与此同时用户的消息模块的消息数量也会递增
- 用户删除留言区的信息时,用户的消息模块消息数量也会递减
但现在有一个问题,这些模块的代码是三位不同的工程师在开发,都写在各自独立的闭包模块里,导致三个模块严重耦合在一起。
文章开始,我们提到:发布订阅模式可以解决主体对象与观察者之间功能的耦合。现在,我们可以利用我们之前创建的消息系统解决模块间耦合的问题。
首先,我们来分析一下三个模块间的角色:发布留言与删除留言功能需求是用户主动触发,所以应该是观察者发布消息;追加评论以及用户消息的递增是被动触发的,所以他们是订阅者去注册消息,那么我们得出以下结论
- 用户信息模块既是消息的发送者也是消息的接受者
- 提交模块是信息的发送者
- 浏览模块是信息的接收者
// 外观模式,简化获取元素
function $(id) {
return document.getElementById(id);
}
// 工程师A
(function() {
// 追加一则消息
function addMsgItem(e) {
var text = e.args.text, // 获取消息中用户添加的文本内容
ul = $('msg'), // 留言容器元素
li = document.createElement('li'), // 创建内容容器元素
span = document.createElement('span'); // 删除按钮
// 写入评论
li.innerHTML = text;
// 关闭按钮
span.onclick = function() {
ul.removeChild(li);
// 发布删除留言信息
Observer.fire('removeCommentMessage', {
num: -1
});
}
// 添加删除按钮
li.appendChild(span);
// 添加留言节点
ul.appendChild(li);
}
Observer.regist('addCommentMessage', addMsgItem);
})();
实现递增用户信息功能也很容易,只需要在原信息数目基础上加1即可
// 工程师B
(function() {
// 更改用户消息数目
function changeMsgNum(e) {
// 获取需要增加的用户消息数目
var num = e.args.num;
$('msg_num').innerHTML = parseInt($('msg_num').innerHTML) + num;
}
// 注册添加评论信息
Observer
.regist('addCommentMessage', changeMsgNum)
.regist('removeCommentMessage', changeMsgNum)
})();
最后,对于一个用户来说,当他提交信息时,就要触发消息发布功能
// 工程师C
(function() {
// 用户点击提交按钮
$('user_submit').onclick = function() {
// 获取用户输入框中的内容
var text = $('user_input');
// 如果消息为空则提交失败
if (text.value === '') return;
// 发布一则评论消息
Observer.fire('addCommentMessage', {
text: text.value, // 消息评论内容
num: 1 // 消息评论数目
});
text.value = ''; // 将输入框清空
}
})();
效果
附完整代码,为了简化流程,这里将所有js代码都放在一个文件中,实际开发中可能是多个文件
index.js
var Observer = (function() {
// 防止消息队列暴露而被篡改故将消息容器作为静态私有变量保存
var _message = {};
return {
// 注册消息接口
regist: function(type, fn) {
// 如果此消息不存在则应该创建一个该消息类型
if (typeof _message[type] === 'undefined') {
// 将动作推入到该消息对应的动作执行队列中
_message[type] = [fn];
// 如果此消息存在
} else {
// 将动作方法推入该消息对应的动作执行序列中
_message[type].push(fn);
}
return this;
},
// 发布消息接口
fire: function(type, args) {
// 如果该消息没有被注册,则返回
if (!_message[type]) return;
// 定义消息信息
var events = {
type: type, // 消息类型
args: args || {} // 消息携带数据
},
i = 0, // 消息动作循环变量
len = _message[type].length; // 消息动作长度
// 遍历消息动作
for (; i < len; i++) {
// 依次执行注册的消息对应的动作序列
_message[type][i].call(this, events);
}
},
// 移除信息接口
remove: function(type, fn) {
// 如果消息动作队列存在
if (_messages[type] instanceof Array) {
// 从最后一个消息动作遍历
var i = _messages[type].length - 1;
for (; i >= 0; i--) {
// 如果存在该动作则在消息动作中移除相应动作
_messages[type][i] === fn && _messages[type].splice(i, 1);
}
}
}
}
})();
// 外观模式,简化获取元素
function $(id) {
return document.getElementById(id);
}
// 工程师A
(function() {
// 追加一则消息
function addMsgItem(e) {
var text = e.args.text, // 获取消息中用户添加的文本内容
ul = $('msg'), // 留言容器元素
li = document.createElement('li'), // 创建内容容器元素
span = document.createElement('span'); // 删除按钮
// 写入评论
li.innerHTML = text;
// 关闭按钮
span.innerHTML = '删除';
span.onclick = function() {
ul.removeChild(li);
// 发布删除留言信息
Observer.fire('removeCommentMessage', {
num: -1
});
}
// 添加删除按钮
li.appendChild(span);
// 添加留言节点
ul.appendChild(li);
}
Observer.regist('addCommentMessage', addMsgItem);
})();
// 工程师B
(function() {
// 更改用户消息数目
function changeMsgNum(e) {
// 获取需要增加的用户消息数目
var num = e.args.num;
$('msg_num').innerHTML = parseInt($('msg_num').innerHTML) + num;
}
// 注册添加评论信息
Observer
.regist('addCommentMessage', changeMsgNum)
.regist('removeCommentMessage', changeMsgNum)
})();
// 工程师C
(function() {
// 用户点击提交按钮
$('user_submit').onclick = function() {
// 获取用户输入框中的内容
var text = $('user_input');
// 如果消息为空则提交失败
if (text.value === '') return;
// 发布一则评论消息
Observer.fire('addCommentMessage', {
text: text.value, // 消息评论内容
num: 1 // 消息评论数目
});
text.value = ''; // 将输入框清空
}
})();
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<style>
body {
margin: 0;
}
.main-wrapper {
position: relative;
width: 80%;
margin: 0 auto;
}
.top-wrapper {
height: 40px;
line-height: 40px;
background-color: #f1f1ff;
padding: 0 20px;
}
.center-wrapper {
padding: 20px;
}
.center-wrapper ul {
list-style: none;
padding: 0;
}
.center-wrapper li {
position: relative;
display: flex;
justify-content: space-between;
padding: 10px 15px;
background: #ffdf6b;
border-radius: 4px;
font-size: 14px;
margin-bottom: 10px;
}
.center-wrapper li span {
font-size: 12px;
cursor: pointer;
color: #969696;
}
.center-wrapper li span:hover {
color: #000;
}
.bottom-wrapper {
padding: 20px;
}
.input-item {
position: relative;
width: 100%;
height: 100px;
border-radius: 3px;
border: 1px solid #cecece;
padding: 10px 15px;
box-sizing: border-box;
}
.button-item {
display: inline-block;
position: relative;
padding: 7px 20px;
background: #5c96ff;
font-size: 14px;
color: #fff;
width: 50px;
border-radius: 4px;
margin-top: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="main-wrapper">
<div class="top-wrapper">
<span>黄若梅子</span>
<span>粉丝</span>
<span>7</span>
<span>消息</span>
<span id="msg_num">0</span>
</div>
<div class="center-wrapper">
<h3>最新发布消息</h3>
<ul id="msg"></ul>
</div>
<div class="bottom-wrapper">
<textarea class="input-item" id="user_input"></textarea>
<div class="button-item" id="user_submit">提交</div>
</div>
</div>
<script src="./index.js"></script>
</body>
</html>