最新消息: USBMI致力于为网友们分享Windows、安卓、IOS等主流手机系统相关的资讯以及评测、同时提供相关教程、应用、软件下载等服务。

看懂 Lighthouse 中 Performance 核心指标

常识 admin 158浏览 0评论

看懂 Lighthouse 中 Performance 核心指标

简介

Lighthouse 是谷歌开源的一款 Web 前端性能测试工具,它将针对此页面运行一连串的测试,然后生成一个有关页面性能的报告。本文中仅对 Performance 部分的指标进行介绍。

Performance 指标

Performance 项的总和得分由6个指标的性能按一定比例综合计算得到。下面是Lighthouse 性能分析的分值报告图例:

为了方便了解各项指标的分值占比、各项指标数据与得分的关系,可使用 Lighthouse Scoring Calculator 进行查看:

First Contentful Paint

首次内容渲染,简称 FCP。测量在用户导航到您的页面后浏览器呈现第一段 DOM 内容所需的时间。1.8 秒内达到快速级别。

如何提升

确保文本在 webfont 加载期间保持可见

确保文本在 webfont 加载期间保持可见。当网页使用自定义字体时,字体文件通常都是较大文件,需要一段时间才能加载完成,某些浏览器会在字体加载之前隐藏文本,从而导致不可见文本闪烁(FOIT)。

避免 FOIT 最简单方法是临时显示系统字体,font-display: swap 告诉浏览器使用自定义字体的文本应立即使用系统字体显示,自定义字体准备就绪后再替换系统字体(遗憾的是 swap 会导致重排)。

@font-face {font-family: 'Pacifico'; font-style: normal;font-weight: 400; src: local('Pacifico Regular'), local('Pacifico-Regular'), url(/s/pacifico/v12/FwZY7-Qmy14u9lezJ-6H6MmBp0u-.woff2) format('woff2'); font-display: swap;
 } 

消除阻塞渲染的资源

消除阻塞渲染的资源。报告中会列出阻止当前页面首次绘制的所有 URL。通过内联关键资源、推迟非关键资源和删除任何未使用的内容来减少这些阻止渲染的 URL 的影响。

哪些资源会阻塞渲染?

以下情况的脚本和样式表将会阻塞渲染:

  • <head> 中没有 deferasync 属性的 <script> 标签。
  • 没有 disabled 属性 或含有 media="all"<link rel="stylesheet"> 标签
<link href="style.css" rel="stylesheet" media="all">
<link href="style.css" rel="stylesheet"> // media 默认为 "all" 
如何识别关键资源?

使用 Chrome DevTools 中的 Coverage 选项卡 来识别非关键 CSS 和 JS。该选项卡会告诉你加载了多少代码、其中未实际使用到的代码。

绿色表示首次绘制所需的脚本/样式,即关键资源,红色则为非关键资源。

如何提取关键资源?

识别出关键资源后就需要区分出关键与非关键资源。对于 CSS 资源可以使用 Critical、CriticalCSS 或 Penthouse 进行提取,而 JS 资源通常结合如 Webpack 之类的打包工具对非关键资源进行拆分或懒加载👇🏻。

如何消除阻塞渲染的脚本?
  • 削减和压缩 JS 文件如果你使用 webpack 等构建工具,添加 TerserWebpackPlugin 对 JS 进行压缩混淆;开启 Tree Shaking 移除未使用的代码;使用 compression-webpack-plugin 进行编码压缩,如果使用了 CDN,通常也存在压缩算法等配置。
  • 减少未使用的 polyfill我们通常使用 Babel 对代码编译成指定环境能运行的代码,通过下面的预设/插件可以减少不必要的 polyfill:
  • @babel/preset-env 是一个智能预设,指定目标环境后即可使用最新的 JavaScript 特性,它会去管理需要哪些插件以及 polyfill。

  • @babel/plugin-transform-runtime 可以复用 Babel 注入的辅助代码以减少代码体积;另一个作用可以避免全局注入 polyfill。

  • 内联核心脚本实际项目中会把一些小但十分重要的 JS 资源通过 <script> ... </script> 内联的方式加载,比如 webpack 打包产物中的运行时资源(runtimeChunk),其中包含了 webpack 进行模块解析、加载、模块清单等代码。runtimeChunk 每次构建都会发生变化,单独提取出来可以避免非变更 chunk 的 contenthash 变更,从而更好的利用浏览器缓存。但这些代码通常只有几 KB 甚至更小,为此增加网络资源请求十分浪费,通过类似 InlineChunkHtmlPlugin 插件内联到 html 中是更好的选择。
  • 非关键脚本移至 </body> 之前HTML 解析过程中如遇到同步的 JS 会等待 JS 下载并执行完之后才继续解析,如果将 JS 资源放在 <head> 中可能造成页面持续白屏一段时间。所以当需要兼容一些旧浏览器时将所有的JS脚本都放在 </body> 之前是最好的选择。这样可以保证非脚本的其他一切元素能够以最快的速度得到加载和解析。
  • 异步加载其他非关键脚本deferasync 都可以实现异步加载 JS 资源,async 是无序异步加载,defer 则是有序顺序来异步加载。
<script src="xx.js" async></script>
<script src="xx.js" defer></script> 

async :并行下载后并直接运行,下载的过程不会阻塞 DOM 解析,但执行会,执行的时间与 DOMContentLoaded 不相关,可能领先也可能在其后。多个 async JS 无法保证按引入的顺序执行,所以当 JS 资源完全独立时可选择此方式加载。

defer :并行下载,但下载完成后不会立即执行,它们在 DOM 解析完成之后、DOMContentLoaded 之前按照引入顺序依次执行,因此不会阻塞 DOM 解析。

DOMContentLoaded :当初始的 HTML ****文档被完全加载和解析完成之后,DOMContentLoaded ****事件会被触发,而不必等待样式表,图片等资源完成加载。

Load :当整个页面及所有依赖资源如样式表和图片都已完成加载时,将触发load事件。

  • 代码拆分、懒加载主流的打包工具(如 webpack、Rollup)都可以通过动态导入来拆分包。动态导入的模块不会包含在初始包中,而是会进行懒加载,或在指定的时机加载。
import moduleA from "library";
form.addEventListener("submit", e => {e.preventDefault();someFunction();
});


// 使用 import()动态导入
form.addEventListener("submit", e => {e.preventDefault();import('library.moduleA').then(module => module.default).then(someFunction()).catch(handleError());
}); 

实际项目中懒加载所有第三方模块并不是很常见,通常使用 SplitChunksPlugin 等工具将第三方依赖被拆分并打包成一个单独的 vendors chunk 是更好的选择,因为它们不会经常更新,使用 chunkhash 可以保证每次构建 vendor chunk 的文件名不会变更,从而更好的进行持久化缓存。

使用 import()React.lazy() 在路由或组件级别进行模块拆分则是一种更简单的方式。

import * as React from "react";
import { Routes, Route } from "react-router-dom";

const About = React.lazy(() => import("./pages/About"));
export default function App() {return (<Routes><Route path="/" element={<Layout />}><Route index element={<Home />} /><Routepath="about"element={<React.Suspense fallback={<>...</>}><About /></React.Suspense>}/></Route></Routes>);
} 

还有一些专门进行懒加载的工具库可以使用,如 Loadable components:

// Component Splitting
import loadable from '@loadable/component';
const Home = loadable(() => import('./Home'));
// Library Splitting
const Moment = loadable.lib(() => import('moment')); 
如何消除阻塞渲染的样式?
  • 削减 CSS对于浏览器来说,CSS 文件中的空格、缩进或注释等字符都是不必要的,删除这些字符能减少 CSS 资源大小。optimize-css-assets-webpack-plugin、gulp-clean-css、rollup-plugin-css-porter 都是常用削减 CSS 的工具。
  • 内联关键样式将重要样式进行内联后,就不再需要通过往返请求来获取关键 CSS。您内联了大量 CSS,则会延迟 HTML 文档其余部分的传输,为了最大限度地减少首次渲染的往返次数,目标是将首屏内容保持在 14 KB(压缩)以下,将这些类放在<style> 中内联到 <head>中。
  • 延迟加载其他非关键样式```


`link rel="preload" as="style"` 表示异步请求样式表,`this.rel='stylesheet'` 表示完成加载后再以标准方式加载 `styles.css`。而 `this.onload=null` 将事件处理程序置空有助于避免某些浏览器在切换 `rel` 属性时重新调用处理程序。

`noscript`对于不执行 JavaScript 的浏览器,对元素内部样式表的引用可作为兜底。

### 预加载关键资源

打开一个网页时,浏览器从服务器请求 HTML 文档,然后解析其中内容,如其中存在其他引用的资源则会单独发起请求,比如 CSS 中的自定义字体资源。对于那些较晚被解析发现的资源,可能包含了我们认为的关键资源,所以我们希望能告知浏览器提前去请求这些关键资源,从而就可以加快加载过程。通过 `<link rel="preload">` 预加载资源就可以实现。

```

浏览器会下载并缓存预加载的资源,以便在需要时立即可用(它不执行脚本或应用样式表)。提供该 as属性有助于浏览器根据其类型设置预取资源的优先级,设置正确的标头,并确定资源是否已存在于缓存中。此属性接受的值包括 scriptstylefontimage 等。省略as属性或使用了其他无效值则等同于XHR 请求,浏览器将不知道它正在获取什么,因此无法确定正确的优先级。它还可能导致某些资源(例如脚本)被获取两次。

某些类型的资源类型如字体,需要以 crossorigin 模式加载,即在 <link> 上设置 crossorigin 属性。同时<link>还接受一个type属性,该属性包含链接资源的MIME 类型。浏览器使用该type属性的值来确保资源仅在其文件类型受支持时才被预加载。如果浏览器不支持指定的资源类型,它将忽略<link rel="preload">

<link rel="preload" href="ComicSans.woff2" as="font" type="font/woff2" crossorigin> 
预加载如何工作

上面的图例中,Pacifico 字体是在 CSS 中通过@font-face定义的。浏览器仅在完成下载和解析 CSS 后才开始加载字体文件。使用 <link rel="preload"> 将 Pacifico 字体预加载,字体下载与 CSS 并行下载。

如何确定要预加载的资源

关键请求链表示浏览器优先处理和获取的资源的顺序,Lighthouse 会将位于链中的第三层资源标记为你预加载链接的候选名单,并在 Lighthouse 报告的 Opportunities 部分的 Preload key requests 中进行展示:

假设你的页面的关键请求链如下:

index.html
|--app.js |--styles.css |--ui.js 

index.html文件声明<script src="app.js">app.js运行时会调用fetch()下载 styles.cssui.js,所以在下载、解析和执行最后 2 个资源之前页面不会完整显示。如果app.js下载、解析和执行需要 200 毫秒,那么预加载 styles.cssui.js 就可能潜在节省为 200 毫秒,从而使您的页面加载速度更快。

<head><link rel="preload" href="styles.css" as="style"><link rel="preload" href="ui.js" as="script">
</head> 

