前端性能优化-性能指标

3,975 阅读13分钟

性能指标是理解和提升网页用户体验的关键。

主要的性能指标包括:

  • First Contentful Paint (FCP)
  • Largest Contentful Paint (LCP)
  • First Input Delay (FID)
  • Time to Interactive (TTI)
  • Total Blocking Time (TBT)
  • Cumulative Layout Shift (CLS)

下面我们将逐一介绍。

First Contentful Paint(FCP)

First Contentful Paint(FCP) 是用于度量感知加载速度(用户肉眼可观察)的重要指标。它标记了页面加载时间轴上的第一个点,在这个时间点用户可以看到页面出现了具体内容。

FCP 是什么

FCP 度量了从页面开始加载到页面内容的任何部分在屏幕上完成渲染的时间。这里的页面内容指的是文本,图片(包括背景图),<svg> 元素,非空白的 <canvas> 元素。

FCP.png

在上图的加载时间线中,FCP 出现在第二帧,因为此时在屏幕上出现了第一个文本和图片。

我们注意到,此时屏幕上仅渲染了部分而不是全部的内容。这是 FCP 和 LCP 之间的重要区别。

如何度量 FCP

FCP 可以在开发环境和生产环境进行度量,这两种环境都有各自的度量工具。

生产环境

PageSpeed Insights
Chrome User Experience Report
Search Console (Speed Report)
web-vitals JavaScript library

开发环境

Lighthouse
Chrome DevTools

利用 JavaScript 度量 FCP

我们可以通过 Paint Timing 来度量 FCP。

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntriesByName('first-contentful-paint')) {
    console.log('FCP candidate:', entry.startTime, entry);
  }
}).observe({type: 'paint', buffered: true});

这里我们创建了一个 PerformanceObserver 对象来监听 first-contentful-paint

这里仅仅是打印了 first-contentful-paint entry, 实际情况比这复杂的多,下面会具体介绍。

在上面的示例中,打印的 first-contentful-paint entry 会告诉我们首个元素出现在屏幕上的时间,但在某些情况下,这个 entry 是不准确的。

通过 Paint Timing API 得到的数据和实际数据是有差异的。表现在下面三个方面:

  • API 会向在后台加载(Tabs 标签页)的页面 dispatch 一个 first-contentful-paint entry, 但实际会忽略这些页面(只有当页面整个时间都在前台时,才应该考虑首次绘制时间)。
  • API 不会向从后退/前进缓存恢复的页面派发 first-contentful-paint entries, 但实际应该考虑进来,因为用户体验到的是不同的页面访问。
  • API 不会计算来自跨域 iframes 的绘制时间,但要正确的度量 FCP , 我们应该考虑到所有的 iframe。子 iframe 可以使用 API 向其父报告它的绘制时间,父会对其进行合并。

在使用 API 度量 FCP 时处理这些细微的差别事倍功半,我们可以使用 web-vitals 库来度量 FCP, 这样就无需我们亲自处理这些差别了。

import {getFCP} from 'web-vitals';

// Measure and log FCP as soon as it's available.
getFCP(console.log);

在某些情况下(比如跨域 iframes),通过 JavaScript 不可能度量出 FCP。web-vitals 库说明了其存在的局限

如何提升 FCP

我们可以使用 Lighthouse 性能审查工具发现提升网页 FCP 指标的机会。注意观察 Opportunities 和 Diagnostics 列表。

想要了解如何提升 FCP, 请继续关注后面的内容。

Largest Contentful Paint(LCP)

Largest Contentful Paint(LCP) 是用于度量感知加载速度(用户肉眼可观察)的重要指标。它标记了页面加载时间轴上的一个点,在这个时间点页面的主要内容已经加载。

度量网页主要内容的加载速度和对用户可见的速度一直是前端开发面临的一个挑战。

像传统的 Load 或是 DOMContentLoaded 事件并不完美, 因为它们不一定与用户在屏幕上看到的内容相对应。而 LCP 仅仅捕获加载的最开始阶段,如果页面有启动界面或是加载进度显示,此时与用户是无关的。

LCP 是什么

LCP 表示的是从首次加载到最大的图片或是文本块出现在视图内的渲染时间。

LCP.svg

为了提供良好的用户体验,我们应该将 LCP 控制在 2.5s 的范围内。

LCP 考虑的元素类型包括:

  • <img> 元素
  • <svg> 元素内的 <image> 元素
  • <video> 元素(使用了封面图片)
  • 通过 url() 加载背景图的元素
  • 包含文本节点或是其它内联文本元素的块级元素

下面有几个 LCP 的示例。

LCP-1.png LCP-2.png

在上面的两条时间线中,最大的元素随着内容的加载而变化。在第一个例子中,新的内容被添加到 DOM 中,这改变了最大元素。在第二个例子中,布局变化和旧的最大元素从视图中消失改变了最大元素。

