iT邦幫忙

2024 iThome 鐵人賽

DAY 11
0

https://ithelp.ithome.com.tw/upload/images/20240925/20168201gUgpeVl4vh.png

今天要介紹的是 Observer 模式,這是 Gof 提出的模式之一,屬於行為型設計模式,這也是目前為止我覺得可以延伸最多應用案例的模式~

情境

在一個應用程式中,有一個物件持有某些狀態,而其他物件對這些狀態的變化感興趣,並且需要根據這些變化進行對應的處理。當物件的狀態發生變化時,需要一個方法讓所有感興趣的物件即時得知,以便它們能夠相應地更新自己的行為。

問題

在上述情境中,如何確保當物件狀態變化時,所有感興趣的物件都能夠即時更新,而不必手動逐一通知?如果每次狀態變化時都需要手動更新所有感興趣的物件,那程式碼將變得複雜且難以維護。尤其當需要增加或移除感興趣的物件時,會使程式碼耦合度過高。此外,如果某些物件未能及時更新,可能會導致應用程式的不一致性。

權衡

  • 即時性:當物件狀態改變時,需要能立即通知所有感興趣物件的方法,以確保這些物件的行為能即時更新,但過多的即時性通知可能會影響系統的效能
  • 解耦合:對物件狀態感興趣的每個物件應該能獨立於該物件而運作,物件間關係耦合度低,以便可依據需求輕鬆的增加或移除,但為了實現低耦合度,可能會增加系統的複雜度,因為要處理低耦合物件間的通訊
  • 擴展性:需要一個方便擴展、能隨時依需求動態調整的機制,以便能處理不斷增加的感興趣物件,然而當擴展的數量增加時,需要通知的數量也會增加,通常會消耗更多資源並花費更多心力管理,因此需要一個方便擴展又便於管理感興趣物件的機制
  • 一致性:系統須確保所有感興趣物件在狀態變化後都能保持一致的狀態更新,但如果要讓多個物件都能同步更新狀態時,可能會有延遲或競爭問題,導致維持一致性困難

解決方案

Observer 是這個情境與問題下的解決方式,GoF 將 Observer 模式定義為:「一個或多個觀察者對一個主體的狀態感興趣,並透過附加於主體來記錄他們對主體的興趣。當觀察者可能感興趣的主體發生變化時,會發送通知訊息去呼叫每個觀察者的更新方法。當觀察者不再對主體的狀態感興趣時,可以簡單地解除觀察。」
Observer 模式通常會有兩元素,一是主體(subject)物件,主體物件會維護依賴於它的客體(object)列表;二是客體(object),也就是對主體感興趣的物件,稱為觀察者(observer)。
整體運作方式就是當主體狀態有更改時,會以廣播的方式通知觀察者,通知內容可包含主體相關資料,而若觀察者不想收到通知,可將其從主體維護的觀察者列表移除。整體架構圖如下:
https://ithelp.ithome.com.tw/upload/images/20240925/20168201Z1Xw9c5xJL.jpg
圖 1 Observer 模式架構圖(資料來源:自行繪製)

依照以上的架構圖,我們來實作一個 Observer 模式吧! 我們需要這四個元件:

  • 主體:維護觀察者列表,可增加或移除觀察者
  • 觀察者:當主體狀態變化時,為需要通知的客體提供 update 介面
  • 具體主體(ConcreteSubject):狀態變化時向觀察者廣播通知,儲存 ConcreteObserver 的狀態
  • 具體觀察者(ConcreteObserver):儲存對 ConcreteSubject 的參照,為觀察者實作 update 介面,以確保它的狀態和主體一致

接著就來實作吧~

Step 1:建立 ObserverList class

我們先建立 ObserverList class,這是一個維護觀察者列表的 class,主體可利用此 class 來建立主體可能擁有的依賴觀察者列表。

class ObserverList {
    constructor() {
      this.observerList = [];
    }

    add(obj) {
      return this.observerList.push(obj);
    }
    
    count() {
      return this.observerList.length;
    }

    get(index) {
      if (index > -1 && index < this.observerList.length) {
        return this.observerList[index];
      }
    }

    indexOf(obj, startIndex) {
      let i = startIndex;

      while (i < this.observerList.length) {
        if (this.observerList[i] === obj) {
          return i;
        }
        i++;
      }

      return -1;
    }

    removeAt(index) {
      this.observerList.splice(index, 1);
    }
}

在這個觀察者列表中會用陣列來儲存對主體感興趣的所有物件,並且有新增和移除觀察者的方法,我們可依據需要去調整這個列表的資料。

Step 2:建立 Subject class

