JavaScript 設計模式之觀察者模式與釋出訂閱模式

AJie發表於2019-04-28

前言

在軟體工程中,設計模式(design pattern)是對軟體設計中普遍存在(反覆出現)的各種問題,所提出的解決方案。

設計模式並不直接用來完成程式碼的編寫,而是描述在各種不同情況下,要怎麼解決問題的一種方案。

設計模式能使不穩定轉為相對穩定、具體轉為相對抽象,避免會引起麻煩的緊耦合,以增強軟體設計面對並適應變化的能力

——維基百科

設計模式是一種軟體開發的思想,有益於降低程式碼的耦合性,增強程式碼的健壯性。往往在大型專案中用的比較多。

今天就來介紹一下觀察者模式與釋出訂閱模式。這在解耦中非常實用。

什麼是觀察者模式?

先舉一個簡單的例子

畢業前,很多同學都會說類似於這樣的話:

“老王,等你結婚了,記得叫我來喝喜酒!”

於是有一天你真的要結婚了,且需要舉辦酒席,這時候你需要通知你的你的那些老友來喝喜酒。於是你拿起了手機給你的那些分佈於世界各地的好朋友打起了電話,說了結婚酒席一事。

到了酒席那天,有的朋友來了,有的人沒來禮卻到了,有的呢只有簡短的兩句祝福,剩下的只有推脫。

這就是觀察者模式

JavaScript 設計模式之觀察者模式與釋出訂閱模式

在觀察者模式中,目標與觀察者相互獨立,又相互聯絡:

  • 兩者都是相互獨立的對物件個體。
  • 觀察者在目標物件中訂閱事件,目標廣播發布事件。

就像之前的例子一樣:

  • 老王就是模式中所謂的目標。
  • 同學們在畢業前說的話就相當於在目標物件上訂閱事件。
  • 老王打電話通知朋友就是釋出事件。
  • 同學們各自作出了不同的行動迴應。

這麼說我們的程式碼就慢慢建立起來了。

首先我們需要定義兩個物件:

  1. 目標物件:Subject
  2. 觀察者物件:Observer

並且在目標物件中要存放觀察者物件的引用,就像老王要存放同學的手機好一樣,只有存了才能聯絡嘛。於是我們有了下面的程式碼:

function Subject() {
  this.observers = new ObserverList();
}
function ObserverList() {
  this.observerList = [];
}
function Observer() {}
複製程式碼

對於目標物件中的引用,我們必須可以動態的控制:

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

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(context)方法來作出改變,同時該方法不應該寫在原型鏈上,因為每一個例項化後的 Observer 物件所做的響應都是不同的,需要獨立儲存 update(context)方法:

function Observer() {
  this.update = function() {
    // ...
  };
}
複製程式碼

到此我們就完成了一個簡單的觀察者模式的構建。

完整程式碼:

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

// The Observer
function Observer() {
  this.update = function() {
    // ...
  };
}
複製程式碼

什麼是釋出訂閱模式?

先舉個簡單的例子:

我們生活中,特別是在一線城市打拼的年輕人,與租房的聯絡再密切不過了。同時我們的身邊也有很多租房中介。

某天路人甲需要租一套三室一廳一廚一衛的房,他找到了中介問了問有沒有。中介看了看發現並沒有他要的房型,於是和路人甲說:“等有房東提供了此類房型的時候再聯絡你。”於是你就回去等訊息了。

有一天,某一位房東將自己多餘的房屋資訊以及圖片整理好發給中介,中介看了看,這不就是路人甲要的房型嗎。於是立馬打電話讓路人甲看房。最終撮合了一單生意。

這就是釋出訂閱模式

JavaScript 設計模式之觀察者模式與釋出訂閱模式

可以看出,在釋出訂閱模式中最重要的是 Topic/Event Channel (Event)物件。我們可以簡單的稱之為“中介”。

在這個中介物件中既要接受釋出者所釋出的訊息,又要將訊息派發給訂閱者。所以中介還應該按照不同的事件儲存相應的訂閱者資訊。

首先我們先會給中介物件的每個訂閱者物件一個標識,每當有一個新的訂閱者訂閱事件的時候,我們就給一個 subUid。

我們先來寫一下中介物件(pubsub):

var pubsub = {};
(function(myObject) {
  var topics = {};
  var subUid = -1;

  myObject.publish = function() {};

  myObject.subscribe = function() {};

  myObject.unsubscribe = function() {};
})(pubsub);
複製程式碼

