工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    前端廣告系統設計:使用 Event-Driven Architecture 實現廣告位互斥管理

    Authors
    • avatar
      Name
      Alex Yu

    最近在專案中實作了一個廣告位管理系統,需求是:當高優先級廣告(例如蓋版廣告)顯示時,低優先級廣告(例如頂部固定廣告)必須隱藏,避免畫面被過多廣告佔據。

    這個需求看似簡單,但實際上有幾個技術挑戰:

    1. 兩個廣告位是獨立載入的:各自透過不同的 SDK 請求廣告,載入時間不可預測
    2. 填充結果不確定:廣告可能有填充,也可能沒有(取決於競價、頻次控制等因素)
    3. 無法直接控制 SDK:廣告 SDK 是黑盒子,我們只能透過它提供的 API 互動
    4. 時序問題:無法保證哪個廣告會先載入完成

    這讓我開始思考:在無法控制第三方 SDK 的情況下,如何優雅地協調多個非同步、獨立的廣告位?

    設計目標

    在開始實作前,我先定義了幾個關鍵目標:

    1. 零輪詢:不用 polling 來檢查狀態,避免效能開銷
    2. 事件驅動:利用 SDK 提供的事件機制,而不是自己猜測
    3. 狀態共享:讓獨立的廣告位能夠知道彼此的狀態
    4. 順序無關:無論哪個廣告先載入完成,結果都要一致
    5. 容錯性強:SDK 載入失敗、網路問題等都不能影響主要功能

    核心設計:Mutex Pattern + Event-Driven

    我借用了作業系統中的 Mutex (Mutual Exclusion) 概念:

    「同一時間只允許一個高優先級資源佔用臨界區」

    翻譯成前端語言就是:

    「當高優先級廣告填充時,低優先級廣告必須隱藏」

    但前端沒有真正的 mutex lock,所以我用了兩個機制來實現:

    1. 全域標記(Global Flag):用 window 物件儲存互斥狀態
    2. 自定義事件(Custom Event):通知其他廣告位「狀態已改變」

    事件驅動架構

    大多數廣告 SDK 都提供事件機制,在廣告載入完成時會觸發事件:

    // ✅ 廣告 SDK 的事件範例(偽代碼)
    adSDK.addEventListener('adRenderComplete', (event) => {
      console.log('廣告載入完成', event.adSlot, event.isEmpty)
    })

    關鍵資訊:

    • adSlot:哪個廣告位
    • isEmpty:是否有廣告填充

    整體架構

    flowchart TD
        A["廣告 SDK (黑盒子)<br/>- slotRenderEnded 事件"]
        B["Manager 層 (協調邏輯)<br/>- OverlayMutexManager<br/>- TopBannerManager"]
        C["狀態管理層 (Zustand)<br/>- topBannerEnabled: boolean"]
    
        A -->|監聽事件| B
        B -->|更新狀態| C

    技術決策

    為什麼選 Zustand 而不是 Context API?

    Context API 是 React 的標準方案,但在這個場景有幾個問題:

    // ❌ Context API 的限制
    const AdContext = React.createContext()
     
    // 問題 1: 需要包裹所有相關元件
    <AdContext.Provider value={adState}>
      <OverlayAd />
      <HeaderAd />
      <SidebarAd />
    </AdContext.Provider>
     
    // 問題 2: 狀態更新會觸發所有 Consumer 重新渲染

    Zustand 的優勢

    1. 不需要 Provider:直接在任何地方 import 使用
    2. 選擇性訂閱:只有真正使用到的狀態改變才會觸發 re-render
    3. 輕量級:核心程式碼只有 1KB
    4. 可以在 Class 中使用:Manager 是用 Class 寫的,可以直接 useAdStore.getState()

    為什麼需要全域標記 + Store 雙重機制?

    一開始我只用 Zustand Store,但發現有時序問題:

    // ❌ 只用 Store 會有時序問題
    // T1: 高優先級廣告載入完成,更新 Store
    // T2: Store 狀態更新
    // T3: React re-render
    // T4: 低優先級廣告的 useEffect 執行檢查
    //
    // 問題:T1 到 T4 之間有延遲!

    如果低優先級廣告的事件剛好在 T2-T4 之間觸發,它會誤以為「沒有高優先級廣告」。

    所以我加上了全域標記作為即時狀態

    // ✅ 雙重保險
    // 全域標記:立即生效,零延遲
    window.ad_blocking_status = 'high_priority_active'
     
    // Zustand Store:用於 React 元件的響應式更新
    setHighPriorityFilled(true)

    這樣即使在 React re-render 之前,自定義事件的 handler 就能立即讀取到正確的狀態。

    實作細節

    步驟一:定義配置結構

    // adConfig.ts
    export interface AdConfig {
      // 高優先級廣告位列表(蓋版廣告)
      highPrioritySlots: string[]
     
      // 低優先級廣告位列表(頂部廣告)
      lowPrioritySlots: string[]
     
      // 自定義事件配置
      events: {
        mutexCheck: string  // 互斥檢查事件
      }
     
      // 互斥狀態標記
      mutex: {
        statusKey: string      // 全域狀態的 key
        blockingValue: string  // 阻擋其他廣告的標記值
      }
     
      // 除錯模式
      debug: boolean
    }
     
    // ✅ 配置範例
    export const adConfig: AdConfig = {
      highPrioritySlots: ['fullscreen-ad-main', 'fullscreen-ad-secondary'],
      lowPrioritySlots: ['sticky-header-ad'],
      events: {
        mutexCheck: 'adMutex:statusChanged'
      },
      mutex: {
        statusKey: 'ad_blocking_status',
        blockingValue: 'high_priority_active'
      },
      debug: process.env.NODE_ENV === 'development'
    }

    步驟二:實作 OverlayMutexManager

    這個 Manager 負責監聽高優先級廣告,並控制互斥邏輯:

    // OverlayMutexManager.ts
    export class OverlayMutexManager {
      private config: AdConfig
      private initialized = false
     
      constructor(config: AdConfig) {
        this.config = config
      }
     
      private log(...args: any[]) {
        if (this.config.debug) {
          console.log('[廣告互斥]', ...args)
        }
      }
     
      // ✅ 判斷是否為高優先級廣告
      private isHighPriorityAd(adSlotId: string): boolean {
        return this.config.highPrioritySlots.includes(adSlotId)
      }
     
      // ✅ 處理廣告渲染完成事件
      private handleAdRenderComplete = (event: AdRenderEvent) => {
        if (!event?.adSlot) {
          this.log('⚠️ 事件異常: event 或 adSlot 為 null')
          return
        }
     
        const adSlotId = event.adSlot.getId() // ← 取得廣告位 ID
     
        // 只處理高優先級廣告(其他廣告位直接跳過)
        if (!this.isHighPriorityAd(adSlotId)) {
          return
        }
     
        // ✅ 高優先級廣告有填充
        if (!event.isEmpty) {
          this.log('高優先級廣告已填充,設定全域標記:', adSlotId)
     
          // ← 設定全域變數,讓其他模組可以讀取互斥狀態
          ;(window as any)[this.config.mutex.statusKey] =
            this.config.mutex.blockingValue
     
          // ← 通知廣告 SDK(如果支援 targeting API)
          if (adSDK.setTargeting) {
            adSDK.setTargeting(
              this.config.mutex.statusKey,
              this.config.mutex.blockingValue
            )
          }
        } else {
          this.log('高優先級廣告未填充:', adSlotId)
        }
     
        // ✅ 無論有沒有填充,都觸發自定義事件
        // ← 讓低優先級廣告知道「可以決定要不要顯示了」
        this.log('觸發事件:', this.config.events.mutexCheck)
        window.dispatchEvent(
          new CustomEvent(this.config.events.mutexCheck)
        )
      }
     
      // ✅ 初始化:註冊事件監聽器
      public init() {
        if (this.initialized) {
          this.log('已初始化,跳過')
          return // ← 防止重複初始化
        }
     
        if (typeof window === 'undefined') {
          this.log('非瀏覽器環境,跳過初始化')
          return // ← SSR 環境不執行
        }
     
        // ← 註冊廣告 SDK 事件監聽器
        adSDK.addEventListener(
          'adRenderComplete',
          this.handleAdRenderComplete
        )
     
        this.log('監聽器已註冊,監聽高優先級廣告:', this.config.highPrioritySlots)
        this.initialized = true // ← 標記已初始化
      }
     
      // ✅ 清理:移除事件監聽器
      public destroy() {
        if (!this.initialized) return // ← 未初始化則跳過
        if (typeof window === 'undefined') return // ← SSR 環境跳過
     
        // ← 移除事件監聽器
        adSDK.removeEventListener(
          'adRenderComplete',
          this.handleAdRenderComplete
        )
     
        this.log('監聽器已移除')
        this.initialized = false // ← 重置狀態
      }
    }

    關鍵設計點

    1. 事件驅動:透過廣告 SDK 的事件知道廣告載入狀態
    2. 全域標記:設定 window.ad_blocking_status,讓其他系統也能讀取
    3. 自定義事件:用 CustomEvent 通知低優先級廣告「可以決策了」
    4. 防呆機制:檢查 initialized 避免重複初始化

    步驟三:實作 TopBannerManager

    // TopBannerManager.ts
    import { useAdStore } from '@/store/adStore'
     
    export class TopBannerManager {
      private config: AdConfig
      private initialized = false
     
      constructor(config: AdConfig) {
        this.config = config
      }
     
      private log(...args: any[]) {
        if (this.config.debug) {
          console.log('[頂部廣告管理]', ...args)
        }
      }
     
      private isLowPriorityAd(adSlotId: string): boolean {
        return this.config.lowPrioritySlots.includes(adSlotId)
      }
     
      // ✅ 處理低優先級廣告渲染完成
      private handleAdRenderComplete = (event: AdRenderEvent) => {
        if (!this.initialized) return
        if (!event?.adSlot) {
          this.log('⚠️ 事件異常')
          return
        }
     
        const adSlotId = event.adSlot.getId()
     
        // 只處理低優先級廣告
        if (!this.isLowPriorityAd(adSlotId)) {
          return
        }
     
        this.log('低優先級廣告渲染完成:', {
          adSlotId,
          isEmpty: event.isEmpty,
          size: event.size
        })
     
        // ✅ 更新 Zustand store
        const { setLowPriorityFilled } = useAdStore.getState()
     
        if (!event.isEmpty) {
          this.log('✅ 低優先級廣告已填充')
          setLowPriorityFilled(true)
        } else {
          this.log('❌ 低優先級廣告未填充')
          setLowPriorityFilled(false)
        }
      }
     
      public init() {
        if (this.initialized) {
          this.log('已初始化,跳過')
          return
        }
     
        if (typeof window === 'undefined') {
          this.log('非瀏覽器環境,跳過初始化')
          return
        }
     
        adSDK.addEventListener(
          'adRenderComplete',
          this.handleAdRenderComplete
        )
     
        this.log('監聽器已註冊,監聽低優先級廣告:', this.config.lowPrioritySlots)
        this.initialized = true
      }
     
      public destroy() {
        if (!this.initialized) return
        if (typeof window === 'undefined') return
     
        adSDK.removeEventListener(
          'adRenderComplete',
          this.handleAdRenderComplete
        )
     
        this.log('監聽器已移除')
        this.initialized = false
     
        // ✅ 重置狀態
        const { resetLowPriorityStatus } = useAdStore.getState()
        resetLowPriorityStatus()
      }
    }

    步驟四:Zustand 狀態管理

    // store/adStore.ts
    import { create } from 'zustand'
     
    export interface AdState {
      lowPriorityFilled: boolean        // 低優先級廣告是否填充
      lowPriorityLastUpdated: number    // 最後更新時間戳
      setLowPriorityFilled: (filled: boolean) => void
      resetLowPriorityStatus: () => void
    }
     
    export const useAdStore = create<AdState>((set) => ({
      // ✅ 預設為 true,避免初始化時閃爍
      lowPriorityFilled: true,
      lowPriorityLastUpdated: 0, // ← 用於追蹤更新時間
     
      setLowPriorityFilled: (filled) =>
        set({
          lowPriorityFilled: filled, // ← 更新填充狀態
          lowPriorityLastUpdated: Date.now(), // ← 記錄更新時間戳
        }),
     
      resetLowPriorityStatus: () =>
        set({
          lowPriorityFilled: true, // ← 重置為預設值
          lowPriorityLastUpdated: 0, // ← 清空時間戳
        }),
    }))

    為什麼預設是 true

    如果預設為 false,在初始化階段低優先級廣告會先被隱藏,等事件觸發後才顯示,會有明顯的閃爍。設定為 true 可以避免這個問題。

    步驟五:React 元件整合

    // components/LowPriorityAd.tsx
    'use client'
     
    import { useEffect, useState } from 'react'
    import { useAdStore } from '@/store/adStore'
     
    export function LowPriorityAd() {
      const lowPriorityFilled = useAdStore((state) => state.lowPriorityFilled)
      const [shouldShow, setShouldShow] = useState(false)
     
      useEffect(() => {
        // ✅ 監聽自定義事件:高優先級廣告渲染完成
        const handleMutexCheck = () => {
          // ← 檢查全域變數:是否有高優先級廣告填充
          const blockingStatus = (window as any).ad_blocking_status
          const isBlocked = blockingStatus === 'high_priority_active'
     
          if (isBlocked) {
            console.log('⛔ 有高優先級廣告,隱藏低優先級廣告')
            setShouldShow(false) // ← 設定為不顯示
          } else {
            console.log('✅ 無高優先級廣告,可顯示低優先級廣告')
            setShouldShow(true) // ← 允許顯示
          }
        }
     
        // ← 註冊事件監聽器
        window.addEventListener('adMutex:statusChanged', handleMutexCheck)
     
        // ← cleanup:移除監聽器避免記憶體洩漏
        return () => {
          window.removeEventListener('adMutex:statusChanged', handleMutexCheck)
        }
      }, [])
     
      // ✅ 同時滿足兩個條件才顯示
      const isVisible = shouldShow && lowPriorityFilled
     
      if (!isVisible) {
        return null
      }
     
      return (
        <div className="fixed top-0 left-0 right-0 z-50">
          {/* 廣告容器 */}
          <div id="low-priority-ad" />
        </div>
      )
    }

    步驟六:初始化流程

    // app/layout.tsx
    'use client'
     
    import { useEffect } from 'react'
    import { OverlayMutexManager } from '@/lib/ad/OverlayMutexManager'
    import { LowPriorityAdManager } from '@/lib/ad/LowPriorityAdManager'
    import { adConfig } from '@/lib/ad/config'
     
    export default function RootLayout({ children }) {
      useEffect(() => {
        // ✅ 建立 Manager 實例
        const highPriorityManager = new OverlayMutexManager(adConfig)
        const lowPriorityManager = new LowPriorityAdManager(adConfig)
     
        // ← 初始化事件監聽器
        highPriorityManager.init()
        lowPriorityManager.init()
     
        // ✅ cleanup:元件卸載時清理
        return () => {
          highPriorityManager.destroy() // ← 移除高優先級監聽器
          lowPriorityManager.destroy()  // ← 移除低優先級監聽器
        }
      }, []) // ← 只在掛載時執行一次
     
      return (
        <html>
          <body>
            <LowPriorityAd />
            {children}
          </body>
        </html>
      )
    }

    完整流程圖

    flowchart TD
        Start[頁面載入]
        Init1[初始化 OverlayMutexManager<br/>監聽高優先級廣告事件]
        Init2[初始化 LowPriorityAdManager<br/>監聽低優先級廣告事件]
        Load[高優先級廣告開始載入...<br/>低優先級廣告開始載入...]
    
        Render1[高優先級廣告渲染完成<br/>event.isEmpty = false]
        SetFlag[設定全域標記<br/>window.ad_blocking_status = 'high_priority_active']
        Dispatch[觸發自定義事件<br/>dispatchEvent 'adMutex:statusChanged']
        Receive[LowPriorityAd 元件收到事件]
        Check[檢查全域標記<br/>isBlocked = true]
        Hide[setShouldShow false<br/>⛔ 隱藏低優先級廣告]
    
        Render2[低優先級廣告渲染完成<br/>event.isEmpty = false]
        Update[更新 Zustand store<br/>setLowPriorityFilled true]
        Rerender[元件重新渲染<br/>isVisible = false && true = false]
        Final[不顯示低優先級廣告 ✅]
    
        Start --> Init1
        Init1 --> Init2
        Init2 --> Load
        Load --> Render1
        Render1 --> SetFlag
        SetFlag --> Dispatch
        Dispatch --> Receive
        Receive --> Check
        Check --> Hide
        Hide --> Render2
        Render2 --> Update
        Update --> Rerender
        Rerender --> Final

    重要的容錯設計

    1. 廣告 SDK 載入失敗的處理

    廣告 SDK 可能因為 AdBlock 或網路問題載入失敗,必須確保不會影響主要功能:

    // ✅ 防禦性程式設計
    public init() {
      if (typeof window === 'undefined') return // ← SSR 環境
     
      // ← 檢查 SDK 是否存在
      if (typeof window.adSDK === 'undefined') {
        this.log('⚠️ 廣告 SDK 未載入,跳過初始化')
        return
      }
     
      // ← 檢查 SDK 是否提供事件 API
      if (typeof window.adSDK.addEventListener !== 'function') {
        this.log('⚠️ 廣告 SDK 版本過舊,不支援事件機制')
        return
      }
     
      // 正常初始化...
    }

    2. React Strict Mode 的冪等性設計

    React 18 的 Strict Mode 會刻意執行兩次 useEffect,必須確保 Manager 能夠安全地重複初始化:

    // ✅ 保證 init() 可以被安全地呼叫多次
    private initialized = false
     
    public init() {
      if (this.initialized) {
        this.log('已初始化,跳過')
        return // ← 第二次呼叫直接返回
      }
     
      // 實際的初始化邏輯...
      this.initialized = true
    }

    3. 記憶體洩漏防護

    務必在元件卸載時移除所有監聽器:

    // ✅ 正確的 cleanup
    useEffect(() => {
      window.addEventListener('adMutex:statusChanged', handleMutexCheck)
     
      return () => {
        window.removeEventListener('adMutex:statusChanged', handleMutexCheck)
      }
    }, [])

    效能考量

    1. 事件監聽器數量

    統一監聽,內部判斷廣告類型:

    // ✅ 更好的做法:統一監聽,內部判斷
    adSDK.addEventListener('adRenderComplete', (event) => {
      const adSlotId = event.adSlot.getId() // ← 取得廣告位 ID
     
      if (this.isHighPriorityAd(adSlotId)) {
        this.handleHighPriority(event) // ← 處理高優先級
      } else if (this.isLowPriorityAd(adSlotId)) {
        this.handleLowPriority(event) // ← 處理低優先級
      }
      // ← 其他廣告位自動忽略
    })

    2. Zustand Store 更新優化

    只在狀態真正改變時更新:

    // ✅ 只在狀態真正改變時更新
    setLowPriorityFilled: (filled) => {
      const currentState = get() // ← 取得當前狀態
     
      // ← 只在狀態改變時才更新,避免不必要的 re-render
      if (currentState.lowPriorityFilled !== filled) {
        set({
          lowPriorityFilled: filled,
          lowPriorityLastUpdated: Date.now(), // ← 記錄更新時間
        })
      }
    }

    3. 自定義事件的命名空間

    使用命名空間避免衝突:

    // ✅ 使用命名空間前綴避免與其他模組衝突
    const EVENT_NAMESPACE = 'adMutex:'
     
    events: {
      mutexCheck: `${EVENT_NAMESPACE}statusChanged` // ← 完整事件名:adMutex:statusChanged
    }

    擴展:支援更多廣告位

    如果需要管理更多廣告位,可以擴展成優先級系統:

    // adConfig.ts - 擴展配置
    export interface ExtendedAdConfig {
      slots: {
        fullscreen: {
          ids: string[]
          priority: 1  // ← 優先級最高
        }
        header: {
          ids: string[]
          priority: 2
        }
        sidebar: {
          ids: string[]
          priority: 3
        }
        footer: {
          ids: string[]
          priority: 4
        }
      }
    }
     
    // AdPriorityManager.ts - 通用優先級管理器
    export class AdPriorityManager {
      private activeSlots: Map<string, number> = new Map() // ← 儲存已填充的廣告位及其優先級
     
      // ✅ 根據優先級決定是否允許顯示
      canShow(slotType: string): boolean {
        const currentPriority = this.config.slots[slotType].priority // ← 取得當前廣告位的優先級
     
        // ← 檢查是否有更高優先級的廣告已填充
        for (const [type, priority] of this.activeSlots) {
          if (priority < currentPriority) {
            return false // ← 數字越小優先級越高,不能顯示
          }
        }
     
        return true // ← 沒有衝突,可以顯示
      }
    }

    從這次實作中提煉的設計原則

    1. 事件驅動 > 輪詢

    事件驅動的優勢:

    • 效能更好:不需要持續執行檢查邏輯
    • 即時性高:狀態改變立即通知
    • 程式碼更清晰:邏輯集中在事件處理函式中

    但要注意:平台/SDK 必須提供可靠的事件機制。如果事件不可靠,可以考慮混合方案(事件為主,超時輪詢為輔)。

    2. 全域狀態 + 響應式狀態 = 零時序問題

    雙重狀態機制的適用場景:

    // ✅ 決策邏輯讀全域變數(零延遲)
    const isBlocked = window.ad_blocking_status === 'high_priority_active'
     
    // ✅ UI 更新用 React 狀態(響應式)
    setLowPriorityFilled(filled)

    這個模式也適用於:

    • WebSocket 訊息處理
    • 多個第三方 SDK 的協調
    • Service Worker 的狀態同步

    3. 防呆設計優於聰明設計

    必須考慮的邊界情況:

    • SDK 載入失敗(AdBlock、網路問題)
    • 事件未觸發或延遲觸發
    • React Strict Mode 的雙重執行
    • 元件重複掛載/卸載

    寧可多寫防呆程式碼,也不要假設事情會正常運作。

    4. 可觀測性從設計階段就要考慮

    Debug 機制的設計:

    // ✅ 支援動態開啟的 debug 模式
    const enableDebug = () => {
      window.__AD_DEBUG__ = true
      console.log('廣告系統 debug 模式已啟用')
    }
     
    // 在 Manager 中檢查
    private log(...args: any[]) {
      if (this.config.debug || window.__AD_DEBUG__) {
        console.log('[廣告互斥]', ...args)
      }
    }

    這讓問題排查變得更容易,而不需要重新部署程式碼。

    總結

    這次實作廣告位互斥系統的核心思路是:

    1. 借用作業系統的 Mutex 概念,實現廣告位之間的互斥控制
    2. 事件驅動架構,利用 SDK 提供的事件而不是輪詢
    3. 雙重狀態機制,解決 React 狀態更新的時序問題
    4. 完善的容錯設計,確保 SDK 載入失敗不影響主要功能
    5. 可觀測性設計,方便問題排查和監控

    這個架構的優勢是:

    • 效能好:零輪詢,事件驅動
    • 可擴展:容易增加更多廣告位和優先級規則
    • 容錯性強:多層防護機制
    • 可維護:邏輯清晰,職責分離

    希望這個設計能幫助到需要處理複雜第三方整合的開發者。

    參考資料