工程師與貓
Published on

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

Authors
  • avatar
    Name
    Alex Yu
    Twitter

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

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

  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:是否有廣告填充

整體架構

技術決策

為什麼選 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>
  )
}

完整流程圖

重要的容錯設計

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. 可觀測性設計,方便問題排查和監控

這個架構的優勢是:

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

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

參考資料