SPA 路由一换,CLS 和 LCP 的测量就乱了——我是怎么在切换后强制重算的

SPA 里的 Web Vitals 测量有个让人头疼的坑:路由切换后,浏览器原生的 PerformanceObserver 不会自动重置 CLS 和 LCP,你拿到的数据要么是旧页面的残留值,要么是新旧页面混在一起的脏数据。我踩过两次这个坑,最后靠手动断开并重建 Observer、同时缓存关键时间戳的方式解决了。

先把这个结论摆出来:要在 SPA 路由切换后得到准确的 CLS 和 LCP,你得在路由变化时主动 disconnect 掉现有的 PerformanceObserver 实例,清空中间状态,然后用新的 page-navigation 标记重新初始化,并在合适时机上报。

为什么原生 API 在 SPA 里会出问题

Web Vitals 库(以及底层的 PerformanceObserver API)最初是为传统的多页应用(MPA)设计的。在 MPA 里,每次页面跳转都是一次完整的文档卸载与重新加载,浏览器会自动重置所有性能指标缓冲区,PerformanceObserver 也能感知到新的 navigation 类型条目。

但 SPA 的路由切换只是 JavaScript 在同一个文档里替换 DOM 内容,没有真正的页面导航。浏览器的 Performance Timeline 里不会有新的 type: "navigate" 条目产生。这意味着:

  1. LCP 候选元素会持续累积。你从列表页切到详情页,PerformanceObserver 还在监听同一个文档,它会把详情页渲染过程中最大的那个元素跟列表页之前记录的最大元素做比较,最终报上去的 LCP 可能是列表页某个大图的时间戳,完全对不上。
  2. CLS 的累加不会清零。Layout Shift 的分数是从页面加载开始累计的,你切了 5 个路由,CLS 就是这 5 个页面所有布局偏移的总和,除非你手动干预。
  3. FCP 和 TTFB 直接拿不到新值。这两个指标依赖 type: "paint"type: "navigation" 的 PerformanceEntry,SPA 路由切换不会产生这些条目。

我在一个 Next.js 项目里第一次发现这个问题,是因为用户详情页的 LCP 一直显示 2.3 秒,但实际页面肉眼可见地 800ms 就出内容了。排查了半小时,发现 PerformanceObserver 报的是上一个列表页的封面大图加载时间。

断开并重建 Observer 的具体做法

Google 的 web-vitals 库从 v3 开始提供了对 SPA 的基本支持,但它的处理方式是监听 type: "soft-navigation" 条目——这是 Chrome 110 开始实验性支持的 API,目前(2025 年初)只在 Chromium 系浏览器可用,Firefox 和 Safari 完全不支持。

如果你需要跨浏览器兼容,或者你的项目对测量精度要求更高,手动管理 Observer 的生命周期是更可靠的选择。

核心思路分三步:

第一步,在每次路由切换时断开所有现有的 Observer。

// 在路由守卫或路由事件回调里
let lcpObserver, clsObserver;

function disconnectObservers() {
  if (lcpObserver) {
    lcpObserver.disconnect();
    lcpObserver = null;
  }
  if (clsObserver) {
    clsObserver.disconnect();
    clsObserver = null;
  }
}

断开 Observer 后,浏览器不会再往回调里推送新的性能条目,LCP 候选元素的比较链就断了。

第二步,清空 CLS 的累加值并重置会话窗口。

CLS 的算法有一个「会话窗口」机制:一连串间隔不超过 1 秒、且总时长不超过 5 秒的布局偏移会被归入同一个窗口,取窗口内所有偏移分数的和作为这次会话的 CLS。如果你不断开 Observer,路由切换前后的偏移会被错误地归到同一个窗口里。

所以断开之后要手动重置:

let clsValue = 0;
let clsSessionStart = 0;
let clsSessionEntries = [];

function resetCLSState() {
  clsValue = 0;
  clsSessionStart = 0;
  clsSessionEntries = [];
}

第三步,重新创建 Observer 并标记新的起始时间基准。

