观察者设计模式

观察者设计模式是一个好的设计模式,这个模式我们在开发中比较常见,尤其是它的变形模式订阅/发布者模式我们更是很熟悉,在我们所熟悉jQuery库和vue.js框架中我们都有体现。我在面试中也曾经被问到observer和它的变形模式publish/subscribe,说实话,当时有点懵。随着工作经历渐多,也认识到它的重要性,特别是当你想要朝着中高级工程师进阶时这个东西更是绕不过。

定义

观察者设计模式中有一个对象(被称为subject)根据观察者(observer)维护一个对象列表,自动通知它们对状态的任何修改。

当一个subject要通知观察者一些有趣的事情时,它会向观察者发送通知(它可以包含通知主题相关的特定数据)

当我们不在希望某一特定的观察员被通知它们所登记的主题变化时,这个主题可以将他们从观察员名单上删除。

为了从整体上了解设计模式的用法和优势,回顾已发布的设计模式是非常有用的,这些设计模式的定义与语言无关。在GoF这本书中,观察者设计模式是这样定义的:

“一个或多个观察者对某一subject的状态感兴趣,并通过附加它们自己来注册它们对该主题的兴趣。当观察者可能感兴趣的主题发生变化时,会发送一个通知信息,该通知将调用们个观察者中的更新方法。当观察者不再对主题的状态感兴趣时,他们可以简单地分离自己。”

组成

扩展我们所学,以组件形式实现observer模式:

主题(subject):维护一个观察者列表,方便添加或删除观察者

观察者(observer):为需要通知对象更改状态的对象提供一个更新接口

实际主题(ConcreteSubject):向观察者发送关于状态变化的通知,存储实际观察者的状态

实际观察者(ConcreteObserver):存储引用到的实际主题,为观察者实现一个更新接口,以确保状态与主题的一致。

实现

1.对一个subject可能拥有的观察者列表进行建模:

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 );
};

2.对subject进行建模,并在观察者列表中补充添加、删除、通知观察者的方法

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 );
  }
};

3.为创建一个新的观察者定义一个框架。框架中的update功能将被稍后的自定义行为覆盖

// The Observer
function Observer(){
  this.update = function(){
    // ...
  };
}

示例

使用上面定义的观察者组件,我们做一个demo,定义如下:

  • 在页面中添加新的可观察复选框的按钮;
  • 一个控制复选框将作为一个subject,通知其它的复选框,它们应该被检查;
  • 正在被添加的复选框容器
  • 然后,我们定义实际的主题和实际的观察者处理句柄,以便为页面添加新的观察者并实现更新接口。

实例代码如下:

html

<button id="addNewObserver">Add New Observer checkbox</button>
<input id="mainCheckbox" type="checkbox"/>
<div id="observersContainer"></div>

js

// 用extend()扩展一个对象
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" );
 
// 实际主题 (Concrete Subject)
// 将控制 checkbox 扩展到 Subject class
extend( controlCheckbox, new Subject() );
 
// 单击checkbox 通知将发送到它的观察者
controlCheckbox.onclick = function(){
  controlCheckbox.notify( controlCheckbox.checked );
};
 
addBtn.onclick = addNewObserver;
 
// 实际观察者(Concrete Observer)
function addNewObserver(){
 
  // 新创建的checkbox被添加
  var check = document.createElement( "input" );
  check.type = "checkbox";
 
  // 扩展 checkbox 用 Observer class
  extend( check, new Observer() );
 
  // 用自定义的 update 行为覆盖默认的
  check.update = function( value ){
    this.checked = value;
  };
 
  // 添加新的 observer 到 observers 列表中
  // 为我们的 main subject
  controlCheckbox.addObserver( check );
 
  // Append the item to the container
  container.appendChild( check );
}

在这个示例中我们研究了如何实现和使用观察者模式,涵盖了主题(subject), 观察者(observer),实际/具体对象(ConcreteSubject),实际/具体观察者(ConcreteObserver)

效果演示:demo

观察者和发布者订阅模式之间的差异

虽然,观察者模式很有用,但是在JavaScript中我们经常会用一种被称为发布/订阅模式这种变体的观察者模式。虽然它们很相似,但是这些模式之间还是有区别的。

观察者模式要求希望接受主题通知的观察者(或对象)必须订阅该对象触发事件的对象(主题)

然而,发布/订阅模式使用一个主题/事件通道,该通道位于希望接受通知(订阅者)和触发事件(发布者)的对象之间。此事件系统允许代码定义特定用于应用程序的事件,这些事件可以通过自定义参数来传递订阅者所需的值。这样的思路是为了避免订阅者和发布者的依赖关系。

与观察者模式不同,它允许任何订阅者实现一个适当的事件处理程序来注册并接收发布者发布的主题通知。