预加载浏览器发现较晚的重要资源对 FCP 和 TTI 都有较大提升。但是预加载所有内容会适得其反,因此仅预加载最关键的资源很重要。 load事件发生后大约 3 秒,未使用的预加载会在 Chrome 中触发控制台警告,这也是判断是否预加载了非关键资源的标志。

预加载策略
预加载 CSS 中定义的资源

预加载 CSS 中通过 @font-face 定义的字体资源可确保在下载 CSS 文件之前获取它们。需要注意的是添加crossorigin属性,否则预加载的字体将被提取两次。

<link rel="preload" href="ComicSans.woff2" as="font" type="font/woff2" crossorigin> 
预加载 CSS

如果已经提取了关键 CSS,则可以将 CSS分为两个部分,首屏所需的关键 CSS 内联在 中,而非关键 CSS 通常使用 JS 延迟加载,在加载非关键 CSS 之前等待 JS 执行会导致用户滚动时呈现延迟,因此使用 <link rel="preload">可以更快的启动下载。

预加载 JavaScript

由于浏览器不执行预加载的资源,因此预加载有助于将获取与执行分开,这可以改善交互时间等指标。如果能拆分出仅预加载关键 JS 资源,则预加载效果最佳。

使用 webpack 预加载 JavaScript 模块

增加 webpackPreload: true 注释即可注入预加载标签。

import(/* webpackPreload: true */ "CriticalChunk") 
其他预加载机制
<link rel="prefetch" href="xx.png">
<link rel="prerender" href="">
<link rel="preconnect" href="" crossorigin>
<link rel="dns-prefetch" href="//cdn.domain"> 
  • <link rel="prefetch">:低优先级资源声明,允许浏览器在空闲时获取并缓存资源。通常对首屏没有帮助,但在能预测用户下一步动向时,prefetch 能加快下一页面加载速度。
  • <link rel="prerender">:与 prefetch 类似,区别在于 prerender 会在空闲时获取 href 属性页面的所有资源。因为它将会加载很多资源并且可能造成带宽的浪费,所以在移动设备中尤其需要小心使用。
  • <link rel="preconnect">:允许浏览器在一个 HTTP 请求正式发给服务器前进行预连接。包括 DNS 解析,TLS 协商,TCP 握手等。
  • <link rel="dns-prefetch">:更快地完成 DNS 查找。当页面通过网址请求服务器时需要先通过 DNS 解析拿到 IP 地址才能发起请求。如果网站存在大量跨域的资源,DNS 的解析过程很可能会降低页面的性能。对于关键的跨域资源,推荐使用 dns-prefetch 进行 DNS 预获取。还可配合 preconnect 进行预连接。

当页面通过网址请求服务器的时候,需要先通过 DNS 解析拿到 IP 地址才能发起请求。如果网站存在大量跨域的资源,DNS 的解析过程很可能会降低页面的性能。对于关键的跨域资源,我们最好进行 dns 预获取,还可以结合 preconnect进行预连接。

Time to Interactive

可交互时间,简称TTI。它表示页面完全交互所需的时间。当页面显示了有用的内容(即FCP),大多数可见的页面元素注册了事件处理程序,并且页面能在 50 毫秒内响应用户交互即可称之为可完全交互。通常 TTI 在 3.8 秒内可达到快速等级。

如何提升

TTI 直接受 JS 脚本的影响,脚本越多,TTI 的延迟越大。

  • JavaScript 越多,在网站可进行交互之前就要花费越长的时间来解析和编译。因此严重推迟用户与网站交互的时间。由于设备有高低端硬件区别,解析/编译代码所花费的时间有 2 到 5 倍的差距。并且浏览器处理(解析、编译) JavaScript 的成本高于相同字节的图像或字体。因为在解码和光栅化图像通常不会阻止主线程或界面交互。
  • 执行 JavaScript(解析/编译后运行代码)也必须在主线程中执行。因此通常将JavaScript 进行拆分以避免锁定主线程。
  • 在运行时,可能出现长时间运行的 JavaScript 任务(即长任务)阻塞主线程。通常可以把长任务拆分为较小的任务(使用 requestAnimationFramerequestIdleCallback),或者选择在主线程之外(如Web Workers)运行 JavaScript。详见最小化主线程工作👇🏻。
  • 另外的 JavaScript 执行时还可能导致内存泄漏。当浏览器收回内存时会暂停执行 JS,因此页面可能会因为 GC(垃圾回收)而出现卡顿或频繁暂停现象。

代码拆分

通常基于路由进行代码拆分,详见代码拆分、懒加载👆🏻。

应用 PRPL 模式来减少 JavaScript 负载

为了减少 JavaScript 解析/编译及网络传输时间,通常使用路由分块或 PRPL 等模式。

  • P: Push or preload,预加载最重要的资源,详见预加载关键资源👆🏻。
  • R: Render,尽快渲染初始路径。
  • P: Pre-cache,预缓存剩余资源。
  • L: Lazy load。懒加载其他路由和非关键资源,详见代码拆分、懒加载👆🏻。
尽快渲染初始路径

存在延迟 First Paint 的资源,Lighthouse 会发出警告。

为了改进 First Paint,可以内联关键 JavaScript/ CSS,并推迟剩余资源。这种获取阻塞渲染的资源的方式可以避免关键资源与服务器的往返,从而提高性能。但是内联代码从开发者的角度来看更难维护,并且不能被浏览器单独缓存。另一种方式是通过服务端渲染来呈现初始的 HTML,相对的它会增加 HTML 的文件体积。

Service Workers 预缓存资源

Service Workers 通过在客户端和服务端之间充当代理的角色,使得客户端可以直接从缓存中获取资产,而不是在重复访问时从服务器中获取。这不仅允许用户在弱网甚至离线时使用应用程序,而且还可以在重复访问时显著加载时间、提升页面加载速度。

相比自己编写自定义的 Service Worker 来更新预缓存资源,使用第三方库来生成 Service Worker 更加方便,如 Workbox 提供了一组工具,方便我们创建和维护 Service Worker 来缓存资产。

参考 Create React APP 中离线缓存策略。

优化第三方脚本

识别缓慢的第三方脚本
  • Lighthouse 诊断报告中 Third-party usage 项中会显示页面中所有的第三方脚本。
  • Lighthouse 诊断报告中 Reduce JavaScript execution time 项会显示需要很长时间来解析、编译或执行的脚本,勾选 3rd-party resources 显示第三方脚本。
  • 当你通过上两步确定了影响性能的第三方脚本后,在 Chrome DevTools 中阻止对应第三方脚本的网络请求来判断其对加载时间的影响。

打开 DevTools 中的 Network 选项卡,对其中的任意资源请求右键选择 ****Block request URL,所有被阻止的请求都将出现在 Request blocking 中。

如何高效加载第三方脚本
使用 async 或 defer
<script async src="script.js">
<script defer src="script.js"> 

asyncdefer 都可以在 HTML 解析时进行并行下载,区别在于下载完成后执行脚本的时机,具体可查看异步加载其他非关键脚本。

建立预连接

在浏览器可以从服务器请求资源之前,它必须建立连接。建立安全连接包括以下三个步骤:

  • 查找域名并将其解析为 IP 地址;
  • 建立与服务器的连接;
  • 加密连接以确保安全。

每个步骤中,浏览器都会向服务器发送一条数据,然后服务器会发回响应,从起点到终点再返回的过程称为一个往返。根据网络状况差异,单次往返可能需要大量时间。通过与重要的第三方来源建立早期连接,通常可以将加载时间加快 100-500 毫秒。

rel=preconnect 告知浏览器你的页面需要尽快建立连接。

需要注意的是仅预连接到你即将使用的关键域,因为浏览器会关闭所有未在 10 秒内使用的连接,不必要的预连接会延迟其他重要资源。

<link rel="preconnect" href="">
<link rel="dns-prefetch" href="http://example"> 

rel=dns-prefetch 告知浏览器尽快完成 DNS 查找。

虽然 preconnectdns-prefetch 有所不同,同时浏览器对 dns-prefetch 的支持与 preconnect 支持也略有不同,但对于不支持 preconnect的浏览器可以将 dns-prefetch 作为后备。

<link rel="preconnect" href="http://example">
<link rel="dns-prefetch" href="http://example"> 
懒加载第三方资源

延迟加载是提高页面速度的好办法。对于不那么重要的资源,比如广告内容可以做懒加载。

另一种是不在首屏使用的第三方资源,使用 Intersection Observer API 可以有效检测元素何时进入或退出浏览器视口。利用比如 lazysizes 这样 JS 库,可以更简单的懒加载图片和 iframes

优化提供第三方资源的方式
  • 第三方 CDN 托管好处是非常简单便捷的享受 CDN 服务。缺点是通常与其他资源不同源,从公共 CDN 加载文件会产生额外网络成本,如执行 DNS 查找、建立服务器连接、执行 SSL 握手等。而且你几乎无法控制缓存策略。
  • 自托管自托管第三方脚本资源可以更好的控制脚本的加载过程。减少 DNS 查找和往返时间、改进HTTP 缓存标头、利用HTTP/2 服务器推送。缺点是脚本需要手动更新,否则可能会过时,当API 发生更改或安全修复时不能做到自动更新。
  • 使用 Service Worker 缓存第三方脚本自托管的另一种替代方案是使用 Service Worker 缓存第三方脚本。它可以更好地控制缓存,同时仍能享受第三方 CDN 的好处。你可以创建自己的第三方资源加载策略,控制获取第三方脚本的频率,限制非必要第三方资源的请求。配合 preconnect 建立预连接也可以在一定程度上降低网络成本。详见使用 Service Workers 预缓存资源👆🏻。

Speed Index

速度指数,简称 SI。用于衡量页面可视区域内容填充的速度,它是页面可视区域内容填充花费的平均时间,通常3.4秒内可达快速等级。

以上面两组图为例,第一组图是优化后页面内容展现方式,由于过程是渐进式的,因此可以给用户更好的视觉体验。第二组图虽然是使用了同样的时间展示出全部内容,但由于空白的时间过长,内容填充花费的平均时间会更长,用户还很可能不耐烦的离开了。

SI 如何计算

Lighthouse 会捕获浏览器中页面加载过程的视频,并计算帧之间内容填充的进展,每帧得分 = 帧时间间隔(通常为100ms) * (1 - 可视区已填充内容占比),最终值为每帧得分总和。

如何提升

提升的关键在于提升服务器响应时间、优化关键渲染路径,避免渲染阻塞。

优化关键渲染路径

页面渲染的关键路径是指浏览器将 HTML,CSS 和 JavaScript 转换为屏幕上的像素所经历的步骤。关键渲染路径包含了 文档对象模型 (DOM),CSS 对象模型 (CSSOM),渲染树和布局。

优化关键渲染路径的常规步骤
对关键路径进行分析

此步骤主要是查看资源的数量、字节数、加载时间、路径长度等。可以通过 Performance 工具记录并查看 Network 栏中的资源请求过程、结合 Timings 中的时间点信息来检查关键路径信息。