重建时需要注意:LCP 的计算依赖页面首次开始加载的时间点作为基准,SPA 路由切换没有这个时间点,所以我们需要自己记录路由切换发生的时刻,在回调里用它来修正 LCP 的计算。

let navigationStart = 0;

function initObservers() {
  navigationStart = performance.now();
  
  // 重建 LCP Observer
  lcpObserver = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    // 只取当前路由之后的条目
    const validEntries = entries.filter(
      (entry) => entry.startTime >= navigationStart
    );
    if (validEntries.length === 0) return;
    
    // LCP 是最后一个候选元素出现的时间
    const lastEntry = validEntries[validEntries.length - 1];
    // 这里用 navigationStart 作为时间原点,计算相对耗时
    const lcp = lastEntry.startTime - navigationStart;
    
    // 当页面完全稳定后上报
    // 实际项目里可以结合路由切换后的网络请求状态来判断
  });
  
  lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
  
  // 重建 CLS Observer
  clsObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 过滤掉路由切换前和那些没有最近输入的偏移
      if (entry.startTime < navigationStart || !entry.hadRecentInput) {
        continue;
      }
      
      // 会话窗口逻辑
      if (clsSessionStart === 0) {
        clsSessionStart = entry.startTime;
        clsSessionEntries = [entry];
      } else if (
        entry.startTime - clsSessionEntries[clsSessionEntries.length - 1].startTime <= 1000 &&
        entry.startTime - clsSessionStart <= 5000
      ) {
        clsSessionEntries.push(entry);
      } else {
        // 窗口关闭,计算当前窗口的 CLS
        const windowCLS = clsSessionEntries.reduce((sum, e) => sum + e.value, 0);
        clsValue = Math.max(clsValue, windowCLS);
        // 开启新窗口
        clsSessionStart = entry.startTime;
        clsSessionEntries = [entry];
      }
    }
  });
  
  clsObserver.observe({ type: 'layout-shift', buffered: true });
}

这里 buffered: true 是个关键参数。重建 Observer 时,浏览器会把缓冲区里已经发生但还没被消费的条目一次性推过来。如果没有这个参数,那些在路由切换瞬间到 Observer 重新创建之间发生的布局偏移或 LCP 候选会出现漏报。

什么时候上报数据

Observer 重建之后,不能立即上报 LCP,因为此时页面可能还在渲染,LCP 候选元素会不断更新。浏览器的 LCP 算法会在用户首次交互(点击、滚动、按键)或者页面完全稳定后确定最终值。

在 SPA 场景下,我用的判断逻辑是:监听路由切换后发起的 API 请求,当所有关键请求完成且页面在 1 秒内没有新的 LCP 候选出现,就认为 LCP 已经稳定。

let lcpFinalized = false;
let lcpCheckTimer = null;

function checkLCPStable(currentLCP, callback) {
  if (lcpFinalized) return;
  
  clearTimeout(lcpCheckTimer);
  lcpCheckTimer = setTimeout(() => {
    lcpFinalized = true;
    callback(currentLCP);
  }, 1000);
}

// 在 LCP Observer 回调里
const lcp = lastEntry.startTime - navigationStart;
checkLCPStable(lcp, (finalLCP) => {
  // 上报 finalLCP
});

CLS 的上报时机更复杂。有些页面在用户交互后还会产生布局偏移(比如展开一个折叠面板),这些偏移如果 hadRecentInput 为 true,浏览器会自动排除。但如果是非用户触发的偏移,需要等页面生命周期结束才上报。实际项目里我通常监听路由离开事件,在离开当前路由时关闭 CLS 会话窗口并上报最终值。

处理跨路由的 FCP 缺失

FCP 在 SPA 路由切换后根本不会产生新的 PerformancePaintTiming 条目。如果你的监控系统必须要有 FCP,有两个替代方案:

