原文:https://addyosmani.com/resources/essentialjsdesignpatterns/book/#observerpatternjavascript
观察者模式中,一个对象(称为主题)维护着一个依赖与它的对象(观察者)列表,当有任何状态变更时通知到它们。
当一个主题需要通知观察者有它感兴趣的事情发生时,它会向这些观察者广播一个通知(其中会包含一些与通知主题相关的数据)。
当我们不再希望某个的观察者接收它订阅主题状态变更的通知时,可以把它们从观察者的名单中去除。
回顾已发布的与语言无关的设计模式定义通常是有帮助的,可以更广泛地了解它们的用法和优势。GoF 书(Design Patterns:Elements of Reusable Object-Oriented Software )对观察者模式的定义是中:
“一个或多个观察者对一个主题的状态感兴趣,并且通过绑定它们自己来注册它们对主题的兴趣。当我们主题中有观察者们感兴趣的变化发生时,会发送一个通知消息来调用每个观察者的更新方法。当观察者对主题的状态不再感兴趣时,它们只需要解绑自己即可。”
我们现在可以使用以下组件拓展我们已经学到的来实现观察者模式:
- Subject :维护着一个观察者列表,便于添加或者移除观察者
- Observer :为需要被通知 Subject 状态变化的对象提供更新接口
- ConcreteSubject :向观察者广播状态的变更,存储 ConcreteObservers 的状态
- ConcreteObserver :存储 ConcreteObservers 的引用,为观察者实现一个更新接口,以确保状态与主题一致
首先,我们为一个主题可能具有的观察者列表建立模型:
function ObserverList(){
this.observerList = [];
}
ObserverList.prototype.add = function( obj ){
return this.observerList.push( obj );
};
ObserverList.prototype.count = function(){
return this.observerList.length;
};
ObserverList.prototype.get = function( index ){
if( index > -1 && index < this.observerList.length ){
return this.observerList[ index ];
}
};
ObserverList.prototype.indexOf = function( obj, startIndex ){
var i = startIndex;
while( i < this.observerList.length ){
if( this.observerList[i] === obj ){
return i;
}
i++;
}
return -1;
};
ObserverList.prototype.removeAt = function( index ){
this.observerList.splice( index, 1 );
};
接下来,我们为主题建模,使其能够增、删和通知观察者列表中的观察者。
function Subject() {
this.observers = new ObserverList();
}
Subject.prototype.addObserver = function(observer) {
this.observers.add(observer);
};
Subject.prototype.removeObserver = function(observer) {
this.observers.removeAt(this.observers.indexOf(observer, 0));
};
Subject.prototype.notify = function(context) {
var observerCount = this.observers.count();
for (var i = 0; i < observerCount; i++) {
this.observers.get(i).update(context);
}
};
接下来我们定义一个创建新观察者的骨架。这里的 update
功能随后将会被自定义逻辑覆盖。
// 观察者
function Observer(){
this.update = function(){
// ...
};
}
在我们使用了上述观察者组件的示例应用中,我们现在定义:
- 一个按钮用于为页面添加新的可观察的复选框
- 一个用作主题的控制复选框,它会通知其他复选框他它们是否应该被选中
- 一个用于容纳新增的复选框的容器。
然后我们定义用于向页面添加新的观察者和实现更新接口的 ConcreteSubject 和 ConcreteObserver 处理函数。在下面的实例中的注释解释了这些组件在这个实例中的用途。
HTML:
<button id="addNewObserver">Add New Observer checkbox</button>
<input id="mainCheckbox" type="checkbox"/>
<div id="observersContainer"></div>
示例脚本:
**
// 使用 extension 拓展一个对象
function extend(obj, extension) {
for (var key in extension) {
obj[key] = extension[key];
}
}
// DOM 元素的引用
var controlCheckbox = document.getElementById('mainCheckbox'),
addBtn = document.getElementById('addNewObserver'),
container = document.getElementById('observersContainer');
// ConcreteSubject
// 使用 Subject 类来拓展用于控制的复选框
extend(controlCheckbox, new Subject());
// 点击复选框将会触发对观察者的通知
controlCheckbox.onclick = function() {
controlCheckbox.notify(controlCheckbox.checked);
};
addBtn.onclick = addNewObserver;
// ConcreteObserver
function addNewObserver() {
// 创建一个被添加的复选框
var check = document.createElement('input');
check.type = 'checkbox';
// 使用 Observer 类来拓展复选框
extend(check, new Observer());
// 使用自定义更新逻辑来覆盖
check.update = function(value) {
this.checked = value;
};
// 为我们的主要主题添加新观察者到观察者列表
controlCheckbox.addObserver(check);
// 将项目添加到容器中
container.appendChild(check);
}
在这个实例中,我研究了如何实现和使用观察者模式,包括主题、观察者、具体的主题和具体的观察者的概念。
观察者模式与发布/订阅模式的区别
虽然观察者模式很有用,但需要注意在 JavaScript 中我们经常会发现它通常会使用一种名为 发布/订阅模式的变体来实现。尽管非常的相似,但是这些模式间还是有一些值得注意的差异。
观察者模式要求希望接收主题通知的观察者(或者对象)必须把该兴趣订阅到触发事件的对象上(主题)。
然而,发布/订阅模式使用的是话题/事件的方式,它位于希望接收通知的对象(订阅者)和触发事件的对象(发布者)之间。事件系统允许代码定义特定于应用的事件,以此来传递包含了订阅者需要的数据的自定义参数。这里的思想是避免订阅者和发布者间的联系。
这个与观察者模式的区别是因为在于它允许任何订阅者实现一个合适的事件处理函数,用它来注册和接收发布者的话题通知广播。
下面的例子是关于如何使用发布/订阅,背景是已经声明了有 publish()
、subscribe()
和 unsubscribe()
函数。
// 一个简单的新邮件处理程序
// 已接收信息的数量
var mailCounter = 0;
// 初始化订阅者,它会监听一个名为 “inbox/newMessage” 的主题
// 渲染新消息的预览
var subscriber1 = subscribe( "inbox/newMessage", function( topic, data ) {
// 记录主题,用于调试
console.log( "A new message was received: ", topic );
// 使用从我们主题传递过来的数据来给用户展示消息预览
$( ".messageSender" ).html( data.sender );
$( ".messagePreview" ).html( data.body );
});
// 这里是另一个订阅者,使用了相同的数据来做另一个任务
// 更新展示从发布者接收消息数量的计数器
var subscriber2 = subscribe( "inbox/newMessage", function( topic, data ) {
$('.newMessageCounter').html( ++mailCounter );
});
publish( "inbox/newMessage", [{
sender: "hello@google.com",
body: "Hey there! How are you doing today?"
}]);
// 我们可以在稍后的时间点使用如下方式来取消接收新话题通知的订阅:
// unsubscribe( subscriber1 );
// unsubscribe( subscriber2 );
这里的总体的思想是促进解耦。它们订阅一个特定的任务或者其他对象的活动,并且会在它们发生时被通知,而不是单个对象直接调用其他对象的方法。
优点
观察者模式和发布/订阅模式鼓励我们认真考虑我们应用中不同部分间的关系。它们还能帮助我们识别那些包含了直接关联的层,这些层可以使用一组主题和观察者来替代。这可以有效地用于将一个应用拆分得更小,更松的耦合的块,以帮助改善代码管理和复用的潜力。
使用观察者模式更深层的动机是当我们需要在不让类之间紧密耦合的情况下保持它们的一致性。例如,当一个对象需要能够在不对其他对象作出假设的情况下通知这些对象。
使用两个模式中任意一种都可以使观察者和主题之间能存在动态的关系。这就提供了一个很强的灵活性,这在我们的应用不同模块是紧耦合的情况下是不能实现的。
尽管它不总是最佳的方案来解决任意问题,这些模式仍是设计低耦合系统最好的工具,并且可以被认作是任何 JavaScript 开发者最重要的工具之一。
缺点
因此,这些模式的一些问题实际上是伴随着它们的优点而来。在发布/订阅模式中,通过从订阅者中解耦除发布者时,有时很难保证我们程序的特定部分如我们预期那样执行。
例如:假设发布者有一个或者多个订阅者在监听它们。假设我们使用这样的假设来记录或输出有关程序进程的错误。如果执行日志记录的监听者奔溃了(或者由于某些原因未能执行),由于系统的解耦特性,发布者将没法得知这个情况。
这个模式的另一个缺点是订阅者之间无法相互感知,无法得知切换订阅者的开销。由于发布者和订阅者间的动态关系,依赖的更新很难被追踪。
发布/订阅模式的实现
发布/订阅模式非常适合 JavaScript 生态,很大的原因是core、ECMAScript 的实现是事件驱动的。尤其是在浏览器环境中,因为 DOM 使用事件作为它同脚本交互主要的 API。
也就是说,不管是 ECMAScript、DOM 都没有提供核心对象或者方法来在实现代码中创建自定义事件系统(除了 DOM3 自定义事件,它绑定了 DOM,因此一般没用)。
幸运的是,主流的 JavaScript 库,如 dojo、jQuery(自定义事件)和 YUI 早就有功能可以帮助轻易的实现一个发布/订阅系统。下面就是一些示例:
// 发布
// jQuery: $(obj).trigger("channel", [arg1, arg2, arg3]);
$( el ).trigger( "/login", [{username:"test", userData:"test"}] );
// Dojo: dojo.publish("channel", [arg1, arg2, arg3] );
dojo.publish( "/login", [{username:"test", userData:"test"}] );
// YUI: el.publish("channel", [arg1, arg2, arg3]);
el.publish( "/login", {username:"test", userData:"test"} );
// 订阅
// jQuery: $(obj).on( "channel", [data], fn );
$( el ).on( "/login", function( event ){...} );
// Dojo: dojo.subscribe( "channel", fn);
var handle = dojo.subscribe( "/login", function(data){..} );
// YUI: el.on("channel", handler);
el.on( "/login", function( data ){...} );
// 退订
// jQuery: $(obj).off( "channel" );
$( el ).off( "/login" );
// Dojo: dojo.unsubscribe( handle );
dojo.unsubscribe( handle );
// YUI: el.detach("channel");
el.detach( "/login" );
对于那些希望通过原生 JavaScript(另一个库)来使用发布/订阅模式的人来说,AmplifyJS 包含干净、无依赖的实现,它可以和任何库或者工具包一起使用。Radio.js 、PubSubJS 或者 Peter Higgins 的 Pure JS 也是值得查看的备选方案。
尤其是 jQuery 开发者还有其他一些选择,可以选择使用这些成熟的实现方案中的一种:Peter Higgins 的 jQuery 插件、Ben Alman(优化版)在 Github gist 上的 Pub/Sub。下面是其中的几个链接:
- Ben Alman 的 Pub/Sub gist https://gist.github.com/661855 (推荐)
- Rick Waldron 的 jQuery-core 风格 https://gist.github.com/705311
- Peter Higgins 的插件 http://github.com/phiggins42/bloody-jquery-plugins/blob/master/pubsub.js
- AmplifyJS 中的 AppendTo 的 Pub/Sub http://amplifyjs.com
- Ben Truyman 的 gist https://gist.github.com/826794
因此我们能够理解有多少 vanilla JavaScript 实现的观察者模式能生效,让我们预览一下我发布在 Github 上一个名为 pubsubz 的关于一个简版的发布/订阅模式的项目。它演示了关于订阅、发布以及退订的核心概念。
我选择了基于我们示例的部分代码,因为它从方法签名和实现方法都与我希望看到的 JavaScript 版的经典观察者模式。
一个发布/订阅的实现
var pubsub = {};
(function(myObject) {
// 存储用于被广播或者被监听的话题
var topics = {};
// 话题标识
var subUid = -1;
// 使用特定的话题名和参数(例如一起传递的数据)来发布/广播兴趣的事件
myObject.publish = function( topic, args ) {
if ( !topics[topic] ) {
return false;
}
var subscribers = topics[topic],
len = subscribers ? subscribers.length : 0;
while (len--) {
subscribers[len].func( topic, args );
}
return this;
};
// 使用特定的话题名和回调函数(用于在话题/事件被观察到时执行)
// 来订阅感兴趣的话题
myObject.subscribe = function( topic, func ) {
if (!topics[topic]) {
topics[topic] = [];
}
var token = ( ++subUid ).toString();
topics[topic].push({
token: token,
func: func
});
return token;
};
// 基于订阅的 token 引用,退订一个特定的话题
myObject.unsubscribe = function( token ) {
for ( var m in topics ) {
if ( topics[m] ) {
for ( var i = 0, j = topics[m].length; i < j; i++ ) {
if ( topics[m][i].token === token ) {
topics[m].splice( i, 1 );
return token;
}
}
}
}
return this;
};
}( pubsub ));
示例:使用我们的实现方案
我们可以按下面的方式来使用这个实现来发布和订阅感兴趣的事件:
// 另一个简单的消息处理器
// 一个简单的消息日志
// 用于记录从订阅者接收的话题和数据
var messageLogger = function ( topics, data ) {
console.log( "Logging: " + topics + ": " + data );
};
// 订阅者监听它们订阅的话题,并且在广播了一个关于这个话题
// 的新通知发出时执行回调函数(如:messageLogger)
var subscription = pubsub.subscribe( "inbox/newMessage", messageLogger );
// 发布者是负责发布话题或者应用的兴趣通知。
// 如:
pubsub.publish( "inbox/newMessage", "hello world!" );
// 或
pubsub.publish( "inbox/newMessage", ["test", "a", "b", "c"] );
// 或
pubsub.publish( "inbox/newMessage", {
sender: "hello@google.com",
body: "Hey again!"
});
// 我们还可以取消订阅使我们不希望订阅者不再继续接受通知
pubsub.unsubscribe( subscription );
// 一旦取消订阅,会导致这个示例中的 messageLogger 不会再执行
// 因为订阅者不再监听了
pubsub.publish( "inbox/newMessage", "Hello! are you still there?" );
示例:用户接口通知
接下来,假设我们有一个用于展示实时库存信息的 web 应用。
这个应用可能有一个网格用来展示库存状态和最近更新的数量。当数据模型变更时,应用需要更新这个网格和计数器。在这个情况下,我们的主题(它将用于发布话题/通知)是这个数据模型,我们的订阅者是网格和计数器。
当我们的订阅者接收到模型变更的通知,它们可以自动的更新它们自己。
在我们的实现中,我们的订阅者将会监听名为 “newDataAvailable” 的话题,来查看是否有新的库存信息。如果一个新的通知发布到这个话题,它将会触发 gridUpdate
来在网格中新增一行来展示这个信息。它还会更新计数器来记录最新的添加的数量。
// 返回当前的本地时间,将用在后面的UI中
getCurrentTime = function() {
var date = new Date(),
m = date.getMonth() + 1,
d = date.getDate(),
y = date.getFullYear(),
t = date.toLocaleTimeString().toLowerCase();
return m + '/' + d + '/' + y + ' ' + t;
};
// 向我们的虚拟表格添加新一行数据
function addGridRow(data) {
// ui.grid.addRow( data );
console.log('updated grid component with:' + data);
}
// 更新我们的虚拟表格来展示最近更新时间
function updateCounter(data) {
// ui.grid.updateLastChanged( getCurrentTime() );
console.log('data last updated at: ' + getCurrentTime() + ' with ' + data);
}
// 使用传递给订阅者的数据来更新表格
gridUpdate = function(topic, data) {
if (data !== undefined) {
addGridRow(data);
updateCounter(data);
}
};
// 创建一个监听 newDataAvailable 话题的订阅者
var subscriber = pubsub.subscribe('newDataAvailable', gridUpdate);
// 以下代表我们的数据层的更新。它可能是通过 ajax 请求实现的,它会广播新数据以供应用其他部分使用。
// 将变更发布到表示新条目的表格更新话题上
pubsub.publish('newDataAvailable', {
summary: 'Apple made $5 billion',
identifier: 'APPL',
stockPrice: 570.91
});
pubsub.publish('newDataAvailable', {
summary: 'Microsoft made $20 million',
identifier: 'MSFT',
stockPrice: 30.85
});
示例:使用 Ben Alman 的 Pub/Sub 方案解耦应用
在下面的电影评分示例中,我们将使用 Ben Alman 的 jQuery 实现的 发布订阅方案来演示我们可以如何来解耦用户界面。注意提交评分仅具有发布新用户和评分数据可用的事实的效果。注意提交一个评分如何只有发布新用户和新的评分数据可用的效果。
然后由这些话题的订阅者决定如何使用这些数据来表示。在我们的例子中,我们把新数据 push 到已有的数组中,然后使用 Underscore 库的 template()
方法来渲染。
HTML/Templates
<script id="userTemplate" type="text/html">
<li><%= name %></li>
</script>
<script id="ratingsTemplate" type="text/html">
<li><strong><%= title %></strong> was rated <%= rating %>/5</li>
</script>
<div id="container">
<div class="sampleForm">
<p>
<label for="twitter_handle">Twitter handle:</label>
<input type="text" id="twitter_handle" />
</p>
<p>
<label for="movie_seen">Name a movie you've seen this year:</label>
<input type="text" id="movie_seen" />
</p>
<p>
<label for="movie_rating">Rate the movie you saw:</label>
<select id="movie_rating">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5" selected>5</option>
</select>
</p>
<p>
<button id="add">Submit rating</button>
</p>
</div>
<div class="summaryTable">
<div id="users"><h3>Recent users</h3></div>
<div id="ratings"><h3>Recent movies rated</h3></div>
</div>
</div>
JavaScript
(function($) {
// 提前编译模板,然后使用闭包“缓存”它们
var userTemplate = _.template($('#userTemplate').html()),
ratingsTemplate = _.template($('#ratingsTemplate').html());
// 订阅新用户的话题,它将用户添加到已提交用户列表中
$.subscribe('/new/user', function(e, data) {
if (data) {
$('#users').append(userTemplate(data));
}
});
// 订阅新评分话题。它是由标题和评分组成。
// 新的评分被添加到一个已添加用户评分运行列表。
$.subscribe('/new/rating', function(e, data) {
if (data) {
$('#ratings').append(ratingsTemplate(data));
}
});
// 处理新增用户
$('#add').on('click', function(e) {
e.preventDefault();
var strUser = $('#twitter_handle').val(),
strMovie = $('#movie_seen').val(),
strRating = $('#movie_rating').val();
// 通知应用有新的用户可用了
$.publish('/new/user', { name: strUser });
// 通知应用新的评分可用了
$.publish('/new/rating', { title: strMovie, rating: strRating });
});
})(jQuery);
示例:解耦一个基于 Ajax 的 jQuery 应用
在我们最后一个示例中,我们将实际探讨一下使用 Pub/Sub 在程序开发早期解耦我们的代码如何使我们在随后可能的重构中减轻痛苦。
特别是在多 Ajax 的应用中,一旦我们接受到了一个请求的响应,我们希望对它做不止一个的动作。一种方案是可以简单的将所有处理请求的逻辑放在 success 回调中,但是这种方案有缺点。
强耦合的应用有时因为不断增长的内部函数/代码依赖而需要代码复用,我们会需要更多的努力。这就意味着尽管将我们的请求处理逻辑硬编码到回调中在我们只需要抓取一次结果集是可行的,但当我们想对同一个数据源进行进一步的 Ajax 调用(以及不同的结束行为)而不必多次重复的写这部分代码时,它并不合适。相较于必须返回浏览调用相同数据的每个层并在随后封装它,我们可以在开始使用 pub/sub 来节省时间。
使用观察者,我们还可以轻松地将任何事件的应用级的通知分离到我们认为合适的粒度级别,使用其他模式可能没有这么优雅。
注意我们下面的示例,当用户指出他们需要搜索和请求返回且实际数据可以可用的时候,会生成一个话题的通知。它让订阅者决定使用这些事件的信息(或者返回的数据)。这样的好处是,如果我们需要,我们可以有10个不同的订阅者使用不同的方式来使用返回的数据,至于 Ajax 层,它不关心。它唯一的职责就是请求和返回数据,然后把它传递给那些需要使用它的人。分隔责任可以让我们的代码整体设计更清晰一些。
HTML/Templates:
<form id="flickrSearch">
<input type="text" name="tag" id="query"/>
<input type="submit" name="submit" value="submit"/>
</form>
<div id="lastQuery"></div>
<ol id="searchResults"></ol>
<script id="resultTemplate" type="text/html">
<% _.each(items, function( item ){ %>
<li><img src="<%= item.media.m %>"/></li>
<% });%>
</script>
JavaScript:
(function($) {
// 提前编译模板并使用闭包“缓存”它们
var resultTemplate = _.template($('#resultTemplate').html());
// 订阅搜索标签的话题
$.subscribe('/search/tags', function(e, tags) {
$('#lastQuery').html('<p>Searched for:<strong>' + tags + '</strong></p>');
});
// 订阅结果话题
$.subscribe('/search/resultSet', function(e, results) {
$('#searchResults')
.empty()
.append(resultTemplate(results));
});
// 提交一个查询请求然后发布标签到 /search/tags 话题
$('#flickrSearch').submit(function(e) {
e.preventDefault();
var tags = $(this)
.find('#query')
.val();
if (!tags) {
return;
}
$.publish('/search/tags', [$.trim(tags)]);
});
// 订阅新发布的标签,然后使用它们执行一个查询请求。
// 一旦数据返回了,发布这个数据给应用的其他部分使用
$.subscribe('/search/tags', function(e, tags) {
$.getJSON(
'http://api.flickr.com/services/feeds/photos_public.gne?jsoncallback=?',
{
tags: tags,
tagmode: 'any',
format: 'json'
},
function(data) {
if (!data.items.length) {
return;
}
$.publish('/search/resultSet', { items: data.items });
}
);
});
})(jQuery);
观察者模式在接口一些解耦应用的场景很有用,如果你还没用过它,我推荐你使用今天前面提到的一种方案,尝试一下。它是最容易上手的一种设计模式,也是最用的设计模式之一。