防震和批次處理多個 Analytics 事件

大多數數據分析、遙測和 Core Web Vitals 數據分析資料的優先順序都低於回應使用者,因此您可以放心地延遲傳送,直到使用者離開頁面為止。但如果需要提供即時更新,則不在此限,因為時效性很重要。

如要提供即時分析更新,同時盡可能減少信標,最佳做法是使用 fetchLater() 搭配 activateAfter 設定選項。這樣一來,您就能有效去抖動,並將指定時間範圍內發生的所有 Analytics 事件批次處理成單一信標,即使使用者在逾時前離開網頁,也能可靠地傳送。

導入方式

  1. 排定要求:只要有任何相關數據分析資料,請立即呼叫 fetchLater() 並傳遞資料酬載和 activateAfter 值。系統會將資料排入佇列,並在指定時間過後或使用者提前離開網頁時傳送。

  2. 批次處理多個事件:如果在 activateAfter 逾時前發生新的數據分析事件,請取消先前排定的要求,並再次呼叫 fetchLater() (使用相同的 activateAfter 值),在單一酬載中傳送完整的事件佇列。

  3. 在逾時時間到期時重設事件佇列:如果排定的信標成功傳送後 (即 fetchLater() 結果的 activated 值為 true),發生新的數據分析事件,請重設事件佇列。

  4. 讓瀏覽器處理其餘作業:如果使用者在 activateAfter 逾時前離開或關閉分頁,瀏覽器仍會可靠地傳送最近一次 fetchLater() 呼叫的酬載。

範例程式碼

這段程式碼會追蹤網頁上的所有 load 和 click 事件,並將 10 秒逾時期間內發生的所有事件批次處理。

// Replace with your analytics endpoint.
const ANALYTICS_ENDPOINT = '/path/to/analytics/endpoint';

// Replace with a time window of your choice. All analytics events that
// occur within this time window will be batched together.
const BATCH_WINDOW = 10 * 1000;

// The maximum number of events to batch. Pick a number that is unlikely
// to overflow the fetchLater() quota for the page.
const MAX_QUEUE_SIZE = 100;

const eventQueue = [];
let fetchLaterResult;
let fetchLaterController;

function trackEvent(eventData) {
  // If the previously queued beacon has already been sent, or if the
  // max queue size has been met, reset the queue.
  if (fetchLaterResult?.activated || eventQueue.length > MAX_QUEUE_SIZE) {
    fetchLaterController = null;
    fetchLaterResult = null;
    eventQueue.length = 0;
  }

  eventQueue.push(eventData);

  // Abort any pending beacons before creating a new one.
  if (fetchLaterController) {
    fetchLaterController.abort();
  }
  fetchLaterController = new AbortController();

  // Schedule a fetch for the events to be sent when the batch window expires.
  // IMPORTANT: wrap the call in a try/catch to handle quota errors.
  try {
    fetchLaterResult = fetchLater(ANALYTICS_ENDPOINT, {
      method: 'POST',
      headers: {'content-type': 'application/json'},
      body: JSON.stringify(eventQueue),
      signal: fetchLaterController.signal,
      activateAfter: BATCH_WINDOW,
    });
  } catch (error) {
    // Handle errors as needed.
  }
}

// Track page loads.
window.addEventListener('load', () => {
  trackEvent({type: 'page_load'});
});

// Track click events.
window.addEventListener('click', (event) => {
  trackEvent({type: 'click', target: serializeElement(event.target)});
});

最佳做法

  • 建議使用 fetchLater(),並將 activateAfter 選項設為批次處理多個發生時間相近的數據分析事件。
  • 建議使用 AbortController 取消待處理的擷取作業,以便處理新的數據分析事件。
  • 建議:請務必確認您沒有將過多事件批次處理,導致超出 fetchLater() 配額 (目前每個來源約 64 KB)。
  • 務必將 fetchLater() 呼叫包裝在 try/catch 中,以便處理配額錯誤。
  • 務必偵測 globalThis 上是否有 fetchLater(),並為不支援 API 的瀏覽器實作備用策略。
  • 請勿使用 ReadableStream 物件做為要求主體,否則會發生錯誤。

瀏覽器支援和備用策略

因此,通常需要備用策略。

不過,考量到這個 API 的可靠性和效能優勢,如果瀏覽器支援,建議使用 fetchLater()。

fetchLater() polyfill

請使用下列最小 fetchLater() polyfill,在不支援的瀏覽器中盡可能實作 API。

這個 polyfill 唯一值得注意的行為差異是,它不會在使用者離開頁面時傳送酬載,而是在頁面的 visibilityState 變更為「hidden」時傳送,因為這是目前最可靠且廣泛可用的工作階段結束信號。

globalThis.fetchLater ??= function fetchLater(url, init = {}) {
  let timeoutHandle;
  let activated = false;

  function sendNow() {
    if (!(init.signal && init.signal.aborted)) {
      // Use fetch keepalive if the browser supports it or if custom fetch
      // parameters are specified (e.g. custom headers or methods).
      // Otherwise fall back to `navigator.sendBeacon()`.
      if (
        'keepalive' in Request.prototype ||
        init.method !== 'POST' ||
        init.headers
      ) {
        fetch(url, Object.assign({}, init, {keepalive: true}));
        activated = true;
      } else {
        activated = navigator.sendBeacon(url, init.body);
      }
    }
    destroy();
  }

  function destroy() {
    document.removeEventListener('visibilitychange', sendNow);
    clearTimeout(timeoutHandle);
  }

  if (document.visibilityState === 'hidden') {
    // If the beacon was created while the page is already hidden, send data
    // ASAP but wait until the next microtask to allow all sync code to run.
    queueMicrotask(sendNow);
  } else {
    document.addEventListener('visibilitychange', sendNow);

    if (typeof init.activateAfter === 'number' && init.activateAfter >= 0) {
      timeoutHandle = setTimeout(sendNow, init.activateAfter);
    }
  }

  if (init.signal) {
    init.signal.addEventListener('abort', destroy);
  }

  return {
    get activated() {
      return activated;
    },
  };
};