Web 性能

# 性能指标

You can't manage what you can't measure. —— Peter Drucker (opens new window)

「如果你无法衡量它,那么你就无法管理它」,性能优化同样如此,那么我们可以通过哪些指标来对页面性能、用户体验就行度量呢?

  • Performance API
  • 页面流畅度
    • FPS
    • RAF(requestAnimationFrame)
  • 首屏性能
    • FP、FCP、FMP
    • CWV(Core Web Vitals)

# Performance API

通常我们将浏览器提供的可以进行测算和采集的 API 统称为 Performance API,该类型的对象可以通过调用只读属性 window.performance 来获得。

在日常的工作中,我们为了计算一个任务的耗时,通常会在任务执行前后利用 Date.now() 分别创建两个时间,最后取两者的差值作为任务执行耗时。但是 Date.now() 存在两个问题:

  1. 返回的时间戳是从 1970 年 1 月 1 日 00:00:00 UTC 开始经过的毫秒数,依赖于系统时间
  2. 精度仅到毫秒(ms)

为了对性能做精准的计算,我们可以选择 Performance API 提供的 performance.now() 来进行高精度计算,其特性为:

  1. 时间戳基于页面打开的时间计算
  2. 精度精确到微秒(us)

可以在控制台打印,更直观看出二者差异:

console.log(Date.now());        // 1655740456043
console.log(performance.now()); // 1070838.1999999285

# 页面流畅度

# FPS

FPS (Frames Per Second, 帧率,每秒传输帧数 ),一般对于网页而言,最优的帧率在 60FPS,如果越接近这个值,页面就越流畅,帧率如果远低于这个值,用户可能会明显感觉到卡顿。

60FPS 意味着页面每隔 16.5ms (1/60) 就需要渲染一次,否则就会出现丢帧的现象,而浏览器中的 JavaScript 执行和页面渲染都是会相互阻塞的,如果在代码中有非常复杂的逻辑占用了大量的执行时长,就会导致页面出现卡顿。

在 Chrome 的 devtools 中我们可以执行 Cmd+Shift+P 输入 show fps 来快速打开 fps 面板,如下图所示:

Run Command

通过观察 FPS 面板,我们可以很方便的对当前页面的流畅度进行监控:

FPS Panel

我们在代码中如果想对当前页面的 FPS 帧率进行监控,可以参考如下这段示例代码:

let frame = 0;
let lastTime = performance.now();
let lastFameTime = performance.now();

const loop = function(time) {
    const now = performance.now();
    const fs = (now - lastFameTime);
    lastFameTime = now;
    let fps = Math.round(1000/fs);
    frame++;
    if(now > 1000 + lastTime) {
        fps = Math.round((frame * 1000) / (now - lastTime));
        frame = 0;
        lastTime = now;
    };
    window.requestAnimationFrame(loop);
}

requestAnimationFrame:

window.requestAnimationFrame() 告诉浏览器你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。

这里借用 MDN 的描述,顾名思义就是传入一个函数,让浏览器在下一次渲染之前进行调用。那么基于这个特性,结合上面提供的 FPS 计算示例代码,我们可以发现,如果我们持续对 requestAnimationFrame 进行调用,那么每次调用的间隔应该在 16.7ms 左右,即满足我们对于页面流畅度 60FPS 的要求,可以使用如下代码在控制台执行试试看:

let lastTime = 0;
const measure = () => {
    console.log(`${Date.now() - lastTime}ms`);
    lastTime = Date.now();
    requestAnimationFrame(measure);
};
measure();

# 首屏性能

首屏性能作为我们最关心的核心指标之一,在性能优化的场景中占据了相当大的比重,那么对于首屏的性能我们有哪些衡量指标呢?针对这个问题,Google 曾经提出过一系列的以用户体验为中心的性能指标。

# FP

FP (First Paint, 首次绘制),代表浏览器第一次向屏幕传输像素的时间,仅表示当前已经开始绘制了,实际意义比较小。

