对多个分析事件进行去抖动和批处理

大多数分析、遥测和 Core Web Vitals 分析数据的优先级都低于响应用户,因此您可以放心地推迟发送这些数据,直到用户离开页面。但如果您需要提供实时更新,则例外,因为在这种情况下,时效性至关重要。

若要提供实时分析更新,同时尽可能减少信标,最佳方法是使用 fetchLater() 并搭配 activateAfter 配置选项。这样,您就可以有效地对给定时间范围内发生的所有 Google 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() 配额(目前每个来源大约 64KB)。
  • 务必将对 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;
    },
  };
};