每个资源请求的图形中都存在一左侧线段、浅色矩形、深色矩形、右侧线段:

  • 左侧线段:Request Sent 之前所有时间花费(Queueing ~ Proxy negotiation)。
  • 浅色矩形:Request Sent + Waiting (TTFB)。
  • 深色矩形:Content Download。
  • 右侧线段:等待主线程的时间花费。

一些关注点:

  • 资源的优先级和耗时。鼠标悬浮到对应资源上可以看到资源优先级(Highest、High、medium、Low、Lowest)。检查是否有 Highest 的非关键资源,或者是否存在某些关键资源以 Low、Lowest 优先级进行加载;
  • 资源加载各阶段耗时。资源矩形的长度体现了耗时,鼠标悬浮到对应资源上可以看到具体耗时和时间组成。检查是否存在耗时较长的资源、队列或主线程等待时间是否符合预期、是否影响到其他关键资源。
  • 加载顺序。某些关键资源的请求路径较深,是否可以提前加载,详见预加载关键资源。
  • 资源开始加载、完成、执行的时机(FP、FCP、LCP…)是否符合预期。

关于资源耗时,在 Network 的瀑布图可以做更具体的耗时分析。

面板中各项耗时项目的含义:

  • Queueing:队列等待时间。以下情况下请求会进行排队:* 存在更高优先级的请求;
  • 同源仅能打开六个 TCP 连接的限制(仅适用于 HTTP/1.0 和 HTTP/1.1);
  • 浏览器需要在磁盘缓存中短暂分配空间。
  • Stalled:请求停滞。请求可能因排队中所述的任何原因而停止。
  • DNS Lookup:DNS 查找。浏览器正在解析请求的 IP 地址。
  • Initial connection:初始连接。浏览器正在建立连接,包括 TCP 握手/重试和协商 SSL.
  • Proxy negotiation:代理协商。浏览器正在与 proxy server 协商请求。
  • Request sent:请求已发送。正在发送请求。
  • ServiceWorker Preparation: ServiceWorker 准备。浏览器正在启动 Service Worker。
  • Request to ServiceWorker: 请求 ServiceWorker。请求正在发送给 service worker.
  • Waiting (TTFB): 等待(TTFB,Time To First Byte)。浏览器正在等待响应返回的第一个字节,此时间包括 1 次往返延迟和服务器准备响应所用的时间。
  • Content Download:内容下载。浏览器直接从网络或 Service Worker 接收响应。Content Download 表示读取响应内容所花费的总时间。大于预期的值可能表示网络速度较慢,或者浏览器正忙于执行其他工作,从而延迟了响应的读取。
  • Receiving Push:接收推送。浏览器正在通过 HTTP/2 服务器推送接收此响应的数据。
  • Reading Push: 读取推送。浏览器正在读取之前接收到的本地数据。
最大限度的减少关键资源的数量

仅加载当前页面渲染所需的必要资源,删除或者延迟其他资源的加载。这里的关键是在于如何识别、提取关键资源👆🏻。

优化关键资源的体积以缩小下载时间

优化资源体积的通常做法就是资源移除未使用的代码,对资源进行压缩混淆,开启gzip压缩等。对于关键资源或者路径较深的资源可以预加载关键资源👆🏻。

优化关键资源的加载顺序

目的是尽早下载所有关键资源,以缩短关键路径长度。这里同样的可以使用预加载策略👆🏻。

最小化主线程工作

  • 使用 Web Workers 在浏览器的主线程之外运行 JavaScript

WebWorkers 提供了postMessageAPI,让你可以使用 myWorker.postMessage(someObject) 将 JavaScript 对象作为消息发送。由于 Web Worker 无法访问 DOM 和许多 API(如WebUSB、WebRTC或Web Audio),因此不能将这些有依赖于代码放入 Worker 中。配合 Comlink 可以让 Web Workers 的使用更简单方便。

  • 将任务分为较小的块

在运行时,长时间运行的 JavaScript 可能会阻塞主线程,从而导致页面无响应。使用类似requestAnimationFrame()requestIdleCallback() 可以将任务拆分为较小的块。

其他

上面已提及的消除阻塞的资源、确保文本在 webfont 加载期间保持可见都对优化SI有较大,不再赘述。

Total Blocking Time

总阻塞时间,简称TBT。它表示用户输入(例如鼠标点击、屏幕点击或键盘按下)被阻塞响应的总时间。当首次内容渲染(FCP)到可完全交互(TTI)期间执行了长任务,超出 50 毫秒的部分即为阻塞时间。任何执行时间超过 50 毫秒的任务都属于长任务,所以当 Lighthouse 检测到一个 70 毫秒长的任务,则阻塞部分即为 20 毫秒。TBT 在 200 毫米内达到快速等级。

长任务的定义

长任务是长时间占用主线程的 JS 代码,它会导致页面无法及时响及用户输入。在加载页面时,长任务可能会占用主线程而导致事件监听与处理程序较晚注册,从而导致页面看起来已经准备就绪,但用户点击不起作用。

RAIL 模型建议我们在 50 毫秒内处理用户输入事件,确保在 100 毫秒内做出可见响应会使得用户感觉交互是及时的。所以超过 50 毫秒的任务都属于长任务。

如何优化

重构低效的 JavaScript 语句

假设 document.querySelectorAll('a') 是一次返回 2000 个节点的调用,使用更精确的选择器重构代码使其仅返回 10 个节点,就会提升 TBT 分数。

如何找出长任务

使用 Chrome Devtools 中Performance 工具对页面进行录制,在录制完成后查看性能报告中的 FPS 和 Main视图(主线程)。若 FPS 栏出现红色条则表示这些帧存在验证问题。

点击FPS栏可缩小并调节查看的范围,在 Main 视图中查看导致掉帧的具体任务,被红色角标标记的即为长任务,鼠标悬停在栏上可知道任务的持续时间。

导致长任务的具体原因可选择灰色任务,选择 Bottom-UPActivity 查看哪些 Activity 对任务总时长“贡献”最大。下图中似乎是一组高昂代价的 DOM 查询。

减少不必要的 JavaScript 加载、解析或执行

大型脚本通常是长任务的主要原因,考虑将它们进行拆分,详见代码拆分、懒加载;删除未使用的代码,详见削减 JS;同时还需要留意第三方脚本,详见优化第三方脚本。

分解长任务

将长任务分解为多个运行时间 < 50 毫秒的小任务,并在正确的时机、地方运行执行它们。正确的地方还可以在主线程之外,详见最小化主线程工作。

Largest Contentful Paint

最大内容绘制指标,简称 LCP。它表示页面首次开始加载到可视区域内可见的最大(尺寸)图片或者文本块完成渲染的相对时间。相对于 FCP 测量初始 DOM 内容完成渲染所需的时间,但无法捕获页面上最大内容渲染所需的时间,LCP 更能体现屏幕可视区元素渲染的用户体验,因为最大的内容通常也是最有意义的。LCP 在 2.5 秒内达到快速级别。

LCP 考察的元素

  • <img>元素;
  • 内嵌在<svg>元素内的<image>元素;
  • 使用了封面图的 <video>元素;
  • 通过url()(非CSS 渐变)加载的带有背景图元素;
  • 含有文本节点或其他子元素的块级元素。

如何定义“最大”

  • 上报的 LCP 元素大小通常为视口所见到的尺寸。如果元素在视口外或被 overflow 裁剪了,这部分尺寸不计算入元素尺寸。
  • 对于哪些在原始尺寸上经过调整的图像元素,报告的元素大小为可见尺寸或原始尺寸,已较小者为准。例如图片通过 CSS 缩小则上报显示尺寸,拉伸或放大则仅报告原始尺寸。
  • 对于文本元素,仅考量文本节点的大小(包含所有文本节点的最小矩形)。
  • 对于所有元素,通过 CSS 设置的 margin、padding 和 border 都不计入考量范围。

通过上述考量比较后,可视区内相对最大尺寸的元素被选为被 LCP 考察的最大元素。

如何报告 LCP

由于网页通常分阶段加载的,因此页面上的最大元素也可能发生变化。浏览器为了应对这种潜在的变化,浏览器会在第一针立即分发一个 类型为 largest-contentful-paintPerformanceEntry 对象,对象的 element 属性引用的是当前的最大内容元素。在渲染后续帧之后,如果最大内容元素发生变化时则会分发另一个 PerformanceEntry 对象。

值得注意的是只有在渲染完成并且对用户可见后才会被视为最大内容元素,比如尚未加载的图片、字体未加载的文本节点(字体阻塞期)都不会被视为渲染完成。这些情况下,较小的元素可能就会报告未最大内容元素,但是当更大的元素完成渲染,就会使用另一个 PerformanceEntry 对象进行上报。

页面也可能会在新内容可用时向 DOM 中添加新元素,如果有任意一个新的元素大于之前的最大内容元素,浏览器还将报告一个新的 PerformanceEntry 对象。

如果当前的最大内容元素从可视区域被移除,那么除非有更大的元素完成渲染,否则该元素将持续作为最大内容元素。

当用户与页面进行交互(通过轻触、滚动或按键)时,浏览器将立刻停止报告新条目,因为用户交互通常会改变用户可见的内容。

示例

  • Instagram 标志加载得相对较早,即使其他内容随后陆续显示,但 Instagram 标志始终是最大元素。
  • 新内容被添加进 DOM,并因此使最大元素发生了改变
  • 由于布局的改变,先前的最大内容从可视区域中被移除。

发现 LCP 元素

  • Lighthouse LCP 诊断报告中的 Largest Contentful Paint element
  • 在开发者工具 Performance 面板中查看 Timings 栏,鼠标点击 LCP 标记会显示哪些元素与 LCP 相关联:

如何提升 LCP

提升服务器响应速度

  • 优化服务器。通常情况下首先要关注 HTML 返回的耗时,其中 Waiting (TTFB,发出页面请求到接收到第一个字节的耗时) 又是反映响应速度的核心指标,通常情况下 100ms 内是不错的 TTFB,建议不得超过 200ms。对于动态网页来说,服务器查询数据和渲染模版都需要一定的时间,因此当 TTFB 过长时可能需要去检查是否存在需要消耗大量时间和系统资源才能完成的查询,或者服务器端是否有其他复杂的操作会延迟页面内容的返回…
  • 使用 CDN。
  • 优先使用缓存提供 HTML 页面。Service Worker 以服务器与浏览器之间的中间人角色拦截当前网站所有的请求并进行判断,需要向服务器发起请求则转给服务器,否则直接使用缓存。使用 Service Worker 实现更小的 HTML 有效负载。
  • 尽早建立第三方连接。详见建立预连接。

减少阻塞渲染的 JavaScript 和 CSS

HTML 解析器遇到任何外部样式表或同步 JavaScript 标签(<script src="main.js">)都会暂停解析,因此脚本和样式表都是阻塞渲染的资源,延迟加载任何非关键的 JavaScript 和 CSS 能加快 FCP,进而加载 LCP,详见消除阻塞渲染的资源。

提升资源加载速度