# FCP

FCP (First Contentful Paint, 首次内容绘制),代表浏览器第一次向屏幕绘制「内容」(只有首次绘制文本、图片 - 包含背景图、非白色)。

相比 FP,FCP 指的是浏览器首次绘制来自 DOM 的内容。例如:文本,图片,SVG,canvas元素等,这个时间点叫 FCP。

# FMP

FMP (First Meaningful Paint, 首次有效绘制),表示页面中有意义的内容开始出现在屏幕上的时间点。它也是我们来衡量用户加载体验的主要指标

FMP 本质上是一个主观认知指标,是通过一个算法来猜测某个时间点可能是 FMP,但是计算方式过于复杂而且不准确,后来 Google 也放弃了 FMP 的探测算法,转而采用更加明确的客观指标 - LCP

# CWV

CWV (Core Web Vitals, 核心 Web 指标),是适用于所有网页的 Web 指标子集,每位网站所有者都应该测量这些指标,并且这些指标还将显示在所有 Google 工具中。每项核心 Web 指标代表用户体验的一个不同方面,能够进行实际测量,并且反映出以用户为中心的关键结果的真实体验。

目前的 Web 核心指标由三个方面构成:页面加载性能交互性视觉稳定性,包含如下三个指标及阈值:

核心 Web 指标

  • LCP: Largest Contentful Paint,最大内容绘制,测量加载性能。为了提供良好的用户体验,LCP 应在页面首次开始加载后的 2.5 秒内发生。
  • FID: First Input Delay,首次输入延迟,测量交互性。为了提供良好的用户体验,页面的 FID 应为 100 毫秒或更短。
  • CLS: Cumulative Layout Shift,累积布局偏移,测量视觉稳定性。为了提供良好的用户体验,页面的 CLS 应保持在 0.1. 或更少。
# LCP

LCP 关注的是首屏中最大元素渲染渲染的时间,和 FCP 不同的是,FCP 更关注浏览器什么时候开始绘制内容,比如一个 loading 页面或者骨架屏,并没有实际价值,所以 LCP 相较于 FCP 更适合作为首屏指标。

拿 Detail 页举例,在 FCP 时,商品图片并未加载,此时对于用户而言,一个近乎白屏的页面是不具备可交互价值的,在 LCP 时,图片已经完成了加载,首屏主要元素也几乎加载完毕,此时的时间作为首屏时间,才是比较接近用户体感的。

既然 LCP 是根据页面上占据面积最大的元素渲染时间确定的,那么元素包含哪些呢:

  • 图片
  • 内嵌在 svg 中的 image 元素
  • 视频的封面
  • 通过 url() 加载的 Background Image
  • 文字

webpagetest.org (opens new window) 上可以很直观的看到当前 LCP 元素的详情信息:

元素面积的计算规则有如下几点:

  • 在 viewport 内可见元素的大小,如果是超出可视区域或者被裁减、遮挡等,都不算入该元素大小
  • 对于图片元素来说,大小是取图片实际大小和原始大小的较小值,即 Min(实际大小,原始大小)
  • 对于文字元素,只取能够覆盖文字的最小矩形面积
  • 对所有元素,marginpaddingborder 等都不算
# FID

FID 指标是指用户首次和网站进行交互到浏览器响应该事件的实际延时时间,可以想象一下,如果在你点击了一个 Button 后,页面没有任何变化,2-3s 后才开始响应,可想而知体验是非常糟糕的。

FID 判定的交互行为有:

  • 点击、触摸、按键等(不包含滚动和缩放)
  • 有事件绑定的行为,比如注册在某个 DOM 上的 click 事件

那么为什么会产生交互延迟呢?比如我在 button 上注册了一个 click 事件,例如:

btn.addEventListener('click', () => {
    // do something
});