一是用 PerformanceObserver 监听 first-paint(但这个在软导航下同样不会触发),所以实际上只剩方案二:自己算。在路由切换后,用 MutationObserver 监听 DOM 中第一个非空白文本或图片元素的渲染时间,把那个时间戳减去 navigationStart 作为自定义的 FCP。

const domObserver = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.addedNodes.length > 0) {
      const firstPaintCandidate = document.querySelector('p, h1, h2, img, svg');
      if (firstPaintCandidate) {
        const fcp = performance.now() - navigationStart;
        domObserver.disconnect();
        // 上报自定义 FCP
        break;
      }
    }
  }
});

domObserver.observe(document.body, { childList: true, subtree: true });

这个值当然不是标准的 FCP,但在 SPA 场景下,它比上一个页面的 FCP 更有参考意义。

在 Vue Router 和 React Router 里的接入点

Vue Router 4.x 里,router.beforeEach 是断开旧 Observer 的最佳时机,router.afterEach 或组件 onMounted 里初始化新的 Observer。

React Router v6 里没有全局的路由守卫,我习惯在根布局组件里用 useLocation 的副作用来处理:

function AppLayout() {
  const location = useLocation();
  
  useEffect(() => {
    disconnectObservers();
    resetCLSState();
    // 给 React 一个渲染帧的时间
    requestAnimationFrame(() => {
      initObservers();
    });
  }, [location.pathname]);
  
  return <Outlet />;
}

注意那个 requestAnimationFrame。如果在路由变化的同一个微任务里重建 Observer,React 可能还没开始渲染新页面的 DOM,LCP Observer 的 buffered: true 拿不到任何条目。延迟一个渲染帧可以确保新页面的元素已经开始绘制。

常见问题

问:web-vitals 库的 onFCPonLCP 等方法在 SPA 里直接用就行了吗?

不能直接依赖。web-vitals 库 v3.3.0 版本开始通过监听 soft-navigation 条目来支持 SPA,但这个 API 目前只有 Chrome 110+ 支持,且需要你在路由切换后手动调用 performance.measureUserAgentSpecificMemory() 之类的标记方法才能触发。如果你的用户群体包含大量 Safari 或 Firefox 用户(比如移动端占比高),web-vitals 库在 SPA 里实际上只会上报首页的数据,后续路由切换完全不会触发新的回调。

问:每次路由切换都重建 Observer 会不会有性能问题?

PerformanceObserver 本身很轻量,disconnect 和重新 observe 的开销在微秒级别,远小于一次 React 渲染或网络请求。我在一个单页应用里用 Chrome DevTools 的 Performance 面板实测过,路由切换时完整的 Observer 重建流程耗时约 0.3-0.5ms,完全不会成为瓶颈。真正需要注意的是不要在 Observer 回调里做重计算——回调的执行频率取决于布局偏移和 LCP 候选出现的频率,在高频 CLS 页面(比如有持续动画的页面)里,回调可能每帧都触发。

问:用 buffered: true 重建后,会不会拿到上一个路由的残留条目?

会,所以要配合 navigationStart 时间戳过滤。buffered: true 会让浏览器把缓冲区里所有未被消费的条目一次性推过来,这些条目里包含路由切换前产生的。entry.startTime 是相对于页面 timeOrigin 的绝对时间,而我们记录的 navigationStart = performance.now() 也是相对于同一个 timeOrigin,所以用 entry.startTime >= navigationStart 过滤就能精准拿到当前路由之后的条目。

问:如果用户快速连续切换路由(比如点了多次返回),CLS 和 LCP 的上报会乱吗?

会的,这也是最难处理的情况。每次路由切换时断开旧 Observer,会导致上一个路由的 Observer 还没等到 LCP 稳定就被销毁了,那个路由的 LCP 数据就丢失了。解决办法是给每个路由分配一个唯一标识(比如用自增 ID 或时间戳 + 路径),在 Observer 回调里检查当前上报是否仍然属于「活跃」的路由,如果不是就丢弃。同时把断开 Observer 的逻辑延迟到新路由的 LCP 稳定之后,而不是在路由切换瞬间立即断开。