Как измерить время взаимодействия и отрисовки при мягкой навигации

Стандартные показатели отрисовки и отзывчивости не учитывают период после начальной загрузки страницы:

  • LargestContentfulPaint (largest-contentful-paint) измеряет только начальную ("жесткую") загрузку документа и прекращает выдавать записи, как только пользователь впервые взаимодействует со страницей.
  • PerformanceEventTiming и event / Interaction to Next Paint (INP) измеряет скорость отклика на ввод до следующего кадра (например, активного состояния кнопки или индикатора загрузки), но не отслеживает последующие асинхронные отрисовки, такие как новый контент, отображаемый после разрешения fetch(), или завершение перехода по клиентскому маршруту.
  • Отслеживать изменения маршрута в одностраничном приложении (SPA) может быть сложно, если не использовать код фреймворка. Если отслеживать только обновления URL, то в отчеты могут попадать изменения URL, не связанные с изменениями маршрута (например, изменения анкорных ссылок, несколько переадресаций для одного изменения маршрута).

Типы записей производительности InteractionContentfulPaint (interaction-contentful-paint) и PerformanceSoftNavigation (soft-navigation) заполняют этот пробел. Они распространяют причинно-следственную связь взаимодействия между асинхронными задачами (fetch, await, setTimeout), чтобы соотносить новые отрисовки контента и переходы по маршрутам одностраничных приложений (SPA) непосредственно с взаимодействием пользователя, которое их вызвало.

Рекомендации по внедрению

1. Как отслеживать отрисовку контента, вызванную взаимодействиями

Используйте PerformanceObserver с type: 'interaction-contentful-paint' и buffered: true, чтобы отслеживать все более крупные отрисовки контента, связанные с любым взаимодействием пользователя.

Каждая запись InteractionContentfulPaint содержит:

  • interactionId – уникальный идентификатор взаимодействия с пользователем, которое инициировало событие (соответствует PerformanceEventTiming.interactionId и PerformanceSoftNavigation.interactionId).
  • startTime – временная метка начала взаимодействия с пользователем, относительно исходного времени начала жесткой навигации.
  • paintTime и presentationTime – когда завершился этап отрисовки и когда на экране появились пиксели.
  • duration – время, прошедшее с начала взаимодействия до отрисовки (presentationTime - startTime).
  • largestContentfulPaint – объект LargestContentfulPaint, содержащий сведения о самом большом отрисованном элементе для этого взаимодействия (element, size, url, id, renderTime, loadTime).
/**
 * Observes contentful paints caused by user interactions and invokes a callback
 * whenever an interaction produces a larger contentful paint.
 *
 * @param {(entry: PerformanceEntry) => void} onInteractionPaint
 */
export function observeInteractionPaints(onInteractionPaint) {
  // Feature-detect support before calling observe() so unsupported browsers
  // do not throw or log console warnings.
  if (
    typeof PerformanceObserver === 'undefined' ||
    !PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint')
  ) {
    return null;
  }

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry.duration is already calculated as (entry.presentationTime - entry.startTime),
      // giving the total time from the user interaction to this contentful paint.
      onInteractionPaint(entry);
    }
  });

  // MANDATORY: Pass buffered: true to capture interaction paints that occurred
  // before this observer was registered.
  observer.observe({ type: 'interaction-contentful-paint', buffered: true });
  return observer;
}

2. Измерение показателя Largest Contentful Paint (LCP) для мягких переходов в одностраничных приложениях

Мягкая навигация выполняется браузером, когда надежное взаимодействие с пользователем приводит к видимому изменению URL и отрисовке контента. Чтобы рассчитать LCP для каждого перехода без обновления страницы:

  1. Проверьте записи 'soft-navigation' и 'interaction-contentful-paint' со значением buffered: true.
  2. ОБЯЗАТЕЛЬНО. При получении записи 'soft-navigation' добавьте ее начальный кандидат LCP, вызвав softNavEntry.getLargestInteractionContentfulPaint(). Если приложение отрисовало контент до обновления URL (например, сначала отрисовало представление, а затем вызвало history.pushState()), то эти ранние записи interaction-contentful-paint были созданы до записи soft-navigation и по-прежнему содержат предыдущий navigationId.
  3. ОБЯЗАТЕЛЬНО. Сопоставляйте последующие записи 'interaction-contentful-paint' с активной плавной навигацией, используя entry.interactionId === softNavEntry.interactionId. НЕ используйте для этого entry.navigationId. Использование interactionId гарантирует, что вы включите отрисовки, которые произошли до обновления URL, и исключите отрисовки из более поздних, не связанных между собой взаимодействий на новом маршруте.
/**
 * Tracks FCP and LCP for each client-side soft navigation and reports finalized
 * route metrics when the user navigates to a new route or hides the page.
 *
 * @param {(report: {
 *   url: string,
 *   navigationId: number,
 *   interactionId: number,
 *   fcpDuration: number,
 *   lcpDuration: number | null,
 *   lcpElement: Element | null,
 *   lcpSize: number | null
 * }) => void} onSoftNavReport
 */
