js設計模式之發佈-訂閱模式

發佈-訂閱模式又叫觀察者模式,它用來定義一種一對多的依賴關係。當某個對象發生改變的時候,全部依賴於它的對象都將獲得通知。在js中,一般用事件模型來代替傳統的發佈訂閱模式(由於js沒有類,能夠直接傳遞函數)。javascript

1.現實世界中的發佈訂閱模式

小a最近看上了一套房子,到了售樓處被告知房子賣完了。售樓MM告訴小明,之後還會推出新的樓盤,但不知道何時推出。因而小明記下了售樓MM的電話,天天打電話去問售樓MM新樓盤的狀況。java

一樣操做的還有小b、小c等。編程

這種狀況在現實中固然不會出現,現實中更可能的是小a、小b、小c留下本身的電話給售樓MM,一旦新的樓盤出來了,售樓MM就會一個一個打電話去通知小a、小b、小c。設計模式

在這個情景中,小a、小b、小c就是訂閱者,售樓MM就是發佈者。緩存

在異步編程中短輪詢和長輪詢就對應上述的兩種狀況。安全

2.發佈訂閱模式的做用

在上面這個例子中,發佈訂閱模式有着明顯的優勢。架構

  • 購房者不須要天天打電話給售樓MM詢問新房開售時間,在合適的時間,售樓MM會通知這些消息訂閱者。
  • 購房者和售樓處再也不緊密耦合在一塊兒。當有新的購房者出現時,他只須要把電話號碼留在售樓處,售樓處也不會關心購房者的任何狀況。之後不管購買者仍是售樓處發生了什麼事情,都不會影響這個過程。只要售樓處記得在合適的時間通知購房者。

第一點普遍用於異步編程中,這是一種代替傳統回調函數的手段。好比咱們監聽異步請求的success和error事件。當事件來臨的時候,發佈一個狀態,那麼對此感興趣的訂閱者就會收到這個狀態並執行相關操做。app

第二點在程序方面帶來的好處是能夠改變對象之間的硬編碼的通知機制。一個對象再也不顯式地去調用另一個對象的某個接口。發佈訂閱模式將兩個對象鬆耦合地聯繫在一塊兒,雖然不清除彼此細節,但並不影響彼此通訊。不管發佈者仍是訂閱者發生了變化,只要它們之間的約定沒有變,就沒有關係。異步

3.常見的發佈訂閱模式--DOM事件

window.addEventListener就是一個典型的例子。異步編程

document.body.addEventListener('click', fn1);
document.body.addEventListener('click', fn2);
document.body.addEventListener('click', fn3);
複製代碼

用戶可能會點擊頁面,但不知道何時點擊。因此咱們訂閱body的click事件,當body被點擊的時候,body節點便會向訂閱者發佈這個消息。

固然咱們還能夠隨意移除訂閱者,經過removeEventListener事件。

4.自定義事件

除了內置的DOM事件,咱們還會常常實現一些自定義的事件,這種依靠自定義事件完成的發佈-訂閱模式能夠用於任何js的代碼中。如今來實現一個簡單的發佈訂閱模式。

  1. 首先須要一個發佈者對象
  2. 發佈者須要維護一個緩存列表,用於存放訂閱者的訂閱函數。
  3. 訂閱者能夠往事件列表添加一個事件,表示訂閱。
  4. 發佈消息的時候,遍歷事件列表,去執行全部事件。
const publish = {}; // 發佈者
publish.clientList = []; // 事件列表

 // 訂閱者往事件列表添加事件
publish.listen = function(fn) {
  this.clientList.push(fn);
}
// 發佈者發佈事件
publish.trigger = function(...args) {
  this.clientList.forEach(event => {
    event.apply(publish, args);
  })
}

publish.listen((area, price) => {
  console.log(area, price); // 60 120
});
publish.trigger('60', '120');
複製代碼

這是最簡單的發佈訂閱模式了。可是它存在一個問題,它沒有區分訂閱類型。好比小a只須要訂閱一個60平米的房子,而小b須要訂閱一個80平米的房子。可是在上述代碼中,並無區分,接下來改寫一下。

const publish = {}; // 發佈者
publish.clientList = {}; // 事件列表
publish.listen = function(type, fn) {
  // 訂閱者往事件列表添加事件
  if (!this.clientList[type]) {
    this.clientList[type] = [];
  }
  this.clientList[type].push(fn);
};
publish.trigger = function(type, ...args) {
  const fns = this.clientList[type];
  if (!fns || fns.length === 0) {
    return false;
  }
  fns.forEach(fn => {
    fn.apply(publish, args);
  });
};

publish.listen('60', price => {
  console.log(price); // 120
});
publish.trigger('60', '120');
複製代碼