通常情况下,后加载的内容比页面上已经存在的内容更大,但情况并非总是如此。接下来的两个示例展示了在页面完全加载之前就出现了最大元素。

LCP-3.png LCP-4.png

在第一个例子中,Instagram 的 logo 加载相对较早,即使其它内容逐步显示,它仍然是最大的元素。在谷歌搜索结果页面的例子中,最大的元素是在任何图片或 logo 加载完成之前显示的一段文本。因为所有单独的图片都比这个段落小,所以在整个加载过程中,它仍然是最大的元素。

如何度量 LCP

这里和 FCP 是一样的,不再赘述。

利用 JavaScript 度量 LCP

和 FCP 类似。

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP candidate:', entry.startTime, entry);
  }
}).observe({type: 'largest-contentful-paint', buffered: true});

这里同样存在和 FCP 一样的问题 - 度量结果不准确,解决办法依旧建议使用 web-vitals

import {getLCP} from 'web-vitals';

// Measure and log LCP as soon as it's available.
getLCP(console.log);

First Input Delay(FID)

First Input Delay(FID) 是用于度量加载响应速度的重要指标。它量化了用户与页面交互时的体验。

FID 是什么

FID 度量用户从第一次与页面交互(例如,当他们点击一个链接,点击一个按钮,或者使用一个自定义的 javascript 组件)到浏览器真正能够开始处理事件处理程序以响应该交互的时间。

FID.svg

为了提供良好的用户体验,我们应该将 FID 控制在 100ms 的范围内。

一般 input 延迟是因为浏览器主线程繁忙不能及时响应用户,最通常的原因就是浏览器忙于处理和执行大的 JavaScript 文件。

FID 度量的仅仅是事件处理的延迟,它不会考虑事件处理时间本身和浏览器在执行事件处理程序之后更新页面 UI 的时间。

下面是一个典型的 web 页面加载时间线。

FID-1.svg

页面对资源(很可能是 CSS 和 JS 文件)发出一些网络请求,在这些资源完成下载后,它们将在主线程上处理。这会导致主线程暂时繁忙(米黄色任务块)。

较长的首次输入延迟通常发生在 FCP 和交互时间 TTI 之间。

FID-2.svg

我们注意到,在 FCP 和 TTI 之间,有相当长的时间(包括三个长任务),如果用户在这段时间试图与页面交互(例如,单击一个链接),从浏览器接收到点击事件到主线程能够响应会有一段延迟。

考虑一下,如果用户试图在最长任务的开头与页面进行交互,会发生什么情况。

FID-3.svg

因为交互发生在浏览器处理任务的过程中,所以它必须等到任务完成后才能对交互作出响应。等待的时间就是该用户在这个页面上的 FID 值。FID 并不是一个固定的值,因为用户完全有可能在任务开始的早些时间(空闲时间)与页面进行交互,这个时候是没有 FID 的。

FID 只关注来自离散操作的输入事件,如单击事件、按键事件, 像滚动和缩放这样的连续交互动作是不考虑的。

如何度量 FID

FID 只能够在生产环境下度量,因为它需要真实的用户去和页面交互。生产环境下的度量工具和其它指标一样。

利用 JavaScript 度量 FID

和 FCP 类似。

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    const delay = entry.processingStart - entry.startTime;
    console.log('FID candidate:', delay, entry);
  }
}).observe({type: 'first-input', buffered: true});

这里同样存在和 FCP 一样的问题 - 度量结果不准确,解决办法依旧建议使用 web-vitals

import {getFID} from 'web-vitals';

// Measure and log FID as soon as it's available.
getFID(console.log);

Time to Interactive(TTI)

Time to Interactive(TTI) 是用于度量加载响应速度的重要开发环境指标。它有助于识别页面看起来是可交互实则不是的情况。

TTI 是什么

TTI 度量从页面开始加载到主要的子资源加载完成并且能够快速可靠地响应用户交互的时间。

我们根据网页的性能追踪结果来计算 TTI, 遵循下面的步骤:

  • 从 FCP 开始
  • 向前搜索至少 5 秒的静默窗口,其中静默窗口定义为:没有长任务和不超过两个正在运行的网络 GET 请求
  • 向后搜索在静默窗口之前的最后一个长任务,如果没有找到长任务就以 FCP 为准
  • TTI 就是最后一个长任务(如果没有找到长任务,就是 FCP)结束之后,静默窗口之前的这段时间

为了提供良好的用户体验,我们应该将 TTI 控制在 5s 的范围内。

TTI.svg

为了提供良好的用户体验,网站应该努力将互动时间控制在5秒以内

在过去,前端开发人员为了加快页面渲染速度而牺牲了 TTI 指标。

像服务端渲染(SSR)这样的技术可能让页面看起来具有交互性(也就是说,链接和按钮在屏幕上可见),但实际上它并不是可交互的,因为主线线程被阻塞了,或者因为控制这些元素的 JavaScript 代码没有加载。