接著建立主體的 class,在主體 class 中我們會建立一個觀察者列表的實例,並透過主體的方法來呼叫觀察者列表的增加和移除,以及定義主體的通知(notify)方法,在需要時呼叫觀察者列表中每個觀察者的更新方法。

class Subject {
    constructor() {
      this.observers = new ObserverList();
    }

    addObserver(observer) {
      this.observers.add(observer);
    }

    removeObserver(observer) {
      this.observers.removeAt(
        this.observers.indexOf(observer, 0)
      );
    }

    notify(context) {
    // 依序呼叫觀察者列表中的每個觀察者(observer)的 update 方法
      const observerCount = this.observers.count();
      for (let i = 0; i < observerCount; i++) {
        this.observers.get(i).update(context);
      }
    }
 }

Step 3:建立 Observer class

Observer class 是用來建立觀察者實例,觀察者最重要的方法就是 update 方法,這樣當主體狀態變化要通知觀察者時,才能呼叫觀察者更新方法,這裡我們先寫一個大概架構,等等會在 ConcreteObserver 補足它。

class Observer {
    constructor() {}

    update() {
      // ...
    }
}

Step 4:根據應用情境撰寫 HTML

鋪陳這麼多抽象類別,就要來看看我們的應用情境,到底誰是我們要觀察的主體,有哪些觀察者、觀察者該如何更新?
我們以勾選框的情境作為範例,當應用程式中有多個確認事項需要使用者逐一勾選時,介面上會出現大量的勾選框。這時,如果有一個「全部勾選」的選項,使用者只需點選一次,就能勾選所有項目,而不需要逐一點選每個勾選框。在這情況中,主體是「全部勾選」的勾選框,其他個別的勾選框則是觀察者,當「全部勾選」的狀態改變時,它會通知所有觀察者(即其他勾選框)更新自己的勾選狀態。
示意圖如下,觀察者先訂閱主體:
https://ithelp.ithome.com.tw/upload/images/20240925/20168201x5SKQFhjVp.jpg
圖 2 觀察者先訂閱主體示意圖(資料來源:自行繪製)

接著,當主體「全部勾選」勾選框狀態改變時,就會通知觀察者,並執行觀察者的更新方法,也就是更新各自的勾選狀態:
https://ithelp.ithome.com.tw/upload/images/20240925/20168201gpl0CLlOjB.jpg
圖 3 主體通知觀察者示意圖(資料來源:自行繪製)

因此,我們的 HTML 需要包含一個主體勾選框,以及按鈕來新增或移除觀察者勾選框。

<button id="addNewObserver">Add New Observer Checkbox</button>
<button id="removeObservers">Remove Observers</button>
<div class='main-check-block'>
<label>
  <input id="mainCheckbox" type="checkbox" />
  全部勾選
</label>
</diV>

<div id="observersContainer"></div>

Step 5:定義 ConcreteSubject 和 ConcreteObserver

接著我們要定義 ConcreteSubjectConcreteObserver,以增加新觀察者並實作更新介面,ConcreteSubject class 內就是主體勾選框,被點擊時會呼叫 notify,而這個 notify 就是前面定義的 Subject class 的通知方法。ConcreteObserver class 內則是每一個觀察者的勾選框,它的 update 方法就是更新勾選框的 check 值。

// ConcreteSubject class
class ConcreteSubject extends Subject {
    constructor(element) {
      super();
      this.element = element;

      this.element.onclick = () => {
        this.notify(this.element.checked);
      };
    }
    }

// ConcreteObserver class
class ConcreteObserver extends Observer {
    constructor(element) {
      super();
      this.element = element;
    }

    update(value) {
      this.element.checked = value;
    }
}

Step 6:將方法綁定到 DOM 元素

最後將我們準備的這些材料們集合起來~將方法綁定到 DOM 元素,完成整個流程:

const addBtn = document.getElementById('addNewObserver');
const container = document.getElementById('observersContainer');
const removeBtn = document.getElementById('removeObservers');
const mainCheckbox = document.getElementById('mainCheckbox');

const controlCheckbox = new ConcreteSubject(mainCheckbox); // 建立主體勾選框

const addNewObserver = () => {
  const observerWrapper = document.createElement('div');
  observerWrapper.classList.add('observer');

  const check = document.createElement('input');
  check.type = 'checkbox';

  const label = document.createElement('label');
  label.textContent = `checkbox item`;
  label.prepend(check);

  observerWrapper.appendChild(label);

  const checkObserver = new ConcreteObserver(check); // 每新增一個勾選框就建立一個 Observer 觀察者
  controlCheckbox.addObserver(checkObserver); // 將觀察者加入主體的觀察者列表中

  container.appendChild(observerWrapper);
};