如今能夠對訂閱的事件類型加以區分了。

5.發佈訂閱模式的通用實現

如今存在一個問題,若是小a如今要去另外一個售樓處買房子,另一個售樓處還有一些其它的行爲,那麼對於上面的代碼又要重複寫一遍,這是沒有必需要的,因此對上面的代碼能夠提取一個共同實現,而且增長取消訂閱的功能。

const event = {
  clientList: {},
  listen: function(type, fn) {},
  trigger: function(type, ...args) {},
  remove: function(type, fn) {
    const fns = this.clientList[type];
    // 沒有該類型的事件
    if (!fns || fns.length === 0) {
      return false;
    }
    // 若是不傳入具體的事件,表示取消該類型的全部事件
    if (!fn) {
      fns.length = 0;
    } else {
      for (let i = 0, len = fns.length; i < len; i++) {
        let _fn = fns[i];
        if (_fn === fn) {
          fns.splice(i, 1);
          break;
        }
      }
    }
  }
};

const installEvent = obj => {
  Object.assign(obj, event);
};

const publish = {};
installEvent(publish);
複製代碼

6.全局的Event對象

上面的代碼中,可能會存在多個發佈者。若是小a還要訂閱300平米的房子,可是這個房子只有售樓處2纔有賣,那麼咱們還須要再建立一個publish2對象。

  • 每一個發佈者對象的建立都須要資源,這是沒有必要的。
  • 小a和售樓處還存在必定的耦合,至少小a要知道是哪一個售樓處。
  • 代碼中發佈者對象能夠直接操做clientList對象,這不是很安全。

因此換個思路,買房不必定必定要去售樓處,咱們能夠委託中介。中介代替小a訂閱消息,中介代替售樓處發佈消息,中介不能直接操做客戶對象列表。那麼在程序中,發佈訂閱模式能夠用一個全局的Event對象來實現,它表示中介。其實就是一個單例。爲了避免能讓Event直接操做clientList,確定須要經過IIFE來實現。

const Event = (() => {
  const clientList = {};
  const listen = (key, fn) => {
    if (!clientList[key]) {
      clientList[key] = [];
    }
    clientList[key].push(fn);
  };

  const trigger = (type, ...args) => {
    const fns = clientList[type];
    if (!fns || fns.length === 0) {
      return false;
    }
    fns.forEach(fn => {
      fn.apply(this, args);
    });
  };

  const remove = (type, fn) => {
    const fns = clientList[type];
    // 沒有該類型的事件
    if (!fns || fns.length === 0) {
      return false;
    }
    // 若是不傳入具體的事件,表示取消該類型的全部事件
    if (!fn) {
      fns.length = 0;
    } else {
      for (let i = 0, len = fns.length; i < len; i++) {
        let _fn = fns[i];
        if (_fn === fn) {
          fns.splice(i, 1);
          break;
        }
      }
    }
  };

  return {
    listen,
    trigger,
    remove
  };
})();

Event.listen('xiaoming-60', price => {
  console.log('小a', price);
});

Event.listen('xiaohong-80', price => {
  console.log('小b', price);
});

Event.trigger('xiaoming-60', 120);
Event.remove('xiaohong-80');
Event.remove('xiaoming-60');
Event.trigger('xiaohong-80', 160);
Event.trigger('xiaoming-60', 140);
複製代碼

若是全局都統一使用一個Event對象的話,可能隨着應用的增大,Event對象的clientList會愈來愈龐大。這時候須要提供命令空間功能。

7.必須先訂閱後發佈嗎

上面的例子中,都是先訂閱後發佈的。若是先發布後訂閱,那麼會致使訂閱者收不到發佈者的消息。

在某些狀況下,咱們須要將發佈的消息保存下來,當有訂閱者來訂閱的時候,再從新把消息發送給訂閱者。當售樓處發給小a消息的時候,若是小a的手機關機,那麼在小a開機後應該仍然能收到這條消息,而不是這條消息消失了。

爲了知足這個場景,咱們須要建立一個存放離線消息的堆棧,當事件發佈的時候,若是此時尚未訂閱者來訂閱這個事件,那麼就將發佈時間的動做包裹在一個函數中,這個函數將會被存入堆棧中。等到有訂閱者來訂閱這個事件的時候,就遍歷堆棧而且依次執行這些包裝函數,也就是從新發布里面的事件。固然離線事件的生命週期只有一次,一旦訂閱者收到事件以後,這些事件就不能再發布了。

8.全局事件的命名衝突

全局的發佈訂閱模式中只有一個clientList來保存消息名和回調函數,你們都經過它來訂閱和發佈各類消息,長此以往,不免會出現事件名衝突的狀況,因此咱們還能夠給Event對象提供建立命名空間的功能。