按照预期,用户点击按钮的时候,回调函数会被直接触发,但是如果当前主线程被渲染、Long Tasks 占用,这个回调的执行就会被延后,就会导致 FID 时长增加。

但是 FID 作为一个「非客观值」,需要用户进行交互才能采集到,用户的交互时机,同样也会对指标的采集、统计造成影响。

# CLS

CLS 是用来衡量视觉界面稳定性的一个指标,指的是页面产生的连续累计布局偏移分数。我们在日常业务中经常会用到懒加载、骨架屏等方式,用较低的成本先展示页面框架,再用动态渲染的方式,来对页面内容进行填充,如果此时布局发生变化,比如动态加载的元素和原本占位的元素大小不一致,可能就会导致用户误操作,影响用户体验,CLS 就是为了度量这类问题而存在。

当我们在说布局偏移的时候,指的是:页面中一个可见元素的起始位置发生改变,而元素的增删则并不会触发布局偏移。那么如何定义偏移的连续累计呢?有如下几个要素:

  • CLS 计算的并非页面整个周期的偏移分数之和,而是累计值最高的连续布局偏移
  • 偏移相隔的时间少于 1s,且整个窗口的最大持续时间为 5s,则被计为连续偏移

# 优化策略

# 网络

  • 预连接
    • <link rel="preconnect" href="https://cdn.domain.com">
    • <link rel="dns-prefetch" href="https://cdn.domain.com">
  • 预加载
    • <link rel="preload" href="http://example.com/xxx.js" />
    • <link rel="prefetch" href="http://example.com/xxx.js" />
  • 预渲染
    • <link rel="prerender" href="http://example.com/xxx" />
  • CDN
  • 使用 HTTP/2.0
  • 资源优化
    • 体积优化
    • 加载优化

我们先看一张图:

网络请求耗时分析

这是本站首页的网络请求耗时,着重看三个时间指标:

  • Total Connection Time: 整体的连接耗时
  • TTFB (Time to First Byte): 首字节传输耗时
  • Content Download: 内容传输耗时

# 预连接

在慢速网络中建立连接通常需要消耗大量时间,尤其是在涉及安全连接时,因为它可能涉及到 DNS 查找、重定向以及用于处理用户请求而与最终服务器的多次往返。可以通过 preconnect, dns-prefetch 优化连接耗时。比如:

<link rel="preconnect" href="https://cdn.domain.com" crossorigin>
<link rel="dns-prefetch" href="https://cdn.domain.com">

几个注意点:

  • dns-prefetch 仅对跨域的 DNS 查找有效,因此请避免使用它来指向你的站点或域。这是因为,到浏览器解析到该标签时,站点域背后的 IP 已经被解析
  • 还可以通过使用 HTTP 链接字段将 dns-prefetch(以及其他资源提示)指定为 HTTP 标头
    Link: <https://fonts.gstatic.com/>; rel=dns-prefetch
    
  • 可以将 dns-prefetchpreconnect 一起使用,dns-prefetch 仅执行 DNS 查找,但 preconnect 会建立与服务器的连接,如果站点是通过 HTTPS 服务的,则此过程包括 DNS 解析,建立 TCP 连接以及执行 TLS 握手。将两者结合起来可以进一步减少跨域请求的延迟感知

  • 浏览器对于 dns-prefetch 的支持比对 preconnect 的支持要好
  • 如果页面需要建立与许多第三方域的连接,若将它们全部 preconnect 可能会适得其反。 preconnect 提示最好仅用于最关键的连接。对于其他的,只需使用 <link rel="dns-prefetch"> 即可节省第一步的时间 - DNS 查找
  • 一些资源(如字体)以匿名模式加载。在这种情况下,应给预连接增加 crossorigin 属性。如果省略它,则浏览器将仅执行 DNS 查找。

# 预加载

可以通过 preload, prefetch 减少内容传输耗时,优化首屏时间。

# preload