const removeObservers = () => {
  const checkboxes = container.getElementsByClassName('observer');
  while (checkboxes.length > 0) {
    const checkbox = checkboxes[0];
    container.removeChild(checkbox);
  }
};

addBtn.onclick = addNewObserver;
removeBtn.onclick = removeObservers;

最後完成的樣子截圖如下:
https://ithelp.ithome.com.tw/upload/images/20240925/201682010cbjkl2Jqd.jpg
圖 4 Checkbox Demo 截圖(資料來源:自行截圖)

完整的程式碼請見連結:Observer Pattern Checkbox Demo

簡單版 Observer 範例

因為勾選框的範例十分簡單明瞭,可能有人會覺得前面準備好多 class,也太大費周章了吧~以完整性來說,是可以分為四個 class(主體、觀察者、具體主體、具體觀察者)沒錯,但其實簡易版我們可以只分成兩個元素就好,就是主體與觀察者。
主體稱為 subject,但也有人稱它為 observable,也就是被觀察的對象;而觀察者就是 observers。所以經常會看到 observable、observers 這兩個詞彙,兩者是代表不同的元素哦! 接下來看看如果是簡單版的 observer 模式該如何實作,簡單版我就以 observable、observers 這兩個詞彙來稱呼主體與觀察者囉~
首先一樣需要由 observable 來儲存觀察者列表,並且要能新增和移除觀察者,要能通知觀察者,其實就是把前面我們提的 ObserverList 的方法一起寫進 observable 了。因此 observable 通常會具有下列功能和元素:

  • observers:一個儲存 observer 的陣列,代表對這主體有興趣的 observer 們,這些 observer 可能是函式也可能是物件,若是物件則該物件內會有可被執行的更新函式(update function),這樣主體狀態變化時才能呼叫這些 observer 項目的函式
  • subscribe():把 observer 加入 observers 陣列中的方法
  • unsubscribe():把 observer 從 observers 陣列中移除的方法
  • notify():一個用來通知此 observable 中所有 observers 的方法,當此方法被呼叫時,observers 陣列內的所有 observer 都會被執行。也有人稱作 dispatch() broadcast()

依照上述元素可建立 Observable class 如下:

class Observable {
  constructor() {
    this.observers = [];
  }

  subscribe(func) {
    this.observers.push(func);
    // 回傳一個取消訂閱的函式
    return () => {
      const index = this.observers.indexOf(func);
      this.observers.splice(index, 1);
    };
  }

  unsubscribe(func) {
    this.observers = this.observers.filter((observer) => observer !== func);
  }

  notify(data) {
    this.observers.forEach((observer) => observer(data));
  }
}

小提醒,在前面的勾選框範例中,觀察者(observer)是一個包含 update 函式的物件。而在我們現在的 Observable class 中,觀察者 observer 預設為直接的函式。在需要通知觀察者時,會直接執行這些函式。

接著來看如何應用,模擬一個簡單的購物車應用,當商品被加到購物車時,觀察者會收到通知並更新購物車的總金額和物品清單。

// 定義購物車更新函式,也就是 observer
function updateCartTotal(data) {
  console.log(`Cart Total: $${data.total}`);
};

function updateCartItems(data) {
  console.log(`Items in Cart: ${data.items.join(', ')}`);
};

// 建立一個購物車 subject/observable
const cartObservable = new Observable();

// 訂閱主體,同時儲存取消訂閱的函式
const unsubscribeTotal = cartObservable.subscribe(updateCartTotal);
const unsubscribeItems = cartObservable.subscribe(updateCartItems);

// 增加商品到購物車並通知觀察者
cartObservable.notify({ total: 50, items: ['Apple', 'Banana'] });

// 接著取消購物車總金額的訂閱
console.log('--- unsubscribe ---');
unsubscribeTotal();

// 此時再新增另一個商品到購物車,這次只會更新商品清單
cartObservable.notify({ total: 35, items: ['Orange'] });

// Cart Total: $50
// Items in Cart: Apple, Banana
// --- unsubscribe ---
// Items in Cart: Orange

應用案例

DOM 事件

當我們需要綁定事件到 DOM 元素上時,這過程就類似 Observer 模式:

const myButton = document.querySelector('#myButton');

function handleClick(event) {
  console.log('myButton was clicked!');
}

myButton.addEventListener('click', handleClick);