七、8點的兩個功能以下:

// 先發布,後訂閱
Event.trigger('click', 1);
Event.listener('click', a => {
  console.log(a); // 1
})

// 使用命名空間
Event.create('namespace1').listen('click', a => {
  console.log(a); // 1
})

Event.create('namespace1').trigger('click', 1);

Event.create('namespace2').listen('click', a => {
  console.log(a); // 2
})

Event.create('namespace2').trigger('click', 2);

// 下面是完整代碼實現
const Event = (() => {
  let Event;
  let _default = 'default';

  Event = (() => {
    const namespaceCache = {};
    const each = (ary, fn) => {
      let ret;
      for (let i = 0, l = ary.length; i < l; i++) {
        let n = ary[i];
        ret = fn.call(n, i, n);
      }
      return ret;
    };

    const _listen = (key, fn, cache) => {
      if (!cache[key]) {
        cache[key] = [];
      }

      cache[key].push(fn);
    };

    const _remove = (key, cache, fn) => {
      if (cache[key]) {
        if (fn) {
          for (let i = 0, _fn, len = cache[key].length; i < len; i++) {
            _fn = cache[key][i];
            if (_fn === fn) {
              cache[key].splice(i, 1);
              break;
            }
          }
        } else {
          cache[key] = [];
        }
      }
    };

    const _trigger = (cache, key, ...args) => {
      const stack = cache[key];
      const _self = this;
      if (!stack || stack.length === 0) {
        return;
      }

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

    const _create = (namespace = _default) => {
      const cache = {};
      let offlineStack = [];
      const ret = {
        listen(key, fn, last) {
          _listen(key, fn, cache);
          if (offlineStack === null) {
            return;
          }
          if (last === 'last') {
            offlineStack.length && offlineStack.pop()();
          } else {
            each(offlineStack, function(...args) {
              console.log(this === args[1]); // true
              this();
            });
          }
          offlineStack = null;
        },
        one(key, fn, last) {
          _remove(key, cache);
          this.listen(key, fn, last);
        },
        remove(key, fn) {
          _remove(key, cache, fn);
        },
        trigger(...args) {
          args.unshift(cache);
          let fn;
          const _self = this;
          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) {
        const event = this.create();
        event.one(key, fn, last);
      },
      remove: function(key, fn) {
        const event = this.create();
        event.remove(key, fn);
      },
      listen: function(key, fn, last) {
        var event = this.create();
        event.listen(key, fn, last);
      },
      trigger: function(...args) {
        const event = this.create();
        event.trigger.apply(this, args);
      }
    };
  })();

  return Event;
})();

複製代碼

9.JS實現發佈訂閱模式的便利性

因爲js沒有類的概念,因此js中的發佈訂閱模式和Java中的實現仍是有區別的。在Java中實現一個本身的發佈訂閱模式,一般會把訂閱者對象當作引用傳入發佈者對象中,同時訂閱者對象還需喲提供一個名爲諸如update的方法,供發佈者對象在合適的時機調動。發佈者對象的clientList保存的是訂閱者對象,而不是js中的函數。若是要移除訂閱者,就從clientList中直接移除掉訂閱者。在js中,咱們經過回調函數的形式來代替傳統的發佈訂閱模式,更加優雅和簡單。

10.小結

發佈訂閱者模式在實際開發中很是有用。

發佈訂閱的優勢很是明顯,一是時間上的解耦,而是對象間的解耦。

  • 時間上的解耦: 在異步編程中,因爲沒法肯定異步加載的時間,有可能訂閱事件的模塊尚未初始化完畢而異步加載就完成了,發佈者就已經發布事件了。經過發佈訂閱模式,能夠將發佈者的事件提早保存起來,等到發佈者加載完畢再執行。
  • 對象間的解耦:發佈訂閱模式中,發佈者和訂閱者能夠沒必要知道對方的存在,而是經過中介對象來通訊。

發佈訂閱模式還能夠用來幫助實現一些別的設計模式,好比中介者模式。從架構上看,不管是MVC仍是MVVM,都少不了發佈訂閱模式的參與,並且js語言自己也是一門基於事件驅動的語言。

固然,發佈訂閱模式也不是沒有缺點。

  • 建立訂閱者自己須要必定的時間和內存,而當你訂閱一個消息後,也許此消息最後都未發生,但這個訂閱者會始終存在於內存中。
  • 另外,發佈訂閱模式將對象間徹底解耦,若是過分使用的話,對象和對象之間的必要聯繫就會被掩蓋,會致使程序難以追蹤和理解。
相關文章
相關標籤/搜索