export function trackSoftNavigationLCP(onSoftNavReport) {
  const supported =
    typeof PerformanceObserver !== 'undefined' &&
    PerformanceObserver.supportedEntryTypes?.includes('soft-navigation') &&
    PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint');

  if (!supported) {
    return null;
  }

  let activeSoftNav = null;
  let activeLcpEntry = null;

  function finalizeCurrentSoftNav() {
    if (!activeSoftNav) return;

    onSoftNavReport({
      // activeSoftNav.name holds the destination URL of the soft navigation.
      url: activeSoftNav.name,
      navigationId: activeSoftNav.navigationId,
      interactionId: activeSoftNav.interactionId,
      // activeSoftNav.duration is (activeSoftNav.presentationTime - activeSoftNav.startTime),
      // which represents First Contentful Paint (FCP) relative to interaction start.
      fcpDuration: activeSoftNav.duration,
      // activeLcpEntry.duration is (activeLcpEntry.presentationTime - activeSoftNav.startTime),
      // representing the soft navigation's LCP duration relative to interaction start.
      lcpDuration: activeLcpEntry ? activeLcpEntry.duration : null,
      lcpElement: activeLcpEntry?.largestContentfulPaint?.element ?? null,
      lcpSize: activeLcpEntry?.largestContentfulPaint?.size ?? null,
    });

    activeSoftNav = null;
    activeLcpEntry = null;
  }

  const softNavObserver = new PerformanceObserver((list) => {
    for (const softNavEntry of list.getEntries()) {
      // Finalize the previous route's metrics before resetting state for the new route.
      finalizeCurrentSoftNav();

      activeSoftNav = softNavEntry;
      // MANDATORY: Seed the LCP candidate with getLargestInteractionContentfulPaint()
      // to capture any attributed contentful paints that occurred before the URL updated.
      activeLcpEntry = softNavEntry.getLargestInteractionContentfulPaint();
    }
  });

  const icpObserver = new PerformanceObserver((list) => {
    for (const icpEntry of list.getEntries()) {
      // MANDATORY: Match by interactionId (not navigationId) so only paints caused
      // by the soft navigation's initiating interaction update the route's LCP.
      if (activeSoftNav && icpEntry.interactionId === activeSoftNav.interactionId) {
        activeLcpEntry = icpEntry;
      }
    }
  });

  softNavObserver.observe({ type: 'soft-navigation', buffered: true });
  icpObserver.observe({ type: 'interaction-contentful-paint', buffered: true });

  // Finalize the active route's LCP when the page is backgrounded or unloaded.
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      finalizeCurrentSoftNav();
    }
  });

  return { softNavObserver, icpObserver, finalizeCurrentSoftNav };
}

3. Измерение показателей Core Web Vitals при мягкой навигации с помощью web-vitals

Если вы хотите передавать в аналитику стандартные показатели Core Web Vitals (LCP, CLS и INP) для жестких и мягких переходов, используйте библиотеку web-vitals (версии 6.0.0 и выше) с { reportSoftNavs: true }, а не разбивайте вручную временную шкалу каждого показателя.

import { onLCP, onFCP, onCLS, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
  // Example-only endpoint; replace with your real telemetry URL.
  navigator.sendBeacon(
    '/analytics',
    JSON.stringify({
      name: metric.name,
      value: metric.value,
      navigationType: metric.navigationType,
      navigationURL: metric.navigationURL,
    }),
  );
}

// Pass { reportSoftNavs: true } to report metrics per soft navigation in addition
// to the initial hard navigation.
onLCP(sendToAnalytics, { reportSoftNavs: true });
onFCP(sendToAnalytics, { reportSoftNavs: true });
onCLS(sendToAnalytics, { reportSoftNavs: true });
onINP(sendToAnalytics, { reportSoftNavs: true });

Рекомендации

  • РЕКОМЕНДУЕТСЯ определять поддержку 'interaction-contentful-paint' и 'soft-navigation' с помощью PerformanceObserver.supportedEntryTypes?.includes(...) перед вызовом observer.observe().
  • РЕКОМЕНДУЕТСЯ передавать { buffered: true } при отслеживании 'interaction-contentful-paint' и 'soft-navigation', чтобы не пропустить ранние записи, если код аналитики инициализируется асинхронно.
  • РЕКОМЕНДУЕТСЯ вызывать softNavEntry.getLargestInteractionContentfulPaint() при обработке новой записи soft-navigation, чтобы отрисовки, которые произошли до обновления URL, были включены в LCP.
  • РЕКОМЕНДУЕТСЯ сопоставлять записи interaction-contentful-paint с мягкой навигацией, используя interactionId, а не navigationId.
  • РЕКОМЕНДУЕТСЯ использовать entry.duration (или entry.presentationTime - entry.startTime) для измерения задержки отрисовки при взаимодействии, а не сообщать entry.presentationTime напрямую. Все временные метки PerformanceEntry отсчитываются от момента начальной жесткой загрузки страницы, а не от начала мягкой навигации.
  • НЕ полагайтесь на performance.getEntriesByType('soft-navigation') для отслеживания долго работающих одностраничных приложений, поскольку буфер браузера ограничен первыми 50 записями о переходах без перезагрузки страницы.

Резервные стратегии

Если в вашу Baseline входят браузеры, которые не поддерживают InteractionContentfulPaint или PerformanceSoftNavigation, используйте эти API как прогрессивное улучшение для мониторинга реальных пользователей (RUM):

  1. Обнаружение функций и постепенное сокращение. Защитите регистрацию наблюдателя с помощью PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint') и PerformanceObserver.supportedEntryTypes?.includes('soft-navigation'). В браузерах, которые не поддерживают эту функцию, не регистрируйте наблюдателей, чтобы приложение работало без ошибок.
  2. Не отключайте стандартные наблюдатели за жесткой навигацией. Продолжайте отслеживать стандартные записи 'largest-contentful-paint', 'event' и 'layout-shift' во всех браузерах. Поскольку LargestContentfulPaint определяется при первом взаимодействии пользователя независимо от поддержки программной навигации, измерение программной навигации в поддерживаемых браузерах не изменяет и не нарушает первоначальные измерения LCP при жесткой загрузке.