מדדי הרינדור והתגובה הרגילים משאירים פער במדידה אחרי הטעינה הראשונית של הדף:
LargestContentfulPaint(largest-contentful-paint) מודד רק את הטעינה הראשונית ("הקשה") של המסמך ומפסיק להפיק רשומות ברגע שהמשתמש יוצר אינטראקציה עם הדף בפעם הראשונה.PerformanceEventTimingו-event/ מהירות התגובה לאינטראקציה באתר (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. מדידת המהירות שבה נטען רכיב התוכן הכי גדול (LCP) בניווטים קלים באפליקציות SPA
הדפדפן מדווח על ניווט קל כשאינטראקציה מהימנה של משתמש גורמת גם לשינוי גלוי בכתובת ה-URL וגם ל-contentful paint. כדי לחשב את ה-LCP לכל מעבר קל:
- צופים ברשומות של
'soft-navigation'וגם של'interaction-contentful-paint'באמצעותbuffered: true. - חובה: כשמתקבלת רשומה של
'soft-navigation', צריך להזין את המועמד הראשוני ל-LCP על ידי קריאה ל-softNavEntry.getLargestInteractionContentfulPaint(). אם האפליקציה הציגה תוכן לפני עדכון כתובת ה-URL (לדוגמה, עיבוד תצוגה ואז קריאה ל-history.pushState()), הרשומות המוקדמות האלה שלinteraction-contentful-paintהופקו לפני הרשומה שלsoft-navigationועדיין כוללות את ה-navigationIdהקודם. - חובה: כדי לקשר בין רשומות
'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) גם בניווטים קשיחים וגם בניווטים רכים לנקודת קצה (endpoint) של ניתוח נתונים, השתמשו בספרייה 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 האלה כשיפור הדרגתי (progressive enhancement) למעקב אחר משתמשים אמיתיים (RUM):
- זיהוי תכונות והפחתה חיננית (graceful degradation): הגנה על רישום הצופה באמצעות
PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint')ו-PerformanceObserver.supportedEntryTypes?.includes('soft-navigation'). בדפדפנים שלא תומכים ב-API, דלגו על רישום הצופים כדי שהאפליקציה תפעל בלי להציג שגיאות. - להשאיר את הצופים הרגילים של ניווט קשיח פעילים: המשיכו לצפות ברשומות הרגילות של
'largest-contentful-paint','event'ו-'layout-shift'בכל הדפדפנים. מכיוון שהערך שלLargestContentfulPaintנקבע באינטראקציה הראשונה של המשתמש, בלי קשר לתמיכה בניווט קל, מדידת ניווטים קלים בדפדפנים שתומכים בכך לא משנה או משבשת את המדידות הראשוניות של LCP בטעינה מלאה.