标准绘制和响应速度指标在初始网页加载后会留下一个衡量缺口:
LargestContentfulPaint(largest-contentful-paint) 仅衡量初始(“硬”)文档加载,并在用户首次与页面互动时立即停止发出条目。PerformanceEventTiming和event/ Interaction to Next Paint (INP) 衡量的是输入响应速度,直至下一帧(例如按钮处于活动状态或加载旋转图标),但不会跟踪后续异步绘制,例如在fetch()解析后或客户端路由转换完成后呈现的新内容。- 在框架代码之外,观察单页应用 (SPA) 中的路由更改可能很困难。如果仅监控网址更新,可能会导致过度报告与路线更改无关的网址更改(例如,锚链接更改、单次路线更改的多次“重定向”)。
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)
当可信的用户互动导致可见网址发生变化且发生内容绘制时,浏览器会发出软导航。如需计算每次软导航的 LCP,请执行以下操作:
- 观察
'soft-navigation'和'interaction-contentful-paint'条目,它们都带有buffered: true。 - 强制性:收到
'soft-navigation'条目后,通过调用softNavEntry.getLargestInteractionContentfulPaint()为其初始 LCP 候选对象提供种子。如果应用在更新网址之前绘制了内容(例如,渲染视图,然后调用history.pushState()),则这些早期interaction-contentful-paint条目会在soft-navigation条目之前发出,并且仍会携带之前的navigationId。 - 强制性要求:通过匹配
entry.interactionId === softNavEntry.interactionId将后续'interaction-contentful-paint'条目与活跃的软导航相关联,请勿通过entry.navigationId进行匹配。使用interactionId可确保您纳入网址更新之前发生的绘制,并排除新路由上后续不相关互动产生的绘制。
/**
* 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. 使用 web-vitals 衡量软导航中的 Core Web Vitals
如果您的目标是向分析端点报告硬导航和软导航的标准 Core Web Vitals(LCP、CLS 和 INP),请使用 web-vitals 库(v6.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 });
最佳做法
- 务必在调用
observer.observe()之前,使用PerformanceObserver.supportedEntryTypes?.includes(...)检测'interaction-contentful-paint'和'soft-navigation'。 - 务必在观察
'interaction-contentful-paint'和'soft-navigation'时传递{ buffered: true },这样,如果分析代码异步初始化,就不会错过早期条目。 - 请在处理新的
soft-navigation条目时调用softNavEntry.getLargestInteractionContentfulPaint(),以便将网址更新之前发生的绘制纳入 LCP。 - 务必使用
interactionId(而非navigationId)将interaction-contentful-paint条目与软导航进行匹配。 - 务必使用
entry.duration(或entry.presentationTime - entry.startTime)来衡量互动绘制延迟时间,而不是直接报告entry.presentationTime。所有PerformanceEntry时间戳都是从初始硬网页加载时间原点开始测量的,而不是从软导航开始测量的。 - 请勿依赖
performance.getEntriesByType('soft-navigation')来监控长期运行的单页应用,因为浏览器的缓冲区仅限于前 50 个软导航条目。
后备策略
如果您的基准目标包括不支持 InteractionContentfulPaint 或 PerformanceSoftNavigation 的浏览器,请将这些 API 视为真实用户监控 (RUM) 的渐进增强功能:
- 特征检测和优雅降级:使用
PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint')和PerformanceObserver.supportedEntryTypes?.includes('soft-navigation')保护观察器注册。在不支持这些 API 的浏览器中,请跳过注册观察器,以便应用在运行时不会抛出错误。 - 保持标准硬导航观察器处于活跃状态:继续在所有浏览器中观察标准
'largest-contentful-paint'、'event'和'layout-shift'条目。由于LargestContentfulPaint会在带来用户首次互动的用户首次互动时最终确定,无论是否支持软导航,因此在支持软导航的浏览器中衡量软导航不会改变或破坏初始硬加载 LCP 衡量。