這裡我們用了工廠模式來建立我們的中介物件。

我們先把訂閱功能實現:

首先我們必須認識到 topics 物件將存放著如下型別的資料:

topics = {
  topicA: [
    {
      token: subuid,
      function: func
    },
  	...
  ],
  topicB: [
    {
      token: subuid,
      function: func
    },
  	...
  ],
  ...
}
複製程式碼

對於 topics 物件,存放在許多不同的事件名稱(topicA...),對於每一個事件都有指定的一個陣列物件用以存放訂閱該事件的訂閱物件及發生事件之後作出的響應。

所以當有訂閱物件在中介中訂閱事件時:

myObject.subscribe = function(topic, func) {
  //如果不存在相應事件就建立一個
  if (!topics[topic]) {
    topics[topic] = [];
  }
  //將訂閱物件資訊記錄下來
  var token = (++subUid).toString();
  topics[topic].push({
    token: token,
    func: func
  });
  //返回訂閱者標識,方標在取消訂閱的時候使用
  return token;
};
複製程式碼

接下來我們來實現取消訂閱的功能:

我們只需要遍歷 topics 各個事件中的物件即可。

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;
};
複製程式碼

剩下的就是釋出事件的實現了:

我們只需要給定事件名稱 topic 和相應的引數即可,找到相應事件所對應的訂閱者列表,遍歷呼叫列表中的方法。

myObject.publish = function(topic, args) {
  if (!topics[topic]) {
    return false;
  }
  var subscribers = topics[topic],
    len = subscribers ? subscribers.length : 0;
  while (len--) {
    subscribers[len].func(args);
  }
  return this;
};
複製程式碼

至此,我們的中介物件就完成了。在釋出訂閱模式中我們不必在意釋出者和訂閱者。

完整程式碼:

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

  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);
複製程式碼

二者的區別和聯絡

區別:

  1. 觀察者模式中需要觀察者物件自己定義事件發生時的相應方法。
  2. 釋出訂閱模式者在釋出物件和訂閱物件之中加了一箇中介物件。我們不需要在乎釋出者物件和訂閱者物件的內部是什麼,具體響應時間細節全部由中介物件實現。

聯絡:

  1. 二者都降低了程式碼的耦合性。
  2. 都具有訊息傳遞的機制,以資料為中心的設計思想。

實戰

這裡需要一點模板引擎的知識,關於模板引擎可以看我之前發的一篇文章:《手擼 JavaScript 模板引擎》

假如我們有如下模板需要渲染:

var template = `<span><% this.value %></span>`;
複製程式碼

該模板依賴的資料來源如下:

var data = {
  value: 0
};
複製程式碼

現假若 data 中的 value 時動態的,每隔一秒加 1。

setInterval(function() {
  data.value++;
}, 1000);
複製程式碼

同時我們也要在頁面上發生變化,這時你可能寫出如下程式碼:

setInterval(function() {
  data.value++;
  document.body.innerHTML = TemplateEngine(template, data);
}, 1000);
複製程式碼

我們可以對比一下發布訂閱模式的實現:

var template = `<span><% this.value %></span>`;
var data = {
  value: 0
};
function render() {
  document.body.innerHTML = TemplateEngine(template, data);
}
window.onload = function() {
  render();
  pubsub.subscribe("change", render);
  setInterval(function() {
    data.value++;
    pubsub.publish("change");
  }, 1000);
};
複製程式碼

前者似乎看起來很簡單明瞭,但是:

  1. 不同功能緊密耦合,如果以後要修改該功能,很可能牽一髮而動全身。
  2. 往往實際開發中我們的訂閱者不止一個,釋出者的訊息也不止一個,遠遠比這個例子的邏輯複雜的多。剪不斷,理還亂。

相比之下,釋出訂閱模式就顯得邏輯清晰,已於維護,值得細細體味。

值得一提:事件監聽的實現

事件監聽是我們經常用到的功能,其實它的實現就是源自於釋出訂閱模式,不信你看:

subject.addEventListener("click", () => {
  //...
});
複製程式碼

這就是在訂閱一個事件的呼叫。

其實觀察者模式與釋出訂閱模式與我們息息相關!?

-EFO-


筆者專門在 github 上建立了一個倉庫,用於記錄平時學習全棧開發中的技巧、難點、易錯點,歡迎大家點選下方連結瀏覽。如果覺得還不錯,就請給個小星星吧!?


2019/04/28

AJie

相關文章