虽然 CSS 或 JavaScript 阻塞时间的增加会直接导致性能下降,但加载许多其他类型资源所需的时间也会影响绘制时间。这里主要关注 LCP 考察元素的加载速度,以下是几种确保尽快加载这些资源的方法:

  • 优化和压缩图片资源。
  • 将图片转换为更新的格式以减少体积(JPEG 2000、JPEG XR 或 WebP);
  • 使用响应式图片。使用媒体查询根据设备和屏幕分辨率获取不同的图片资源;
  • 使用 CDN。
  • 预加载首屏重要资源。通常有些重要资源是较晚被解析发现的,如 CSS 中的自定义字体。如果你确定某些资源需要被优先获取,可以使用<link rel="preload"> 更早的获取该资源,但应该仅对关键资源(如字体、首屏图片或视频、关键路径CSS/JS)进行预加载。详见预加载关键资源👆🏻。
<link rel="preload" as="script" href="script.js" />
<link rel="preload" as="style" href="style.css" />
<link rel="preload" as="image" href="img.png" />
<link rel="preload" as="video" href="vid.webm" type="video/webm" />
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin /> 
  • 压缩文本文件。所有浏览器都支持 Gzip,使用 Gzip 之类的算法可以显著缩减文本文件大小。

自适应服务

自适应服务是指加载页面主要内容资源时,根据用户的设备或网络条件按需获取不同的资源。

这种做法主要是通过使用网络状况 API、设备内存 API 和硬件并发 API 来实现,比如当用户处于低于 4G 的网络连接速度,你可以显示图像,而不是视频:

