利用發佈 / 訂閱機制來處理頁面上非同步的事件傳遞

fin
5 min readAug 24, 2021

情境

一個或多個元件 Cx 需要呼叫另一個模組 G 來執行一些命令 / 傳送事件,但不知道 G 何時會被載入。G 與 Cx 們的載入順序很隨機,導致各種發送給 G 的命令可能在 G 載入之前或之後,更有可能的是兩者都有。

比如說頁面載入時 C0 想傳送 custom page view 的事件,頁面其中一個 C1 元件載入時想傳送 widget loaded,C2 被點擊時想傳送 clicked,但每個傳送的時機點我們都無法確定 G 是否已經被載入了,只希望他最終會把事件處理掉。

這常發生在:呼叫第三方函式庫做資料紀錄的後送,或是非同步載入的不同元件之間的狀態傳遞。

這裡有個重要的假設:G 最終一定會被載入並且執行。(要怎麼做到這件事情就不再討論範圍內)

思考

必須老實說一開始當然是直接抄 GTM 的,等到實際使用後發現在許多場合都有滿多類似情境,才更深入的去思考並記錄下此篇文。所以,先給大家看看 GTM 的使用方法 以及今天想探討的原始碼

從時間線來看,我們可以大致拆解成三個階段:G 模組載入之前,載入時,與載入之後。

載入之前:先把狀態儲存下來,以便在載入後可以讀取並執行。
載入時:把上述的狀態讀入並且執行。
載入之後:直接執行。
(在這,瀏覽器的單執行緒就是個優勢,讓我們免於 concurrency 的問題)

由於元件們 Cx 不會(也不應該)知道 G 模組載入了沒,因此理想做法應該是提供統一的呼叫方式,讓元件們不管在什麼階段下都是呼叫同樣的方法去處理。這就要講到標題所述的發佈 / 訂閱機制,元件們是發佈者負責發出事件,而 G 模組則是訂閱方負責處理事件。

發佈訂閱就想到排隊,排隊就想亂貼圖(完全沒關係)

實作

第一階段:載入之前

『先把狀態儲存下來,以便在載入後可以讀取並執行』

進到實作第一階段時,第一個問題:事件要存在哪裡?這回到我們剛剛的解決方案 — 發佈 / 訂閱機制上,我們需要有一個可以發佈事件的地方,一個存放事件的佇列。最簡單的佇列自然是原生的陣列了:

<script>
dataLayer = [];
</script>

這是整個機制的初始化區塊,所以務必放在發佈者 Cx 以及訂閱者 G 的前面。

有了這個區塊,發佈者就可以發佈事件了。既然佇列是一個原生陣列,發佈事件自然就是把事件放入陣列中,從原生陣列的方法來看,我們有 push unshift concat 三種選擇。要用哪種來實作其實都可以,GTM 用了 push,那這裡就以 push 來做範例

# 在 Cx 內
window.dataLayer.push({things: 'about', web: 'dev'})

前面初始化的 dataLayer 放在 window 環境就是為了可以讓任何需要用到的元件都可以直接存取。像這種需要全域運作存取的機制可以放在 window,但一般元件內的機制就留在元件內。

到這第一階段就結束了,看起來很普通對吧?這是此機制的精妙所在:透過設計好的發佈 / 訂閱機制以及相關的強化(第二階段),讓發佈者要發佈事件變得很簡單。

第二階段:載入中

不囉唆,直接附上 G 模組原始碼

// Add listener for future state changes.
const oldPush = this.dataLayer_.push;
const that = this;
this.dataLayer_.push = function() {
const states = [].slice.call(arguments, 0);
const result = oldPush.apply(that.dataLayer_, states);
that.processStates_(states);
return result;
};

看得懂的人恭喜你已經了解整個機制了,不理解的人就往下看讓我來解釋解釋。

第一階段建立佇列的目的是為了可以讓訂閱者讀取並處理,因此,當訂閱者載入時,勢必要對陣列做讀取並執行(對應到上圖中的 this.processStates(states) ),這是整個機制的終點。

發佈者丟到佇列,然後訂閱者執行,看起來很順,世界很美好。但這裡還沒處理第三階段,訂閱者載入之後的事件發佈情形。如果佇列仍然是原生的陣列,訂閱者在讀取陣列後,要如何知道後續的事件呢?一種方式是你可以定時地回來檢視這個陣列,並且找出是否有新增的,但這種做法遇到需要比較即時的反應時勢必需要減低檢視間隔,若多數時間沒有新增事件的話等同於造成大量的效能浪費。

那我們來看看上述的 G 模組原始碼是怎麼回事,這裡我們看到的是一個 class,有個 dataLayer_ 的變數,且我們替換了他的 push function 變成了一個加工後的版本 。新的版本其實也只有多做一件事情,呼叫 this.processStates(states) ,也就是把事件傳遞給訂閱者的意思。

至此其實就完成了,是不是超級簡單?整個機制有幾個精妙的地方

  1. 使用陣列極簡化初始的方法
  2. 發佈者不用管載入狀況, push 就對了
  3. 陣列儲存著訂閱者載入前的資訊
  4. 載入時替換 push 讓訂閱者可以即時獲得新的事件資訊

第三階段:載入後

沒啥好說的,跟第一階段一樣有事件想 push 就 push。做完了。

# 在 Cx 內
window.dataLayer.push({things: 'about', web: 'dev'})

打完收工

嗯其實就這樣,機制不難,難在遇到這種情境的時候有辦法辨識並且應用這個機制。前端存在著非常多的非同步載入與執行,這樣的情境還滿常出現的。

另外補充說明幾點:

這不是純的發佈/訂閱機制

其實整個範例的佇列的操控者也等同於訂閱者,且訂閱者只有一個,這與一般在講的發佈/訂閱機制不太一樣,算是非常特化,這樣的佇列也沒辦法與其他模組共用。

為什麼不用 addEventListener?

addEventListner 需要有個通知的對象,但一開始訂閱者根本不在因此無法通知

訂閱者如何處理資訊?

機制都做好了,接下來,就是你們的事了(菸)

--

--