下面一个例子提供了功能实现,使用发布/订阅模式,可以支持在幕后的publish(),subscribe(),unsubscribe()

// 一个简单的邮件处理程序
// 接收邮件数
var mailCounter = 0;
 
// 初始化监听主题的名为 "inbox/newMessage" 的订阅者.
 
// 呈现一个新消息的预览
var subscriber1 = subscribe( "inbox/newMessage", function( topic, data ) {
 
  // 为了调试目的打印 topic
  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生态系统中很适用,这在很大程度上是因为在核心的ECMAScript实现是事件驱动的。在浏览器环境中尤其如此,因为DOM将事件作为脚本的主要交互API。

也就是说,ECMAScript和DOM都不提供在实现代码中创建自定义事件系统的核心对象或方法(可能只有DOM3 CustomEvent,它是绑定到DOM的,不是通用)。

幸运的是,流行的JavaScript库,如dojo、jQuery(自定义事件)和YUI已经有了一些实用工具,它们可以帮助轻松实现发布/订阅系统。下面我们可以看到一些例子:

var pubsub = {};

(function(myObject) {
 
    // Storage for topics that can be broadcast
    // or listened to
    var topics = {};
 
    // A topic identifier
    var subUid = -1;
 
    // Publish or broadcast events of interest
    // with a specific topic name and arguments
    // such as the data to pass along
    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;
    };
 
    // Subscribe to events of interest
    // with a specific topic name and a
    // callback function, to be executed
    // when the topic/event is observed
    myObject.subscribe = function( topic, func ) {
 
        if (!topics[topic]) {
            topics[topic] = [];
        }
 
        var token = ( ++subUid ).toString();
        topics[topic].push({
            token: token,
            func: func
        });
        return token;
    };
 
    // Unsubscribe from a specific
    // topic, based on a tokenized reference
    // to the subscription
    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 ));

简单实现如下:

// Return the current local time to be used in our UI later
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);
};
 
// Add a new row of data to our fictional grid component
function addGridRow( data ) {
 
   // ui.grid.addRow( data );
   console.log( "updated grid component with:" + data );
 
}
 
// Update our fictional grid to show the time it was last
// updated
function updateCounter( data ) {
 
   // ui.grid.updateLastChanged( getCurrentTime() );
   console.log( "data last updated at: " + getCurrentTime() + " with " + data);
 
}
 
// Update the grid using the data passed to our subscribers
gridUpdate = function( topic, data ){
 
  if ( data !== undefined ) {
     addGridRow( data );
     updateCounter( data );
   }
 
};
 
// Create a subscription to the newDataAvailable topic
var subscriber = pubsub.subscribe( "newDataAvailable", gridUpdate );
 
// The following represents updates to our data layer. This could be
// powered by ajax requests which broadcast that new data is available
// to the rest of the application.
 
// Publish changes to the gridUpdated topic representing new entries
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
});

用户接口通知

接下来我们假设有一个web应用程序负责显示实时股票信息。

应用程序可能有一个网格用于显示股票统计数据和显示最新更新点的计数器。当数据模型发生变化时,应用程序将需要更新网格和计数器。在这个场景中,我们的主题(将发布主题/通知)是数据模型,我们的订阅者是网格和计数器。

当我们的订阅者收到通知时,模型本身已经更改,他们可以相应地更新自己。

在我们的实现中,我们的订阅用户将收主题“newDataAvailable”,以了解是否有新的股票信息可用。如果一个新的通知发布到这个主题,它将触发gridUpdate向包含该信息的网格添加一个新的行。它还将更新上一次更新的计数器,以记录上一次添加的数据

// Return the current local time to be used in our UI later
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);
};
 
// Add a new row of data to our fictional grid component
function addGridRow( data ) {
 
   // ui.grid.addRow( data );
   console.log( "updated grid component with:" + data );
 
}
 
// Update our fictional grid to show the time it was last
// updated
function updateCounter( data ) {
 
   // ui.grid.updateLastChanged( getCurrentTime() );
   console.log( "data last updated at: " + getCurrentTime() + " with " + data);
 
}
 
// Update the grid using the data passed to our subscribers
gridUpdate = function( topic, data ){
 
  if ( data !== undefined ) {
     addGridRow( data );
     updateCounter( data );
   }
 
};

// Create a subscription to the newDataAvailable topic
var subscriber = pubsub.subscribe( "newDataAvailable", gridUpdate );
 
// The following represents updates to our data layer. This could be
// powered by ajax requests which broadcast that new data is available
// to the rest of the application.
 
// Publish changes to the gridUpdated topic representing new entries
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
});

其它设计模式相关文章请转‘大处着眼,小处着手’——设计模式系列

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容