preload 是告诉浏览器预先请求当前页面需要的资源(关键的脚本,字体,主要图片等)。当资源真正被使用的时候立即执行,就无需等待网络的消耗。它可以通过 Link 标签进行创建:

<!-- 使用 link 标签静态标记需要预加载的资源 -->
<link rel="preload" href="/path/to/style.css" as="style">

<!-- 或使用脚本动态创建一个 link 标签后插入到 head 头部 -->
<script>
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'style';
    link.href = '/path/to/style.css';
    document.head.appendChild(link);
</script>

当浏览器解析到这行代码就会去加载 href 中对应的资源但不执行,待到真正使用到的时候再执行,另一种方式方式就是在 HTTP 响应头中加上 preload 字段:

Link: <https://example.com/other/styles.css>; rel=preload; as=style

这种方式比通过 Link 响应头方式加载资源方式更快,请求在返回还没到解析页面的时候就已经开始预加载资源了。

# prefetch

prefetchpreload 不同,它的作用是告诉浏览器未来可能会使用到的某个资源,浏览器就会在闲时去加载对应的资源。用法跟 preload 一样:

Html 模式:

<link rel="prefetch" href="/path/to/style.css" as="style">

HTTP 响应头模式:

Link: <https://example.com/other/styles.css>; rel=prefetch; as=style

  • 当一个资源被 preload 或者 prefetch 获取后,它将被放在内存缓存中等待被使用,如果资源存在有效的缓存机制(如 cache-control 或 max-age),它将被存储在 HTTP 缓存中,可以被不同页面所使用
  • 正确使用 preload / prefetch 不会造成二次下载,也就说:当页面上使用到这个资源时候 preload 资源还没下载完,这时候不会造成二次下载,会等待第一次下载并执行脚本
  • 对于 preload 来说,一旦页面关闭了,它就会立即停止 preload 获取资源,而对于 prefetch 资源,即使页面关闭,prefetch 发起的请求仍会进行不会中断
  • 没有用到的 preload 资源在 Chrome 的 console 里会在 onload 事件 3s 后发生警告
  • preload 是告诉浏览器页面必定需要的资源,浏览器一定会加载这些资源,而 prefetch 是告诉浏览器页面可能需要的资源,浏览器不一定会加载这些资源。没有用到的 preload 资源在 Chrome 的 console 里会在 onload 事件 3s 后发生警告。建议对于当前页面很有必要的资源使用 preload,对于可能在将来的页面中使用的资源使用 prefetch

什么情况会导致二次获取

  • 对于同一个资源同时使用 preloadprefetch 会造成二次的下载
  • preload 字体不带 crossorigin 也将会二次获取

是否会对用户的带宽造成浪费

preloadprefetch 情况下,如果资源不能被缓存,那么都有可能浪费一部分带宽,在移动端请慎用。

如何检测 preload 支持情况

const preloadSupported = () => {
    const link = document.createElement('link');
    const relList = link.relList;
    if (!relList || !relList.supports) return false;
    return relList.supports('preload');
};

# 缓存

  • HTTP 缓存
    • keep-alive
    • 强缓存
    • 协商缓存
  • ServiceWorker

# 渲染

  • 懒执行
    • Web Worker
  • 懒加载
  • 渲染性能
    • 事件代理
    • 防抖和节流
    • 减少回流和重绘
    • 利用 GPU 加速
    • 服务端渲染

# 懒执行

懒执行就是将某些逻辑延迟到使用时再计算。该技术可以用于首屏优化,对于某些耗时逻辑并不需要在首屏就使用的,就可以使用懒执行。懒执行需要唤醒,一般可以通过定时器或者事件的调用来唤醒。

# Web Worker

JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

# 懒加载

懒加载就是将不关键的资源延后加载。

懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的 src 属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src 属性,这样图片就会去下载资源,实现了图片懒加载。

懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等等。

# 相关链接

# 参考资料

上次更新: 2022/6/21 10:03:39