if (navigator.connection && navigator.connection.effectiveType) {if (navigator.connection.effectiveType === '4g') {// 加载视频} else {// 加载图像}
} 

相关一些实用属性:

  • navigator.connection.effectiveType:有效连接类型。
  • navigator.connection.saveData:启用/禁用数据保护程序。
  • navigator.hardwareConcurrency:CPU 核心数。
  • navigator.deviceMemory:设备内存。

使用 Service Worker 缓存资源

Service Worker 提供较小的 HTML 响应,还可用于缓存任何静态资源,并在收到重复请求时将资源直接提供给浏览器,而无需通过网络请求。使用 Service Worker 预缓存关键资源可以显著减少资源加载时间,特别是对于弱网下重新加载网页(甚至离线访问)的场景。详见使用 Service Worker 缓存资源 👆🏻。

使用服务端渲染

对于客户端渲染的网站来说,首要的工作是将 JavaScript 的数量最小化,但结合服务端渲染可以进一步改善 LCP。服务端渲染的实现方式是使用服务器执行 JavaScript 将应用程序的 DOM 填充到模板并渲染为 HTML,浏览器获取到的是不再仅仅是一个 HTML 外壳。当客户端接管页面后将所有 JavaScript 及所需数据“水合”到相同的 DOM 中,因此这种做法可以确保页面的主要内容在服务器上已进行渲染(而无需等待客户端完成 DOM 渲染)从而改进 LCP。

相对的服务端渲染也会带来一定的弊端:增加同构的复杂性;服务器执行 JavaScript 渲染 HTML 文件会增加服务器响应时间(TTFB);服务端渲染的页面在所有客户端 JavaScript 执行完毕之前响应对任何用户输入作出响应,从而使得可完全交互(TTI) 变得更差。

使用预渲染

预渲染不像服务器渲染那样即时编译 HTML,它只在构建时为特定的路由(通常需要 SEO 的页面)生成特定的几个静态页面。对于客户端渲染的网站,使用预渲染是提升 LCP 常用方法。

下面是一个通过 webpack 与 PrerenderSPAPlugin 插件实现预加载的简单示例(详见这里):

const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')
module.exports = {plugins: [...new PrerenderSPAPlugin({// Required - The path to the webpack-outputted app to prerender.staticDir: path.join(__dirname, 'dist'),// Required - Routes to render.routes: [ '/', '/about', '/some/deep/nested/route' ],})]
} 

Cumulative Layout Shift

累积布局偏移,简称 CLS。其计算自未在用户输入 500 毫秒内发生的布局偏移的偏移分数总和,通过其值来表示测量内容的不稳定性,其值越高稳定性越差。通常分数在 0.1 内为高稳定性表现。

CLS 如何计算

CLS 分数 = 影响分 * 距离分。

影响分:前一帧和当前帧的所有不稳定元素的可见区域集合(占总可视区域的部分)就是当前帧的影响分。上图中灰色背景标识的元素在一帧中占据了可视区域 50%,在下一帧中,元素下移了可视区域高度的 25%,红色虚线矩形框表示两帧中元素的可见区域集合,即该集合占总可视区域的 75%,因此其影响分数为0.75

距离分:任何不稳定元素在一帧中位移的最大距离(水平或垂直)除以可视区域的最大尺寸维度(宽度或高度,以较大者为准)。图例中最大的可视区域尺寸维度是高度,不稳定元素的位移距离为可视区域高度的 25%,因此距离分数为 0.25。

本示例中,布局偏移分数即为 0.75 * 0.25 = 0.1875

如何发现影响 CLS 的元素

  • 查看开发者工具 Lighthouse 面板的 CLS 诊断报告中:
  • 在开发者工具 Performance 面板中查看 Experience 栏,鼠标点击出现的 ``标记:

常见原因与提升方案

无尺寸的图像

使用响应式网页设计后,我们常常会省略图片和视频元素上widthheight,并取而代之开始使用 CSS 来调整图像大小👇🏻,这样做的缺点是只有在图片开始下载且浏览器可以确定其尺寸后才能为其分配空间,从而也会导致页面发生重排,原本的文本发生突然偏移。

img {width: 100%; /* or max-width: 100%; */height: auto;
} 

始终在图片和视频元素上包含widthheight属性。或者通过使用 CSS 定义长宽比给容器预留所需的空间,从而确保浏览器能够在加载图像期间在文档中分配正确的空间大小。

现代浏览器会根据widthheight属性设置图像的默认长宽比:

<img src="puppy.jpg" width="640" height="360" alt="小狗与气球" /> 

而且所有浏览器的UA 样式表都会根据元素现有的widthheight属性添加默认长宽比,即 aspect-ratio :

img {aspect-ratio: attr(width) / attr(height);
} 

如果图片在容器中,使用 CSS 将图像调整为容器的宽度,设置 height: auto; 即可。

核心就是永远不要忘记设置在图片元素上设置 widthheight 属性(非style中的widthheight),从而保证浏览器可以添加 aspect-ratio 使得图片加载时能预留空间。

img {height: auto;width: 100%;
} 

处理响应式图片

一种简单的方式是可以可以设置 width= “100%”,CSS 中显式设置预期的 aspect-ratio,由于其并不影响图片实际渲染的宽高,只影响图片加载期间的预留空间,因此 aspect-ratio 的值可以与实际的宽高比不一致。

<img src="xxx" width="100%"> 
img {max-width: 100%;width: auto; // 保证图片渲染为固有尺寸aspect-ratio: auto 4/3;
} 

如果你不能预测宽高比,但如果你可以知道图片的原始尺寸信息(即使与实际渲染宽度不一致),则可以设置width和height属性,实际渲染宽高通过css样式控制。

以富文本中的图片场景为例:

通常的富文本组件在编辑图片时都可以设置图片相对父元素的实际渲染宽度百分比,从而得到添加了 width 属性的 img 字符串。

<img> <!-- 默认或重置 -->
<img width="30%"> <!-- 30% -->
<img width="100%"> <!-- 100% --> 

通过自定义图片上传成功的方法获取图片原始尺寸信息并添加到 img 标签上。

<img alt="size=2048*1456">
<img alt="size=2048*1456" width="30%">
<img alt="size=2048*1456" width="100%"> 

页面获取到文本字符串后先做一些处理:

  • 添加width、height属性使得浏览器能得到aspect-ratio
  • 通过内联样式或css样式控制实际渲染尺寸(图片不拉伸)。

最终转换为👇🏻:

<img alt="size=2048*1456" width="2048" height="1456">
<img alt="size=2048*1456" width="2048" height="1456" style="width:30%">
<img alt="size=2048*1456" width="2048" height="1456" style="width:100%"> 
img {max-width: 100%;height: auto;
} 

设置 height: auto 的目的是保证图片能真实宽高比渲染高度,否则会将使用设置的 height 属性而使得图片被拉伸。

无尺寸的广告、嵌入和 iframe

广告

广告是一些网站重要收入来源,但在广告生命周期的许多时间点都会引发布局偏移,影响网站的用户体验。缓解广告导致偏移的常见做法:

  • 为广告位预留空间;
  • 无广告返回时显示占位符避免预留空间被折叠;
  • 避免在可视区域顶部附近放置广告。因为可视区域顶部附近的广告比中间的广告可能造成更大的布局偏移。

嵌入和 iframe

一些可嵌入的组件能够使页面嵌入其他网站的可移植内容(如视频、地图、发帖),这些嵌入相对广告内容通常更难提前预知嵌入内容的大小(比如帖子内容中无法预知有多少文本、图片和视频),但也不能给这些嵌入内容预留尽可能大的空间,只能通过使用占位符给嵌入预留空间来最大程度减少 CLS。

动态内容注入

通常情况下除非是对用户交互做出响应,否则应尽量避免在现有内容的上方插入内容,这样可以确保任何布局偏移都在用户的预期之内。在一些必须的、可预知的场景下(如顶部横幅)应尽可能避免让用户感到意外的布局偏移,可以使用占位符或者骨架图进行空间预留。

在某些必须动态内容添加的场景下(如加载更多内容到列表、更新实时反馈内容),通常使用以下几种方法避免意外布局偏移:

  • 在固定尺寸的容器中进行新旧内容替换。过渡完成之前应禁用链接和控件以避免意外点击。
  • 让用户主动加载新内容,如加载更多、刷新按钮,在用户输入后 500 ms 内发生的布局偏移不会记入 CLS。
  • 屏幕外的内容完成加载(通常是顶部内容)可以向用户添加一个通知(如显示一个向上滚动的按钮),说明存在内容更新并且已经可用。

FOUT 与 FOIT

下载和渲染自定义字体时可能存在两种方式导致布局偏移。

  • 后备字体替换为新字体时导致无样式文本闪烁(FOUT);
  • 新字体完成渲染前浏览器会隐藏文本从而导致不可见文本闪烁(FOIT)。

以下方法可以最大程度减少这两种情况:

  • font-display用于指定字体的显示策略。值为swap 时告诉浏览器使用了该字体的文本应立即使用系统字体显示。遗憾的 swap 会导致重排。
  • 预加载字体资源。使用<link rel="preload"> 预加载字体将有更大几率保证字体在首次绘制中可以使用(详见预加载CSS中定义的资源)。font-display: optional 为字体提供一个 100 ms 的阻塞周期,并且没有交换周期,所以 <link rel="preload"> 结合 font-display: optional 使用可以避免导致重排。

动画

对很多 CSS 属性值进行更改都会触发浏览器进行重排(如 box-shadowbox-sizing),尽可能使用 transform 实现动画仅会导致重绘从而避免布局偏移。

看懂 Lighthouse 中 Performance 核心指标

简介

Lighthouse 是谷歌开源的一款 Web 前端性能测试工具,它将针对此页面运行一连串的测试,然后生成一个有关页面性能的报告。本文中仅对 Performance 部分的指标进行介绍。

Performance 指标

Performance 项的总和得分由6个指标的性能按一定比例综合计算得到。下面是Lighthouse 性能分析的分值报告图例:

为了方便了解各项指标的分值占比、各项指标数据与得分的关系,可使用 Lighthouse Scoring Calculator 进行查看:

First Contentful Paint

首次内容渲染,简称 FCP。测量在用户导航到您的页面后浏览器呈现第一段 DOM 内容所需的时间。1.8 秒内达到快速级别。

如何提升

确保文本在 webfont 加载期间保持可见

确保文本在 webfont 加载期间保持可见。当网页使用自定义字体时,字体文件通常都是较大文件,需要一段时间才能加载完成,某些浏览器会在字体加载之前隐藏文本,从而导致不可见文本闪烁(FOIT)。

避免 FOIT 最简单方法是临时显示系统字体,font-display: swap 告诉浏览器使用自定义字体的文本应立即使用系统字体显示,自定义字体准备就绪后再替换系统字体(遗憾的是 swap 会导致重排)。

@font-face {font-family: 'Pacifico'; font-style: normal;font-weight: 400; src: local('Pacifico Regular'), local('Pacifico-Regular'), url(/s/pacifico/v12/FwZY7-Qmy14u9lezJ-6H6MmBp0u-.woff2) format('woff2'); font-display: swap;
 } 

消除阻塞渲染的资源

消除阻塞渲染的资源。报告中会列出阻止当前页面首次绘制的所有 URL。通过内联关键资源、推迟非关键资源和删除任何未使用的内容来减少这些阻止渲染的 URL 的影响。

哪些资源会阻塞渲染?

以下情况的脚本和样式表将会阻塞渲染:

  • <head> 中没有 deferasync 属性的 <script> 标签。
  • 没有 disabled 属性 或含有 media="all"<link rel="stylesheet"> 标签
<link href="style.css" rel="stylesheet" media="all">
<link href="style.css" rel="stylesheet"> // media 默认为 "all" 
如何识别关键资源?

使用 Chrome DevTools 中的 Coverage 选项卡 来识别非关键 CSS 和 JS。该选项卡会告诉你加载了多少代码、其中未实际使用到的代码。

绿色表示首次绘制所需的脚本/样式,即关键资源,红色则为非关键资源。

如何提取关键资源?

识别出关键资源后就需要区分出关键与非关键资源。对于 CSS 资源可以使用 Critical、CriticalCSS 或 Penthouse 进行提取,而 JS 资源通常结合如 Webpack 之类的打包工具对非关键资源进行拆分或懒加载👇🏻。

如何消除阻塞渲染的脚本?
  • 削减和压缩 JS 文件如果你使用 webpack 等构建工具,添加 TerserWebpackPlugin 对 JS 进行压缩混淆;开启 Tree Shaking 移除未使用的代码;使用 compression-webpack-plugin 进行编码压缩,如果使用了 CDN,通常也存在压缩算法等配置。
  • 减少未使用的 polyfill我们通常使用 Babel 对代码编译成指定环境能运行的代码,通过下面的预设/插件可以减少不必要的 polyfill:
  • @babel/preset-env 是一个智能预设,指定目标环境后即可使用最新的 JavaScript 特性,它会去管理需要哪些插件以及 polyfill。

  • @babel/plugin-transform-runtime 可以复用 Babel 注入的辅助代码以减少代码体积;另一个作用可以避免全局注入 polyfill。

  • 内联核心脚本实际项目中会把一些小但十分重要的 JS 资源通过 <script> ... </script> 内联的方式加载,比如 webpack 打包产物中的运行时资源(runtimeChunk),其中包含了 webpack 进行模块解析、加载、模块清单等代码。runtimeChunk 每次构建都会发生变化,单独提取出来可以避免非变更 chunk 的 contenthash 变更,从而更好的利用浏览器缓存。但这些代码通常只有几 KB 甚至更小,为此增加网络资源请求十分浪费,通过类似 InlineChunkHtmlPlugin 插件内联到 html 中是更好的选择。
  • 非关键脚本移至 </body> 之前HTML 解析过程中如遇到同步的 JS 会等待 JS 下载并执行完之后才继续解析,如果将 JS 资源放在 <head> 中可能造成页面持续白屏一段时间。所以当需要兼容一些旧浏览器时将所有的JS脚本都放在 </body> 之前是最好的选择。这样可以保证非脚本的其他一切元素能够以最快的速度得到加载和解析。
  • 异步加载其他非关键脚本deferasync 都可以实现异步加载 JS 资源,async 是无序异步加载,defer 则是有序顺序来异步加载。
<script src="xx.js" async></script>
<script src="xx.js" defer></script> 

async :并行下载后并直接运行,下载的过程不会阻塞 DOM 解析,但执行会,执行的时间与 DOMContentLoaded 不相关,可能领先也可能在其后。多个 async JS 无法保证按引入的顺序执行,所以当 JS 资源完全独立时可选择此方式加载。

defer :并行下载,但下载完成后不会立即执行,它们在 DOM 解析完成之后、DOMContentLoaded 之前按照引入顺序依次执行,因此不会阻塞 DOM 解析。

DOMContentLoaded :当初始的 HTML ****文档被完全加载和解析完成之后,DOMContentLoaded ****事件会被触发,而不必等待样式表,图片等资源完成加载。

Load :当整个页面及所有依赖资源如样式表和图片都已完成加载时,将触发load事件。

  • 代码拆分、懒加载主流的打包工具(如 webpack、Rollup)都可以通过动态导入来拆分包。动态导入的模块不会包含在初始包中,而是会进行懒加载,或在指定的时机加载。
import moduleA from "library";
form.addEventListener("submit", e => {e.preventDefault();someFunction();
});


// 使用 import()动态导入
form.addEventListener("submit", e => {e.preventDefault();import('library.moduleA').then(module => module.default).then(someFunction()).catch(handleError());
}); 

实际项目中懒加载所有第三方模块并不是很常见,通常使用 SplitChunksPlugin 等工具将第三方依赖被拆分并打包成一个单独的 vendors chunk 是更好的选择,因为它们不会经常更新,使用 chunkhash 可以保证每次构建 vendor chunk 的文件名不会变更,从而更好的进行持久化缓存。

使用 import()React.lazy() 在路由或组件级别进行模块拆分则是一种更简单的方式。

import * as React from "react";
import { Routes, Route } from "react-router-dom";

const About = React.lazy(() => import("./pages/About"));
export default function App() {return (<Routes><Route path="/" element={<Layout />}><Route index element={<Home />} /><Routepath="about"element={<React.Suspense fallback={<>...</>}><About /></React.Suspense>}/></Route></Routes>);
} 

还有一些专门进行懒加载的工具库可以使用,如 Loadable components:

// Component Splitting
import loadable from '@loadable/component';
const Home = loadable(() => import('./Home'));
// Library Splitting
const Moment = loadable.lib(() => import('moment')); 
如何消除阻塞渲染的样式?
  • 削减 CSS对于浏览器来说,CSS 文件中的空格、缩进或注释等字符都是不必要的,删除这些字符能减少 CSS 资源大小。optimize-css-assets-webpack-plugin、gulp-clean-css、rollup-plugin-css-porter 都是常用削减 CSS 的工具。
  • 内联关键样式将重要样式进行内联后,就不再需要通过往返请求来获取关键 CSS。您内联了大量 CSS,则会延迟 HTML 文档其余部分的传输,为了最大限度地减少首次渲染的往返次数,目标是将首屏内容保持在 14 KB(压缩)以下,将这些类放在<style> 中内联到 <head>中。
  • 延迟加载其他非关键样式```


`link rel="preload" as="style"` 表示异步请求样式表,`this.rel='stylesheet'` 表示完成加载后再以标准方式加载 `styles.css`。而 `this.onload=null` 将事件处理程序置空有助于避免某些浏览器在切换 `rel` 属性时重新调用处理程序。

`noscript`对于不执行 JavaScript 的浏览器,对元素内部样式表的引用可作为兜底。

### 预加载关键资源

打开一个网页时,浏览器从服务器请求 HTML 文档,然后解析其中内容,如其中存在其他引用的资源则会单独发起请求,比如 CSS 中的自定义字体资源。对于那些较晚被解析发现的资源,可能包含了我们认为的关键资源,所以我们希望能告知浏览器提前去请求这些关键资源,从而就可以加快加载过程。通过 `<link rel="preload">` 预加载资源就可以实现。

```

浏览器会下载并缓存预加载的资源,以便在需要时立即可用(它不执行脚本或应用样式表)。提供该 as属性有助于浏览器根据其类型设置预取资源的优先级,设置正确的标头,并确定资源是否已存在于缓存中。此属性接受的值包括 scriptstylefontimage 等。省略as属性或使用了其他无效值则等同于XHR 请求,浏览器将不知道它正在获取什么,因此无法确定正确的优先级。它还可能导致某些资源(例如脚本)被获取两次。

某些类型的资源类型如字体,需要以 crossorigin 模式加载,即在 <link> 上设置 crossorigin 属性。同时<link>还接受一个type属性,该属性包含链接资源的MIME 类型。浏览器使用该type属性的值来确保资源仅在其文件类型受支持时才被预加载。如果浏览器不支持指定的资源类型,它将忽略<link rel="preload">

<link rel="preload" href="ComicSans.woff2" as="font" type="font/woff2" crossorigin> 
预加载如何工作

上面的图例中,Pacifico 字体是在 CSS 中通过@font-face定义的。浏览器仅在完成下载和解析 CSS 后才开始加载字体文件。使用 <link rel="preload"> 将 Pacifico 字体预加载,字体下载与 CSS 并行下载。

如何确定要预加载的资源

关键请求链表示浏览器优先处理和获取的资源的顺序,Lighthouse 会将位于链中的第三层资源标记为你预加载链接的候选名单,并在 Lighthouse 报告的 Opportunities 部分的 Preload key requests 中进行展示:

假设你的页面的关键请求链如下:

index.html
|--app.js |--styles.css |--ui.js 

index.html文件声明<script src="app.js">app.js运行时会调用fetch()下载 styles.cssui.js,所以在下载、解析和执行最后 2 个资源之前页面不会完整显示。如果app.js下载、解析和执行需要 200 毫秒,那么预加载 styles.cssui.js 就可能潜在节省为 200 毫秒,从而使您的页面加载速度更快。

<head><link rel="preload" href="styles.css" as="style"><link rel="preload" href="ui.js" as="script">
</head> 

预加载浏览器发现较晚的重要资源对 FCP 和 TTI 都有较大提升。但是预加载所有内容会适得其反,因此仅预加载最关键的资源很重要。 load事件发生后大约 3 秒,未使用的预加载会在 Chrome 中触发控制台警告,这也是判断是否预加载了非关键资源的标志。

预加载策略
预加载 CSS 中定义的资源

预加载 CSS 中通过 @font-face 定义的字体资源可确保在下载 CSS 文件之前获取它们。需要注意的是添加crossorigin属性,否则预加载的字体将被提取两次。

<link rel="preload" href="ComicSans.woff2" as="font" type="font/woff2" crossorigin> 
预加载 CSS

如果已经提取了关键 CSS,则可以将 CSS分为两个部分,首屏所需的关键 CSS 内联在 中,而非关键 CSS 通常使用 JS 延迟加载,在加载非关键 CSS 之前等待 JS 执行会导致用户滚动时呈现延迟,因此使用 <link rel="preload">可以更快的启动下载。

预加载 JavaScript

由于浏览器不执行预加载的资源,因此预加载有助于将获取与执行分开,这可以改善交互时间等指标。如果能拆分出仅预加载关键 JS 资源,则预加载效果最佳。

使用 webpack 预加载 JavaScript 模块

增加 webpackPreload: true 注释即可注入预加载标签。

import(/* webpackPreload: true */ "CriticalChunk") 
其他预加载机制
<link rel="prefetch" href="xx.png">
<link rel="prerender" href="">
<link rel="preconnect" href="" crossorigin>
<link rel="dns-prefetch" href="//cdn.domain"> 
  • <link rel="prefetch">:低优先级资源声明,允许浏览器在空闲时获取并缓存资源。通常对首屏没有帮助,但在能预测用户下一步动向时,prefetch 能加快下一页面加载速度。
  • <link rel="prerender">:与 prefetch 类似,区别在于 prerender 会在空闲时获取 href 属性页面的所有资源。因为它将会加载很多资源并且可能造成带宽的浪费,所以在移动设备中尤其需要小心使用。
  • <link rel="preconnect">:允许浏览器在一个 HTTP 请求正式发给服务器前进行预连接。包括 DNS 解析,TLS 协商,TCP 握手等。
  • <link rel="dns-prefetch">:更快地完成 DNS 查找。当页面通过网址请求服务器时需要先通过 DNS 解析拿到 IP 地址才能发起请求。如果网站存在大量跨域的资源,DNS 的解析过程很可能会降低页面的性能。对于关键的跨域资源,推荐使用 dns-prefetch 进行 DNS 预获取。还可配合 preconnect 进行预连接。

当页面通过网址请求服务器的时候,需要先通过 DNS 解析拿到 IP 地址才能发起请求。如果网站存在大量跨域的资源,DNS 的解析过程很可能会降低页面的性能。对于关键的跨域资源,我们最好进行 dns 预获取,还可以结合 preconnect进行预连接。

Time to Interactive

可交互时间,简称TTI。它表示页面完全交互所需的时间。当页面显示了有用的内容(即FCP),大多数可见的页面元素注册了事件处理程序,并且页面能在 50 毫秒内响应用户交互即可称之为可完全交互。通常 TTI 在 3.8 秒内可达到快速等级。

如何提升

TTI 直接受 JS 脚本的影响,脚本越多,TTI 的延迟越大。

  • JavaScript 越多,在网站可进行交互之前就要花费越长的时间来解析和编译。因此严重推迟用户与网站交互的时间。由于设备有高低端硬件区别,解析/编译代码所花费的时间有 2 到 5 倍的差距。并且浏览器处理(解析、编译) JavaScript 的成本高于相同字节的图像或字体。因为在解码和光栅化图像通常不会阻止主线程或界面交互。
  • 执行 JavaScript(解析/编译后运行代码)也必须在主线程中执行。因此通常将JavaScript 进行拆分以避免锁定主线程。
  • 在运行时,可能出现长时间运行的 JavaScript 任务(即长任务)阻塞主线程。通常可以把长任务拆分为较小的任务(使用 requestAnimationFramerequestIdleCallback),或者选择在主线程之外(如Web Workers)运行 JavaScript。详见最小化主线程工作👇🏻。
  • 另外的 JavaScript 执行时还可能导致内存泄漏。当浏览器收回内存时会暂停执行 JS,因此页面可能会因为 GC(垃圾回收)而出现卡顿或频繁暂停现象。

代码拆分

通常基于路由进行代码拆分,详见代码拆分、懒加载👆🏻。

应用 PRPL 模式来减少 JavaScript 负载

为了减少 JavaScript 解析/编译及网络传输时间,通常使用路由分块或 PRPL 等模式。

  • P: Push or preload,预加载最重要的资源,详见预加载关键资源👆🏻。
  • R: Render,尽快渲染初始路径。
  • P: Pre-cache,预缓存剩余资源。
  • L: Lazy load。懒加载其他路由和非关键资源,详见代码拆分、懒加载👆🏻。
尽快渲染初始路径

存在延迟 First Paint 的资源,Lighthouse 会发出警告。

为了改进 First Paint,可以内联关键 JavaScript/ CSS,并推迟剩余资源。这种获取阻塞渲染的资源的方式可以避免关键资源与服务器的往返,从而提高性能。但是内联代码从开发者的角度来看更难维护,并且不能被浏览器单独缓存。另一种方式是通过服务端渲染来呈现初始的 HTML,相对的它会增加 HTML 的文件体积。

Service Workers 预缓存资源

Service Workers 通过在客户端和服务端之间充当代理的角色,使得客户端可以直接从缓存中获取资产,而不是在重复访问时从服务器中获取。这不仅允许用户在弱网甚至离线时使用应用程序,而且还可以在重复访问时显著加载时间、提升页面加载速度。

相比自己编写自定义的 Service Worker 来更新预缓存资源,使用第三方库来生成 Service Worker 更加方便,如 Workbox 提供了一组工具,方便我们创建和维护 Service Worker 来缓存资产。

参考 Create React APP 中离线缓存策略。

优化第三方脚本

识别缓慢的第三方脚本
  • Lighthouse 诊断报告中 Third-party usage 项中会显示页面中所有的第三方脚本。
  • Lighthouse 诊断报告中 Reduce JavaScript execution time 项会显示需要很长时间来解析、编译或执行的脚本,勾选 3rd-party resources 显示第三方脚本。
  • 当你通过上两步确定了影响性能的第三方脚本后,在 Chrome DevTools 中阻止对应第三方脚本的网络请求来判断其对加载时间的影响。

打开 DevTools 中的 Network 选项卡,对其中的任意资源请求右键选择 ****Block request URL,所有被阻止的请求都将出现在 Request blocking 中。

如何高效加载第三方脚本
使用 async 或 defer
<script async src="script.js">
<script defer src="script.js"> 

asyncdefer 都可以在 HTML 解析时进行并行下载,区别在于下载完成后执行脚本的时机,具体可查看异步加载其他非关键脚本。

建立预连接

在浏览器可以从服务器请求资源之前,它必须建立连接。建立安全连接包括以下三个步骤:

  • 查找域名并将其解析为 IP 地址;
  • 建立与服务器的连接;
  • 加密连接以确保安全。

每个步骤中,浏览器都会向服务器发送一条数据,然后服务器会发回响应,从起点到终点再返回的过程称为一个往返。根据网络状况差异,单次往返可能需要大量时间。通过与重要的第三方来源建立早期连接,通常可以将加载时间加快 100-500 毫秒。

rel=preconnect 告知浏览器你的页面需要尽快建立连接。

需要注意的是仅预连接到你即将使用的关键域,因为浏览器会关闭所有未在 10 秒内使用的连接,不必要的预连接会延迟其他重要资源。

<link rel="preconnect" href="">
<link rel="dns-prefetch" href="http://example"> 

rel=dns-prefetch 告知浏览器尽快完成 DNS 查找。

虽然 preconnectdns-prefetch 有所不同,同时浏览器对 dns-prefetch 的支持与 preconnect 支持也略有不同,但对于不支持 preconnect的浏览器可以将 dns-prefetch 作为后备。

<link rel="preconnect" href="http://example">
<link rel="dns-prefetch" href="http://example"> 
懒加载第三方资源

延迟加载是提高页面速度的好办法。对于不那么重要的资源,比如广告内容可以做懒加载。

另一种是不在首屏使用的第三方资源,使用 Intersection Observer API 可以有效检测元素何时进入或退出浏览器视口。利用比如 lazysizes 这样 JS 库,可以更简单的懒加载图片和 iframes

优化提供第三方资源的方式
  • 第三方 CDN 托管好处是非常简单便捷的享受 CDN 服务。缺点是通常与其他资源不同源,从公共 CDN 加载文件会产生额外网络成本,如执行 DNS 查找、建立服务器连接、执行 SSL 握手等。而且你几乎无法控制缓存策略。
  • 自托管自托管第三方脚本资源可以更好的控制脚本的加载过程。减少 DNS 查找和往返时间、改进HTTP 缓存标头、利用HTTP/2 服务器推送。缺点是脚本需要手动更新,否则可能会过时,当API 发生更改或安全修复时不能做到自动更新。
  • 使用 Service Worker 缓存第三方脚本自托管的另一种替代方案是使用 Service Worker 缓存第三方脚本。它可以更好地控制缓存,同时仍能享受第三方 CDN 的好处。你可以创建自己的第三方资源加载策略,控制获取第三方脚本的频率,限制非必要第三方资源的请求。配合 preconnect 建立预连接也可以在一定程度上降低网络成本。详见使用 Service Workers 预缓存资源👆🏻。

Speed Index

速度指数,简称 SI。用于衡量页面可视区域内容填充的速度,它是页面可视区域内容填充花费的平均时间,通常3.4秒内可达快速等级。

以上面两组图为例,第一组图是优化后页面内容展现方式,由于过程是渐进式的,因此可以给用户更好的视觉体验。第二组图虽然是使用了同样的时间展示出全部内容,但由于空白的时间过长,内容填充花费的平均时间会更长,用户还很可能不耐烦的离开了。

SI 如何计算

Lighthouse 会捕获浏览器中页面加载过程的视频,并计算帧之间内容填充的进展,每帧得分 = 帧时间间隔(通常为100ms) * (1 - 可视区已填充内容占比),最终值为每帧得分总和。

如何提升

提升的关键在于提升服务器响应时间、优化关键渲染路径,避免渲染阻塞。

优化关键渲染路径

页面渲染的关键路径是指浏览器将 HTML,CSS 和 JavaScript 转换为屏幕上的像素所经历的步骤。关键渲染路径包含了 文档对象模型 (DOM),CSS 对象模型 (CSSOM),渲染树和布局。

优化关键渲染路径的常规步骤
对关键路径进行分析

此步骤主要是查看资源的数量、字节数、加载时间、路径长度等。可以通过 Performance 工具记录并查看 Network 栏中的资源请求过程、结合 Timings 中的时间点信息来检查关键路径信息。

每个资源请求的图形中都存在一左侧线段、浅色矩形、深色矩形、右侧线段:

  • 左侧线段:Request Sent 之前所有时间花费(Queueing ~ Proxy negotiation)。
  • 浅色矩形:Request Sent + Waiting (TTFB)。
  • 深色矩形:Content Download。
  • 右侧线段:等待主线程的时间花费。

一些关注点:

  • 资源的优先级和耗时。鼠标悬浮到对应资源上可以看到资源优先级(Highest、High、medium、Low、Lowest)。检查是否有 Highest 的非关键资源,或者是否存在某些关键资源以 Low、Lowest 优先级进行加载;
  • 资源加载各阶段耗时。资源矩形的长度体现了耗时,鼠标悬浮到对应资源上可以看到具体耗时和时间组成。检查是否存在耗时较长的资源、队列或主线程等待时间是否符合预期、是否影响到其他关键资源。
  • 加载顺序。某些关键资源的请求路径较深,是否可以提前加载,详见预加载关键资源。
  • 资源开始加载、完成、执行的时机(FP、FCP、LCP…)是否符合预期。

关于资源耗时,在 Network 的瀑布图可以做更具体的耗时分析。

面板中各项耗时项目的含义:

  • Queueing:队列等待时间。以下情况下请求会进行排队:* 存在更高优先级的请求;
  • 同源仅能打开六个 TCP 连接的限制(仅适用于 HTTP/1.0 和 HTTP/1.1);
  • 浏览器需要在磁盘缓存中短暂分配空间。
  • Stalled:请求停滞。请求可能因排队中所述的任何原因而停止。
  • DNS Lookup:DNS 查找。浏览器正在解析请求的 IP 地址。
  • Initial connection:初始连接。浏览器正在建立连接,包括 TCP 握手/重试和协商 SSL.
  • Proxy negotiation:代理协商。浏览器正在与 proxy server 协商请求。
  • Request sent:请求已发送。正在发送请求。
  • ServiceWorker Preparation: ServiceWorker 准备。浏览器正在启动 Service Worker。
  • Request to ServiceWorker: 请求 ServiceWorker。请求正在发送给 service worker.
  • Waiting (TTFB): 等待(TTFB,Time To First Byte)。浏览器正在等待响应返回的第一个字节,此时间包括 1 次往返延迟和服务器准备响应所用的时间。
  • Content Download:内容下载。浏览器直接从网络或 Service Worker 接收响应。Content Download 表示读取响应内容所花费的总时间。大于预期的值可能表示网络速度较慢,或者浏览器正忙于执行其他工作,从而延迟了响应的读取。
  • Receiving Push:接收推送。浏览器正在通过 HTTP/2 服务器推送接收此响应的数据。
  • Reading Push: 读取推送。浏览器正在读取之前接收到的本地数据。
最大限度的减少关键资源的数量

仅加载当前页面渲染所需的必要资源,删除或者延迟其他资源的加载。这里的关键是在于如何识别、提取关键资源👆🏻。

优化关键资源的体积以缩小下载时间

优化资源体积的通常做法就是资源移除未使用的代码,对资源进行压缩混淆,开启gzip压缩等。对于关键资源或者路径较深的资源可以预加载关键资源👆🏻。

优化关键资源的加载顺序

目的是尽早下载所有关键资源,以缩短关键路径长度。这里同样的可以使用预加载策略👆🏻。

最小化主线程工作

  • 使用 Web Workers 在浏览器的主线程之外运行 JavaScript

WebWorkers 提供了postMessageAPI,让你可以使用 myWorker.postMessage(someObject) 将 JavaScript 对象作为消息发送。由于 Web Worker 无法访问 DOM 和许多 API(如WebUSB、WebRTC或Web Audio),因此不能将这些有依赖于代码放入 Worker 中。配合 Comlink 可以让 Web Workers 的使用更简单方便。

  • 将任务分为较小的块

在运行时,长时间运行的 JavaScript 可能会阻塞主线程,从而导致页面无响应。使用类似requestAnimationFrame()requestIdleCallback() 可以将任务拆分为较小的块。

其他

上面已提及的消除阻塞的资源、确保文本在 webfont 加载期间保持可见都对优化SI有较大,不再赘述。

Total Blocking Time

总阻塞时间,简称TBT。它表示用户输入(例如鼠标点击、屏幕点击或键盘按下)被阻塞响应的总时间。当首次内容渲染(FCP)到可完全交互(TTI)期间执行了长任务,超出 50 毫秒的部分即为阻塞时间。任何执行时间超过 50 毫秒的任务都属于长任务,所以当 Lighthouse 检测到一个 70 毫秒长的任务,则阻塞部分即为 20 毫秒。TBT 在 200 毫米内达到快速等级。

长任务的定义

长任务是长时间占用主线程的 JS 代码,它会导致页面无法及时响及用户输入。在加载页面时,长任务可能会占用主线程而导致事件监听与处理程序较晚注册,从而导致页面看起来已经准备就绪,但用户点击不起作用。

RAIL 模型建议我们在 50 毫秒内处理用户输入事件,确保在 100 毫秒内做出可见响应会使得用户感觉交互是及时的。所以超过 50 毫秒的任务都属于长任务。

如何优化

重构低效的 JavaScript 语句

假设 document.querySelectorAll('a') 是一次返回 2000 个节点的调用,使用更精确的选择器重构代码使其仅返回 10 个节点,就会提升 TBT 分数。

如何找出长任务

使用 Chrome Devtools 中Performance 工具对页面进行录制,在录制完成后查看性能报告中的 FPS 和 Main视图(主线程)。若 FPS 栏出现红色条则表示这些帧存在验证问题。

点击FPS栏可缩小并调节查看的范围,在 Main 视图中查看导致掉帧的具体任务,被红色角标标记的即为长任务,鼠标悬停在栏上可知道任务的持续时间。

导致长任务的具体原因可选择灰色任务,选择 Bottom-UPActivity 查看哪些 Activity 对任务总时长“贡献”最大。下图中似乎是一组高昂代价的 DOM 查询。

减少不必要的 JavaScript 加载、解析或执行

大型脚本通常是长任务的主要原因,考虑将它们进行拆分,详见代码拆分、懒加载;删除未使用的代码,详见削减 JS;同时还需要留意第三方脚本,详见优化第三方脚本。

分解长任务

将长任务分解为多个运行时间 < 50 毫秒的小任务,并在正确的时机、地方运行执行它们。正确的地方还可以在主线程之外,详见最小化主线程工作。

Largest Contentful Paint

最大内容绘制指标,简称 LCP。它表示页面首次开始加载到可视区域内可见的最大(尺寸)图片或者文本块完成渲染的相对时间。相对于 FCP 测量初始 DOM 内容完成渲染所需的时间,但无法捕获页面上最大内容渲染所需的时间,LCP 更能体现屏幕可视区元素渲染的用户体验,因为最大的内容通常也是最有意义的。LCP 在 2.5 秒内达到快速级别。

LCP 考察的元素

  • <img>元素;
  • 内嵌在<svg>元素内的<image>元素;
  • 使用了封面图的 <video>元素;
  • 通过url()(非CSS 渐变)加载的带有背景图元素;
  • 含有文本节点或其他子元素的块级元素。

如何定义“最大”

  • 上报的 LCP 元素大小通常为视口所见到的尺寸。如果元素在视口外或被 overflow 裁剪了,这部分尺寸不计算入元素尺寸。
  • 对于哪些在原始尺寸上经过调整的图像元素,报告的元素大小为可见尺寸或原始尺寸,已较小者为准。例如图片通过 CSS 缩小则上报显示尺寸,拉伸或放大则仅报告原始尺寸。
  • 对于文本元素,仅考量文本节点的大小(包含所有文本节点的最小矩形)。
  • 对于所有元素,通过 CSS 设置的 margin、padding 和 border 都不计入考量范围。

通过上述考量比较后,可视区内相对最大尺寸的元素被选为被 LCP 考察的最大元素。

如何报告 LCP

由于网页通常分阶段加载的,因此页面上的最大元素也可能发生变化。浏览器为了应对这种潜在的变化,浏览器会在第一针立即分发一个 类型为 largest-contentful-paintPerformanceEntry 对象,对象的 element 属性引用的是当前的最大内容元素。在渲染后续帧之后,如果最大内容元素发生变化时则会分发另一个 PerformanceEntry 对象。

值得注意的是只有在渲染完成并且对用户可见后才会被视为最大内容元素,比如尚未加载的图片、字体未加载的文本节点(字体阻塞期)都不会被视为渲染完成。这些情况下,较小的元素可能就会报告未最大内容元素,但是当更大的元素完成渲染,就会使用另一个 PerformanceEntry 对象进行上报。

页面也可能会在新内容可用时向 DOM 中添加新元素,如果有任意一个新的元素大于之前的最大内容元素,浏览器还将报告一个新的 PerformanceEntry 对象。

如果当前的最大内容元素从可视区域被移除,那么除非有更大的元素完成渲染,否则该元素将持续作为最大内容元素。

当用户与页面进行交互(通过轻触、滚动或按键)时,浏览器将立刻停止报告新条目,因为用户交互通常会改变用户可见的内容。

示例

  • Instagram 标志加载得相对较早,即使其他内容随后陆续显示,但 Instagram 标志始终是最大元素。
  • 新内容被添加进 DOM,并因此使最大元素发生了改变
  • 由于布局的改变,先前的最大内容从可视区域中被移除。

发现 LCP 元素

  • Lighthouse LCP 诊断报告中的 Largest Contentful Paint element
  • 在开发者工具 Performance 面板中查看 Timings 栏,鼠标点击 LCP 标记会显示哪些元素与 LCP 相关联:

如何提升 LCP

提升服务器响应速度

  • 优化服务器。通常情况下首先要关注 HTML 返回的耗时,其中 Waiting (TTFB,发出页面请求到接收到第一个字节的耗时) 又是反映响应速度的核心指标,通常情况下 100ms 内是不错的 TTFB,建议不得超过 200ms。对于动态网页来说,服务器查询数据和渲染模版都需要一定的时间,因此当 TTFB 过长时可能需要去检查是否存在需要消耗大量时间和系统资源才能完成的查询,或者服务器端是否有其他复杂的操作会延迟页面内容的返回…
  • 使用 CDN。
  • 优先使用缓存提供 HTML 页面。Service Worker 以服务器与浏览器之间的中间人角色拦截当前网站所有的请求并进行判断,需要向服务器发起请求则转给服务器,否则直接使用缓存。使用 Service Worker 实现更小的 HTML 有效负载。
  • 尽早建立第三方连接。详见建立预连接。

减少阻塞渲染的 JavaScript 和 CSS

HTML 解析器遇到任何外部样式表或同步 JavaScript 标签(<script src="main.js">)都会暂停解析,因此脚本和样式表都是阻塞渲染的资源,延迟加载任何非关键的 JavaScript 和 CSS 能加快 FCP,进而加载 LCP,详见消除阻塞渲染的资源。

提升资源加载速度

虽然 CSS 或 JavaScript 阻塞时间的增加会直接导致性能下降,但加载许多其他类型资源所需的时间也会影响绘制时间。这里主要关注 LCP 考察元素的加载速度,以下是几种确保尽快加载这些资源的方法:

  • 优化和压缩图片资源。
  • 将图片转换为更新的格式以减少体积(JPEG 2000、JPEG XR 或 WebP);
  • 使用响应式图片。使用媒体查询根据设备和屏幕分辨率获取不同的图片资源;
  • 使用 CDN。
  • 预加载首屏重要资源。通常有些重要资源是较晚被解析发现的,如 CSS 中的自定义字体。如果你确定某些资源需要被优先获取,可以使用<link rel="preload"> 更早的获取该资源,但应该仅对关键资源(如字体、首屏图片或视频、关键路径CSS/JS)进行预加载。详见预加载关键资源👆🏻。
<link rel="preload" as="script" href="script.js" />
<link rel="preload" as="style" href="style.css" />
<link rel="preload" as="image" href="img.png" />
<link rel="preload" as="video" href="vid.webm" type="video/webm" />
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin /> 
  • 压缩文本文件。所有浏览器都支持 Gzip,使用 Gzip 之类的算法可以显著缩减文本文件大小。

自适应服务

自适应服务是指加载页面主要内容资源时,根据用户的设备或网络条件按需获取不同的资源。

这种做法主要是通过使用网络状况 API、设备内存 API 和硬件并发 API 来实现,比如当用户处于低于 4G 的网络连接速度,你可以显示图像,而不是视频:

if (navigator.connection && navigator.connection.effectiveType) {if (navigator.connection.effectiveType === '4g') {// 加载视频} else {// 加载图像}
} 

相关一些实用属性:

  • navigator.connection.effectiveType:有效连接类型。
  • navigator.connection.saveData:启用/禁用数据保护程序。
  • navigator.hardwareConcurrency:CPU 核心数。
  • navigator.deviceMemory:设备内存。

使用 Service Worker 缓存资源

Service Worker 提供较小的 HTML 响应,还可用于缓存任何静态资源,并在收到重复请求时将资源直接提供给浏览器,而无需通过网络请求。使用 Service Worker 预缓存关键资源可以显著减少资源加载时间,特别是对于弱网下重新加载网页(甚至离线访问)的场景。详见使用 Service Worker 缓存资源 👆🏻。

使用服务端渲染

对于客户端渲染的网站来说,首要的工作是将 JavaScript 的数量最小化,但结合服务端渲染可以进一步改善 LCP。服务端渲染的实现方式是使用服务器执行 JavaScript 将应用程序的 DOM 填充到模板并渲染为 HTML,浏览器获取到的是不再仅仅是一个 HTML 外壳。当客户端接管页面后将所有 JavaScript 及所需数据“水合”到相同的 DOM 中,因此这种做法可以确保页面的主要内容在服务器上已进行渲染(而无需等待客户端完成 DOM 渲染)从而改进 LCP。

相对的服务端渲染也会带来一定的弊端:增加同构的复杂性;服务器执行 JavaScript 渲染 HTML 文件会增加服务器响应时间(TTFB);服务端渲染的页面在所有客户端 JavaScript 执行完毕之前响应对任何用户输入作出响应,从而使得可完全交互(TTI) 变得更差。

使用预渲染

预渲染不像服务器渲染那样即时编译 HTML,它只在构建时为特定的路由(通常需要 SEO 的页面)生成特定的几个静态页面。对于客户端渲染的网站,使用预渲染是提升 LCP 常用方法。

下面是一个通过 webpack 与 PrerenderSPAPlugin 插件实现预加载的简单示例(详见这里):

const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')
module.exports = {plugins: [...new PrerenderSPAPlugin({// Required - The path to the webpack-outputted app to prerender.staticDir: path.join(__dirname, 'dist'),// Required - Routes to render.routes: [ '/', '/about', '/some/deep/nested/route' ],})]
} 

Cumulative Layout Shift

累积布局偏移,简称 CLS。其计算自未在用户输入 500 毫秒内发生的布局偏移的偏移分数总和,通过其值来表示测量内容的不稳定性,其值越高稳定性越差。通常分数在 0.1 内为高稳定性表现。

CLS 如何计算

CLS 分数 = 影响分 * 距离分。

影响分:前一帧和当前帧的所有不稳定元素的可见区域集合(占总可视区域的部分)就是当前帧的影响分。上图中灰色背景标识的元素在一帧中占据了可视区域 50%,在下一帧中,元素下移了可视区域高度的 25%,红色虚线矩形框表示两帧中元素的可见区域集合,即该集合占总可视区域的 75%,因此其影响分数为0.75

距离分:任何不稳定元素在一帧中位移的最大距离(水平或垂直)除以可视区域的最大尺寸维度(宽度或高度,以较大者为准)。图例中最大的可视区域尺寸维度是高度,不稳定元素的位移距离为可视区域高度的 25%,因此距离分数为 0.25。

本示例中,布局偏移分数即为 0.75 * 0.25 = 0.1875

如何发现影响 CLS 的元素

  • 查看开发者工具 Lighthouse 面板的 CLS 诊断报告中:
  • 在开发者工具 Performance 面板中查看 Experience 栏,鼠标点击出现的 ``标记:

常见原因与提升方案

无尺寸的图像

使用响应式网页设计后,我们常常会省略图片和视频元素上widthheight,并取而代之开始使用 CSS 来调整图像大小👇🏻,这样做的缺点是只有在图片开始下载且浏览器可以确定其尺寸后才能为其分配空间,从而也会导致页面发生重排,原本的文本发生突然偏移。

img {width: 100%; /* or max-width: 100%; */height: auto;
} 

始终在图片和视频元素上包含widthheight属性。或者通过使用 CSS 定义长宽比给容器预留所需的空间,从而确保浏览器能够在加载图像期间在文档中分配正确的空间大小。

现代浏览器会根据widthheight属性设置图像的默认长宽比:

<img src="puppy.jpg" width="640" height="360" alt="小狗与气球" /> 

而且所有浏览器的UA 样式表都会根据元素现有的widthheight属性添加默认长宽比,即 aspect-ratio :

img {aspect-ratio: attr(width) / attr(height);
} 

如果图片在容器中,使用 CSS 将图像调整为容器的宽度,设置 height: auto; 即可。

核心就是永远不要忘记设置在图片元素上设置 widthheight 属性(非style中的widthheight),从而保证浏览器可以添加 aspect-ratio 使得图片加载时能预留空间。

img {height: auto;width: 100%;
} 

处理响应式图片

一种简单的方式是可以可以设置 width= “100%”,CSS 中显式设置预期的 aspect-ratio,由于其并不影响图片实际渲染的宽高,只影响图片加载期间的预留空间,因此 aspect-ratio 的值可以与实际的宽高比不一致。

<img src="xxx" width="100%"> 
img {max-width: 100%;width: auto; // 保证图片渲染为固有尺寸aspect-ratio: auto 4/3;
} 

如果你不能预测宽高比,但如果你可以知道图片的原始尺寸信息(即使与实际渲染宽度不一致),则可以设置width和height属性,实际渲染宽高通过css样式控制。

以富文本中的图片场景为例:

通常的富文本组件在编辑图片时都可以设置图片相对父元素的实际渲染宽度百分比,从而得到添加了 width 属性的 img 字符串。

<img> <!-- 默认或重置 -->
<img width="30%"> <!-- 30% -->
<img width="100%"> <!-- 100% --> 

通过自定义图片上传成功的方法获取图片原始尺寸信息并添加到 img 标签上。

<img alt="size=2048*1456">
<img alt="size=2048*1456" width="30%">
<img alt="size=2048*1456" width="100%"> 

页面获取到文本字符串后先做一些处理:

  • 添加width、height属性使得浏览器能得到aspect-ratio
  • 通过内联样式或css样式控制实际渲染尺寸(图片不拉伸)。

最终转换为👇🏻:

<img alt="size=2048*1456" width="2048" height="1456">
<img alt="size=2048*1456" width="2048" height="1456" style="width:30%">
<img alt="size=2048*1456" width="2048" height="1456" style="width:100%"> 
img {max-width: 100%;height: auto;
} 

设置 height: auto 的目的是保证图片能真实宽高比渲染高度,否则会将使用设置的 height 属性而使得图片被拉伸。

无尺寸的广告、嵌入和 iframe

广告

广告是一些网站重要收入来源,但在广告生命周期的许多时间点都会引发布局偏移,影响网站的用户体验。缓解广告导致偏移的常见做法:

  • 为广告位预留空间;
  • 无广告返回时显示占位符避免预留空间被折叠;
  • 避免在可视区域顶部附近放置广告。因为可视区域顶部附近的广告比中间的广告可能造成更大的布局偏移。

嵌入和 iframe

一些可嵌入的组件能够使页面嵌入其他网站的可移植内容(如视频、地图、发帖),这些嵌入相对广告内容通常更难提前预知嵌入内容的大小(比如帖子内容中无法预知有多少文本、图片和视频),但也不能给这些嵌入内容预留尽可能大的空间,只能通过使用占位符给嵌入预留空间来最大程度减少 CLS。

动态内容注入

通常情况下除非是对用户交互做出响应,否则应尽量避免在现有内容的上方插入内容,这样可以确保任何布局偏移都在用户的预期之内。在一些必须的、可预知的场景下(如顶部横幅)应尽可能避免让用户感到意外的布局偏移,可以使用占位符或者骨架图进行空间预留。

在某些必须动态内容添加的场景下(如加载更多内容到列表、更新实时反馈内容),通常使用以下几种方法避免意外布局偏移:

  • 在固定尺寸的容器中进行新旧内容替换。过渡完成之前应禁用链接和控件以避免意外点击。
  • 让用户主动加载新内容,如加载更多、刷新按钮,在用户输入后 500 ms 内发生的布局偏移不会记入 CLS。
  • 屏幕外的内容完成加载(通常是顶部内容)可以向用户添加一个通知(如显示一个向上滚动的按钮),说明存在内容更新并且已经可用。

FOUT 与 FOIT

下载和渲染自定义字体时可能存在两种方式导致布局偏移。

  • 后备字体替换为新字体时导致无样式文本闪烁(FOUT);
  • 新字体完成渲染前浏览器会隐藏文本从而导致不可见文本闪烁(FOIT)。

以下方法可以最大程度减少这两种情况:

  • font-display用于指定字体的显示策略。值为swap 时告诉浏览器使用了该字体的文本应立即使用系统字体显示。遗憾的 swap 会导致重排。
  • 预加载字体资源。使用<link rel="preload"> 预加载字体将有更大几率保证字体在首次绘制中可以使用(详见预加载CSS中定义的资源)。font-display: optional 为字体提供一个 100 ms 的阻塞周期,并且没有交换周期,所以 <link rel="preload"> 结合 font-display: optional 使用可以避免导致重排。

动画

对很多 CSS 属性值进行更改都会触发浏览器进行重排(如 box-shadowbox-sizing),尽可能使用 transform 实现动画仅会导致重绘从而避免布局偏移。

发布评论

评论列表 (0)

  1. 暂无评论