在這裡,myButton 是主體(subject/observable),而 handleClick(即點擊後要執行的 callback function)就是觀察者(observer)。addEventListener 就像是 subscribe(),它負責將觀察者新增到 myButton 主體中。
當按鈕的點擊事件發生變化時,myButton 會通知所有的觀察者(即事件監聽器),並執行這些 callback function。因此,我們可以對 myButton 多次使用 addEventListener 來增加不同的 callback function。myButton 會維護一個觀察者的列表(所有的事件監聽器),並在點擊時透過迴圈逐一執行這些 callback function,確保每個綁定的 callback 都能被觸發。

這種設計讓我們能將多個行為綁定到同一個事件上,並確保每個行為在事件發生時都能被執行。

Redux

Redux 的 Store 基本上也運用了 Observer 模式,用來通知使用該資料的元件 re-render。以下是官方文件中的一個微型範例,展示了 Redux store 的內部運作原理:

function createStore(reducer, preloadedState) {
  let state = preloadedState
  const listeners = []  

  function getState() {
    return state
  }

  function subscribe(listener) {
    listeners.push(listener)
    return function unsubscribe() {
      const index = listeners.indexOf(listener)
      listeners.splice(index, 1)
    }
  }

  function dispatch(action) {
    state = reducer(state, action)
    listeners.forEach(listener => listener())
  }

  dispatch({ type: '@@redux/INIT' })

  return { dispatch, subscribe, getState }
}

其中,listeners 陣列相當於觀察者列表,負責儲存對 store 感興趣的觀察者。subscribe 函式則用來將觀察者加到這個陣列中,並回傳一個 unsubscribe 函式,以便將觀察者從列表中移除。而 dispatch 函式相當於 notify 方法,它會遍歷 listeners 陣列並逐一呼叫這些觀察者。
因此,Redux 的 createStore 所建立的 store 其實就是一個主體(subject/observable)。當 store 的 dispatch 方法被呼叫時,它會通知所有的觀察者(listeners),讓他們知道該更新狀態了。
更多可參考官方文件 Inside a Redux Store

RxJS

RxJS 是個使用 Observer 模式的熱門程式庫,RxJS 的官方文件這樣介紹自己:「ReactiveX 將 Observer 模式與迭代器模式以及函數式程式設計與集合相結合,以滿足對事件管理序列的理想方式需求。(ReactiveX combines the Observer pattern with the Iterator pattern and functional programming with collections to fill the need for an ideal way of managing sequences of events)」
因為 RxJS 又是一門很深的學問了,這裡就先不深入太多,希望未來有機會介紹它。(我也是近期工作上才接觸到 RxJS,仍在努力學習中!)
對 RxJS 有興趣的我大推 Huli 大大的 希望是最淺顯易懂的 RxJS 教學 這篇文章,真的是讓對 RxJS 完全陌生的我學到很多!

TanStack Query (react-query)

剛好看到 react query 的相關文章,有提及 useQuery 底層邏輯也用到了 observer 的概念去實作,對 react query 運作邏輯有興趣的可以參考:[npm] react-query深入淺出 TanStack Query(一):在呼叫 useQuery 後發生了什麼事

優點

以 Observer 作為解決方案優點如下:

  • 耦合性低:降低主體與觀察者之間的耦合關係,可以隨時擴展或刪除觀察者、可隨時解耦
  • 單一責任原則:主體物件只負責呼叫通知方法,不須處理具體更新邏輯;觀察者只處理接收到的資料
  • 靈活性高:可依據需求動態增加或移除觀察者

缺點

以 Observer 作為解決方案優點如下:

  • 複雜度:隨著觀察者數量的增加,管理這些觀察者會變得更加複雜
  • 效能問題:當要通知的觀察者數量變多,或者每個觀察者的更新邏輯變得複雜時,遍歷並通知所有觀察者可能會對應用程式的效能產生負面影響
  • 內存洩漏隱憂:如果沒有適當移除不再需要的觀察者,可能導致內存洩漏,系統可能會有穩定性的風險

其他補充

感覺光是 Observer 模式和延伸的應用案例就可以寫好幾篇了呀...,除了文章提到的案例,其實還有很多情況都可使用 Observer 模式的解法,也有許多我還沒提到的案例,如果有讀者想到也很歡迎提出討論!
在《JavaScript 設計模式學習手冊 第二版》書中有提到,Observer 模式有助於應用程式中的解耦,它是最容易上手的設計模式之一、也是最強大的設計模式之一,很鼓勵不熟悉 Observer 的讀者試著了解或是嘗試實作看看~
接著還有一個與 Observer 很類似的 Publish/Subscribe 模式,我會在下一篇繼續介紹~

Reference


上一篇
[Day 10] Flyweight 模式
下一篇
[Day 12] Publish/Subscribe 模式
系列文
30天的 JavaScript 設計模式之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言