延續昨天提到的 Observer 模式,今天要介紹的是與 Observer 十分相似的延伸版,即 Publish/Subscribe 模式。GoF 一書中提到,Observer 模式也被稱作 Dependents 或 Publish-Subscribe。然而,隨著時間的推移,在現代實務中,Observer 和 Publish/Subscribe 模式之間存在一些細微差異。
1
,觀察者將直接接收到這個資料 1
,除非主體在通知之前修改了資料1
,但中介層可以將其修改為 2
,然後再傳遞給訂閱者。訂閱者不會知道原始的資料是 1
,只會接收到修改後的 2
Publish/Subscribe 模式透過事件頻道來構建系統,允許我們根據不同應用場景的需求來定義事件,使系統更具靈活性和適應性。這種模式不僅能夠傳遞發布者的資料,還可以加入客製化的參數,並且有效避免訂閱者與發布者間的直接依賴關係。
以下為比較示意圖,簡言之兩者就是差在 Publish/Subscribe 多了一個頻道層,將主體和觀察者解耦,降低依賴關係。
圖 1 Observer 和 Publish/Subscribe 差異(資料來源:自行繪製)
小補充,技術上來說,Observer 和 Publish/Subscribe 都可以在傳遞資料過程中修改要傳遞的資料,但 Observer 模式強調的是直接性和耦合性,而 Publish/Subscribe 模式則強調解耦和資料處理的靈活性。
來試著實作看看 Publish/Subscribe 吧!
先建立一個 PubSub
class,訂閱、發布和取消訂閱的方法,並儲存所有主題以及每個主題對應的訂閱者陣列。
class PubSub {
constructor() {
this.topics = {}; // 儲存主題
this.subUid = 0; // 主題的 uid
}
publish(topic, args) { // 將特定主題內容發布給所有訂閱者
if (!this.topics[topic]) {
return false;
}
const subscribers = this.topics[topic]; // 找到該主題的訂閱者陣列
let len = subscribers ? subscribers.length : 0;
while (len--) { // 用迴圈執行訂閱者的 function 並傳入發布者要傳送的 data
subscribers[len].func(topic, args);
}
return this;
}
subscribe(topic, func) {
if (!this.topics[topic]) {
this.topics[topic] = []; // 如果儲存主題區沒有該主題,就新建一個
}
const token = (++this.subUid).toString(); // 根據現有 uid 計算新的 id
this.topics[topic].push({ // 將訂閱者推進該主題的訂閱者陣列,加入新的 id
token,
func,
});
return token;
}
unsubscribe(token) { // 根據 token 值,刪除特定訂閱者
for (const topic in this.topics) { // 遍歷主題物件
if (this.topics.hasOwnProperty(topic)) { // 如果該主題存在物件內
const subscribers = this.topics[topic]; // 找出該主題訂閱者
for (let i = 0; i < subscribers.length; i++) { // 遍歷訂閱者找到符合 token 的,移除該訂閱者
if (subscribers[i].token === token) {
subscribers.splice(i, 1);
return token;
}
}
}
}
return this;
}
}
接著以 PubSub
實例來建立訂閱與發佈的邏輯:
// 定義 messageLogger,這是訂閱者收到通知後要執行的函式
const messageLogger = (topics, data) => {
console.log(`Logging: ${topics}: ${data}`)
}
// 訂閱 'inbox/newMessage' 這個主題,當收到這主題通知時就會執行 messageLogger
const subscription = pubsub.subscribe('inbox/newMessage', messageLogger);
// 發布者會發布主題,並傳送資料給訂閱者,資料格式自訂 但訂閱者要大概知道資料才知道如何在 callback 處理
pubsub.publish('inbox/newMessage', 'hello world!');
pubsub.publish('inbox/newMessage', {
sender: 'hello@google.com',
body: 'Hey again!'
});
// 可取消訂閱
// 一旦取消訂閱,之後發布該主題的事件也不會觸發 messageLogger
pubsub.unscribe(subscription)
Publish/Subscribe 在實務應用上也很常見,例如當我們要在前端介面上即時顯示股票資料的圖表和最後更新時間時,就可應用 Publish/Subscribe 模式。當股票資料改變時,對應的圖表和更新時間介面都需要同步更新。在這情境中,股票資料是發布者,而圖表和更新時間則是訂閱者。當訂閱者收到資料已更新的事件/主題時,它們就會執行相應的更新操作。
在前端應用中,我們經常需要向後端請求資料,並根據這些資料執行後續動作或渲染對應的畫面。然而,這些請求通常是非同步的,需要確保後續邏輯在請求成功後才執行。為了在成功取得資料後才執行後續邏輯,我們通常會將後續的邏輯放在請求成功的 callback 中,例如:
// 發送非同步的 API 請求
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => {
// 請求成功後執行後續邏輯
console.log('data', data);
})
.catch(error => {
console.error('請求失敗:', error);
});
這種做法會增加函式或程式碼之間的依賴性,將請求與後續的邏輯緊密耦合。高耦合的程式碼不僅難以重用,當你需要在請求成功後發送另一個 API 請求並執行後續處理時,程式碼也會變得難以維護。
如何解決此問題? 可以考慮使用 Publish/Subscribe 模式,將不同事件的通知分離。這樣可以實現關注點分離,讓 API 請求專注於請求和傳回資料,而其他使用資料的邏輯則可以獨立處理,減少依賴性,提升程式碼的可重用性和可維護性。
以下是一個使用 Publish/Subscribe 模式與 API 請求結合的範例,我用了 The Rick and Morty API 作為資料來源。使用者可以在輸入框中搜尋角色名稱,當 API 請求成功後,將顯示出對應的搜尋結果。
首先要建立 HTML 結構,其中包含一個供使用者輸入角色名稱的搜尋框、提交按鈕,以及顯示搜尋結果的區塊:
<form id="characterSearch">
<input type="text" id="query" placeholder="Enter character name" />
<input type="submit" value="Search" />
</form>
<div id="lastQuery"></div>
<ol id="searchResults"></ol>
接著,我們會撰寫 JavaScript 來實現搜尋和顯示搜尋結果的邏輯。首先,我們會使用之前提到的 PubSub class(這部分程式碼先略過),並訂閱兩個主題:
/search/characterName
:當此主題被觸發時,我們會更新 #lastQuery
區塊的文字,顯示目前正在搜尋的角色名稱。/search/resultSet
:當此主題被觸發時,我們會將搜尋結果渲染到 #searchResults
區塊中。接著要綁定表單的提交事件,當使用者提交表單時,會發布 /search/characterName
主題,並發送 API 請求。當請求成功後,會發布 /search/resultSet
主題。因為我們之前已經訂閱了這兩個主題,因此當主題被觸發時,相關的後續邏輯會自動執行。這種方法能將 API 請求與後續處理邏輯分開,降低耦合度,實現了關注點分離。
class PubSub {
// ...略
}
const pubsub = new PubSub(); // 建立 pubsub 實例
// 訂閱搜尋角色名稱的主題,並傳入觸發此主題後要執行的邏輯
pubsub.subscribe("/search/characterName", (topic, characterName) => {
document.getElementById(
"lastQuery"
).innerText = `Searched for: ${characterName}`;
});
// 訂閱搜尋資料準備好的主題,並傳入觸發此主題後要執行的邏輯
pubsub.subscribe("/search/resultSet", (topic, results) => {
const searchResults = document.getElementById("searchResults");
searchResults.innerHTML = "";
// 最多顯示 10 筆資料
const limitedResults = results.slice(0, 10);
limitedResults.forEach((character) => {
const li = document.createElement("li");
li.innerHTML = `
<h2>${character.name}</h2>
<img src="${character.image}" alt="${character.name}" />
<p>Status: ${character.status}</p>
<p>Species: ${character.species}</p>
<p>Gender: ${character.gender}</p>
`;
searchResults.appendChild(li);
});
});
// 綁定表單提交事件、提交後會發布 /search/characterName 主題,並發送 API 請求
document.getElementById("characterSearch").addEventListener("submit", (e) => {
e.preventDefault();
const characterName = document.getElementById("query").value.trim();
if (!characterName) {
return;
}
pubsub.publish("/search/characterName", characterName);
// 發送 API 請求,當請求成功後就發布 /search/resultSet 主題
fetch(`https://rickandmortyapi.com/api/character/?name=${characterName}`)
.then((response) => response.json())
.then((data) => {
if (data.results && data.results.length > 0) {
pubsub.publish("/search/resultSet", data.results);
} else {
alert("No characters found.");
}
})
.catch((error) => {
console.error("Request failed:", error);
});
});
完整程式碼連結請點此。
以 Publish/Subscribe 作為解決方案優點如下:
以 Publish/Subscribe 作為解決方案缺點如下:
Publish/Subscribe 很常(也適合)被應用在事件驅動的架構或場景中,其他應用案例如:即時事件分發、並行處理與工作流、應用程式的資料串流等...。並不限於前端應用,更多可參考 Pub/Sub Common use cases