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

- Name
- Alex Yu
最近在專案中實作了一個廣告位管理系統,需求是:當高優先級廣告(例如蓋版廣告)顯示時,低優先級廣告(例如頂部固定廣告)必須隱藏,避免畫面被過多廣告佔據。
這個需求看似簡單,但實際上有幾個技術挑戰:
- 兩個廣告位是獨立載入的:各自透過不同的 SDK 請求廣告,載入時間不可預測
- 填充結果不確定:廣告可能有填充,也可能沒有(取決於競價、頻次控制等因素)
- 無法直接控制 SDK:廣告 SDK 是黑盒子,我們只能透過它提供的 API 互動
- 時序問題:無法保證哪個廣告會先載入完成
這讓我開始思考:在無法控制第三方 SDK 的情況下,如何優雅地協調多個非同步、獨立的廣告位?
設計目標
在開始實作前,我先定義了幾個關鍵目標:
- 零輪詢:不用 polling 來檢查狀態,避免效能開銷
- 事件驅動:利用 SDK 提供的事件機制,而不是自己猜測
- 狀態共享:讓獨立的廣告位能夠知道彼此的狀態
- 順序無關:無論哪個廣告先載入完成,結果都要一致
- 容錯性強:SDK 載入失敗、網路問題等都不能影響主要功能
核心設計:Mutex Pattern + Event-Driven
我借用了作業系統中的 Mutex (Mutual Exclusion) 概念:
「同一時間只允許一個高優先級資源佔用臨界區」
翻譯成前端語言就是:
「當高優先級廣告填充時,低優先級廣告必須隱藏」
但前端沒有真正的 mutex lock,所以我用了兩個機制來實現:
- 全域標記(Global Flag):用
window物件儲存互斥狀態 - 自定義事件(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 的優勢:
- 不需要 Provider:直接在任何地方 import 使用
- 選擇性訂閱:只有真正使用到的狀態改變才會觸發 re-render
- 輕量級:核心程式碼只有 1KB
- 可以在 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 // ← 重置狀態
}
}關鍵設計點:
- 事件驅動:透過廣告 SDK 的事件知道廣告載入狀態
- 全域標記:設定
window.ad_blocking_status,讓其他系統也能讀取 - 自定義事件:用
CustomEvent通知低優先級廣告「可以決策了」 - 防呆機制:檢查
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)
}
}這讓問題排查變得更容易,而不需要重新部署程式碼。
總結
這次實作廣告位互斥系統的核心思路是:
- 借用作業系統的 Mutex 概念,實現廣告位之間的互斥控制
- 事件驅動架構,利用 SDK 提供的事件而不是輪詢
- 雙重狀態機制,解決 React 狀態更新的時序問題
- 完善的容錯設計,確保 SDK 載入失敗不影響主要功能
- 可觀測性設計,方便問題排查和監控
這個架構的優勢是:
- 效能好:零輪詢,事件驅動
- 可擴展:容易增加更多廣告位和優先級規則
- 容錯性強:多層防護機制
- 可維護:邏輯清晰,職責分離
希望這個設計能幫助到需要處理複雜第三方整合的開發者。