为了避免这个问题,尽量减少 FCP 和 TTI 之间的差异。在某些情况下,确实存在明显的差异,可以通过加载动画告知用户页面还不具备交互性。

如何度量 TTI

最好在开发环境度量 TTI, 最好的方式是使用 Lighthouse 工具审查页面。

Total Blocking Time(TBT)

Total Blocking Time(TBT) 是用于度量 FCP 和 TTI 之间的总的阻塞时间。

当有长任务(在主线程上的运行时间超过 50ms 的)存在时我们就认为主线程是阻塞的。我们说主线程被阻塞是因为浏览器不能中断正在进行的任务。因此,当用户在一个长任务运行时与页面交互,浏览器必须等待任务完成后才能响应。

如果任务足够长(例如超过 50 ms),用户很可能会注意到页面加载延迟。

一个页面总的阻塞时间是发生在 FCP 和 TTI 之间的每个长任务的阻塞时间之和。

我们看下页面加载期间主线程运行的时间线。

TBT.svg

这里共有五个任务,其中的三个是长任务,因为它们的执行时间超过了 50ms, 下面的图展示了每个长任务的阻塞时间。

TBT-1.svg

因此,虽然在主线程上运行任务的总时间是 560ms,但其中只有 345ms 被认为是阻塞时间。

任务执行时间总的阻塞时间
任务1250ms200ms
任务290ms40ms
任务335ms0ms
任务430ms0ms
任务5155ms105ms
总的阻塞时间345ms

如何度量 TBT

在开发环境通过 Chrome DevTools 和 Lighthouse 即可。

Cumulative Layout Shift(CLS)

CLS 度量的是在页面的整个生命周期中每一次意外布局移位的总和。

如果一个可见元素在前后两帧中的位置发生了变化,就会发生布局移位。要注意的是,只有当现有元素改变了它们的起始位置时,布局才会发生变化,如果一个新元素被添加到 DOM 中,或者某个元素改变了大小,只要这个改变不会导致其它可见元素的起始位置发生变化,它的布局就没有变化。

CLS.svg

为了提供良好的用户体验,我们应该将 CLS 控制在 0.1 的范围内。

下面来看下这个 0.1 是怎么来的。

CLS 的计算

为了计算布局移位,浏览器会查看视口的大小和两帧之间视口中不稳定元素的位移。布局移位是和元素位移相关的两个参数的乘积:

layout shift score = impact fraction * distance fraction

impact fraction 表示不稳定元素对两帧间视口区域的影响。

不稳定元素在前后两帧视口中所占区域的并集就是 impact fraction。

CLS-1.png

在上面的示例中,左侧(也就是前一帧)图片内的元素占据了视口的一半区域,右侧(也就是后一帧)图片内的元素向下移动了视口高度的 25%。红色虚线框表示的就是这两帧中元素可见区域的并集,也就是 75%, 所以 impact fraction 就是 0.75。

布局移动计算公式的另一部分是用来度量不稳定元素相对于视口移动的距离。distance fraction 是不稳定元素最大移动距离(水平或垂直)和视口最大尺寸(宽度或高度,看哪个更大)的比值。

CLS-2.png

在上面的例子中,最大的视口尺寸是高度,不稳定元素移动了视口高度的 25%,因此 distance fraction 就是 0.25。

最后可以得到布局移位就是 0.75 * 0.25 = 0.1875

布局移位并不是毫无可取之处,事实上,许多动态 web 应用程序经常改变页面上元素的起始位置。如果布局移位是按照用户预期产生的,并无害处。为了响应用户交互(比如点击链接,按下按钮,搜索框输入等)而产生的移位通常是没有问题的,只要这些变化不对用户的交互产生太大影响。

动画和过渡,如果做得好,是一个更新页面内容的好方法,不会让用户感到突兀。页面上的内容突然移动会造成糟糕的用户体验。但是内容从一个位置逐渐且自然地移动到另一个位置通常可以帮助用户更好地理解发生了什么,并在状态变化期间引导用户。

CSS transform 属性允许我们在不改变布局的情况下让元素动起来

  • 使用 transform: scale() 代替改变 height 和 width 属性
  • 要移动元素,避免更改 top、right、bottom 或 left 属性,而是使用 transform: translate()

如何度量 CLS

这里和 FCP 是一样的,不再赘述。

利用 JavaScript 度量 CLS

和 FCP 类似。

let cls = 0;

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!entry.hadRecentInput) {
      cls += entry.value;
      console.log('Current CLS value:', cls, entry);
    }
  }
}).observe({type: 'layout-shift', buffered: true});

这里同样存在和 FCP 一样的问题 - 度量结果不准确,解决办法依旧建议使用 web-vitals

import {getCLS} from 'web-vitals';

// Measure and log CLS in all situations
// where it needs to be reported.
getCLS(console.log);