Lighthouse 和性能优化

251 阅读13分钟

项目做久了,经常会思考怎么让自己的项目更好一点。页面加载很慢,开发过程中也会很烦躁。开发时,开发者自己也是用户,久而久之,也总结出一点想法。产品的流畅程度,分为两部分,一个是项目运行真的很快,一个是用户感觉上的快。项目运行的快发,可以通过优化代码实现。一些特殊情况导致的产品卡顿,例如网络问题,代码层面是没有办法处理,也就是在程序员控制范围之外。这时候需要通过给用户一点点反馈,来减缓等待的烦躁,这也就是我说的感受上的快。

例如,我们去餐厅吃饭。除了预制菜,菜品上齐总需要等待一些时间。这个过程中,假设服务员经常提供一些小的服务,例如询问需要什么样的茶水,我们提供零食是否需要。这部分服务,和后厨做饭是同步进行的。上菜时间在合理范围内,消费者通常不会把这部分时间,算到枯燥的等待时间之中。同理,假设我们能在用户等待整个页面响应完成的过程中,持续提供一些简单的反馈,会大大减少体验上的厌烦情绪。前提是你的产品能在合理的时间内完成加载。如果服务员给食客讲完了一整段评书,菜还没有上来,再精彩的评书怕是也很难挽回客户,说到底人家还是来吃饭的。

优化要了解原理,以及找到优化方向。幸好 Chrome 提供了 Lighthouse 作为分析工具,配套的文档、博客很丰富。内容很琐碎,记录下来,免得遗忘。

参数

Lighthouse 评估考量的维度很全面。指标体现的问题,日常工作中可能也遇到过。开发过程中若能提前考虑,可以减少出现问题的几率。记录一下,增加优化意识,也方便日常中的排查优化工作。

First Contentful Paint(FCP)

首次内容绘制 (FCP) ,浏览器渲染出第一段 DOM 内容所用的时间,DOM 片段的大小不作为考量。这个参数其实影响没有那么大,一个只有很少文字的 span 标签,也被视作 FCP。这部分可能不是用户视觉的重点,不会使用户明显感到网站响应快速。

Speed Index

官方中文翻译叫速度指数(AI 机翻的),在我看来像渐进变化指数。例如我们的页面,可能是两栏布局。为了加快响应,开发者可能倾向于先展示出主体框架,例如使用骨架屏,随后往框架中填充细节。整个过程是一个渐进变化,衡量变化速率可以看 Speed Index。

TODO: 这里涉及到 SPA 的问题,SPA 是框架控制渲染 DOM。以 Vue 举例,渲染 DOM 由框架控制,这部分是阻塞主线程的,在 befaorCreate 发出的请求 callback 是微任务,应该会在主线程完成后调用?DOM 渲染完成后,会请求 DOM 片段中的图片,这个过程也是渐进变化的。问题是 DOM 后面发出的请求可能是页面非常重要的内容,例如主页的图片,创建 DOM 时线程阻塞不会请求图片,可能会影响到 LCP。

Total Blocking Time(TBT)

总阻塞时间,计算页面从开始渲染,到可以响应交互的总阻塞时间。单个任务执行时间超出 50ms,则被视为长任务。超过 50ms 的部分被视为阻塞时间,例如一个任务耗时 100ms,阻塞部分为 50ms。将阻塞部分全部相加,即是 TBT。

Cumulative Layout Shift(CLS)

累计布局偏移。布局偏移也算是比较常见的场景,如页面中有元素需要权限,加载过程中,接口返回相应的权限,前端需要显示或隐藏元素,相邻元素就可能偏移。如果在这个跳变过程中,用户需要操作对应元素,就有误操作风险。例如添加删除按钮时,原位置 UI 显示是详情按钮。用户想去点详情,刚好发生跳变,就会误触删除按钮。

Largest Contentful Paint (LCP)

LCP 用于测量视口中最大的内容元素何时渲染到屏幕上, 报告的是视口中可见最大图片或文本块相对于用户首次导航到网页的呈现时间。这粗略地估算出网页主要内容何时对用户可见。页面中最大的区域,通常也是用户关注的焦点,这部分优化很重要。

优化手段

优化主要涉及到两方面,浏览器行为和网络请求,先从浏览器加载网页开始考虑。

优化资源加载

总所周知,JavaScript 和 CSS 都有可能会阻塞 DOM。默认情况下,只要我们引入 JavaScript ,一定会阻塞 DOM 解析,无论相关的功能模块是否被使用到。

JavaScript 可以调用 API 修改 DOM 和 CSSOM。浏览器为了不重复工作(例如,极端情况,JavaScript 可以把 html 内容全部清空。即使这段代码后面,仍有未解析的 html 片段,也无需解析了,因为内容已经被清空了),遇到 JavaScript 就会暂停 DOM 构建,脚本执行完成后再继续构建 DOM。如果,JavaScript 代码中需要修改 CSSOM,此时 CSSOM 并未构建完成,浏览器会暂停脚本执行,等待 CSSOM 构建完成。也就是说,JavaScript 一定会阻塞 DOM 构建。CSSOM 和 DOM 没有依赖关系,但是 CSSOM 可能阻塞 JavaScript 运行,进而影响到 DOM 构建。

浏览器渲染机制是会尽快渲染,当遇到网络请求或阻塞任务时,会尝试将已解析内容渲染到页面,接着处理阻塞部分。为了尽快呈现完整 DOM,开发者应延迟尽可能阻塞渲染的内容。可以理解为渲染和 JavaScript tasks 也存在 eventloop,渲染引擎解析 html 时,遇到 JavaScript 脚本,就会切换至 JavaScript。切换前,会将已经解析的内容渲染到页面上。

移除没有使用的资源

不用的 JavaScript 模块,也会执行。最简单的优化方向就有了,移除没有使用的资源。这里可以使用 Chrome 开发者工具,查看引入资源的覆盖率(Coverage)。打开控制台,使用快捷键 Ctrl + Shift + P,Mac 是 Cmd + Shift + P 打开命令行查找,输入 Coverage 打开标签页。点击录制,刷新页面,就可以看到JS 和 CSS文件的覆盖率。

重点关注覆盖率低的文件,部分文件可能是依赖,当前没有使用,后面会用到。不能看到没有使用就移除,还是需要开发者自己去甄别。引入但不使用的文件,在多次迭代升级的老项目中很容易出现。

优化加载方式

还有一些覆盖率很低的文件,是没有按需导入造成的。可以按需导入的模块,尽量使用按需导入。有些第三方依赖没有提供按需导入,但是自身并不影响页面渲染。这种资源可以使用异步加载的方式,例如使用 async 属性,并行异步加载 js 资源,请求过程不会阻塞渲染。

对于 CSS,可以分割未使用部分,异步加载来加快初始渲染速度。在 Coverage 页面,点击相关资源,浏览器使用颜色标识了那些行代码没有使用。拆分未使用代码,使用以下方法延迟处理相关 CSS,参考这篇文章

<!-- preload 会在网络请求中提到 -->
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>

如果有多端适配需求,还可以使用媒询,加载不同的设置。下面是 MDN 上的一个例子,media 是媒询条件:

<link
  href="mobile.css"
  rel="stylesheet"
  media="screen and (max-width: 600px)" />

这里强调下,我们没有必要把每一步做到极致,更应关注整体表现。 例如,为了加快渲染速度,把代码拆的七零八落,会增加请求消耗。请求的资源很小时,主要的消耗不是传输,变成了请求中的 RTT(Round-Trip Time,往返时延)。网络不理想,频繁请求更要命。

async 和 defer

JavaScript 异步加载有两种方式,一个是 async,另一个是 defer。像前文讲的,浏览器遇到 script 标签,无论是行内还是外联脚本,都会停下 DOM 解析,等待 JavaScript 执行完成。外联脚本,还需要请求网络,耗时更加严重。给 script 标签添加 async 属性,可以标记异步资源。这样浏览器就会并行请求脚本,等待资源返回的过程,不会阻塞 DOM 渲染。脚本返回后,立即执行。一般来说,DOM 解析是很快的,远小于网络请求耗时。很多依赖和页面结构无关,不影响渲染的都可以使用异步加载。async 无法确定返回时机,谁先返回执行谁。假设脚本之间有相互依赖,就会出现问题。defer 是另一个异步加载的方式,defer 的文件会在 DOMContentLoaded 事件之后触发,这个时候 DOM 已经全部解析完成,脚本一定可以获取 DOM 信息。另一个重要的点是,defer 脚本是按照 html 中先后顺序执行的。如果脚本间有依赖关系,defer 可以确保执行的先后顺序。

压缩文件,推迟加载

文件越小,自然传输越快。压缩文件也是提升性能的重点,现在框架的脚手架都配置了 js、css 文件压缩,通常不需要我们做什么处理。更多需要考虑的是,图片这些资源。针对不同尺寸的屏幕,需要的图片大小也不一样。例如移动端尺寸有限,就没有必要设置像素很高的图片。使用媒询,针对不同像素、不同尺寸,按需加载不同的资源。下面是一个 img 标签使用媒询的例子:

<img
  src="clock-demo-200px.png"
  alt="Clock"
  srcset="clock-demo-200px.png 200w, clock-demo-400px.png 400w"
  sizes="(max-width: 600px) 200px, 50vw" />

对于图片来说,AVIF 和 WebP 这两种图片格式具有更优的压缩和质量特性。采用新的格式,可以显著减少图片大小。

还有一种提升加载速度的方法,就是懒加载。懒加载基本上前端必须知道的,这里就不赘述了。

优化网络请求

启用文本压缩

这个也是一般服务器都会配置的,目前主流传输的压缩算法是 gzip,查看 Response Headers,如果配置了 Content-Encoding 说明已经配置了压缩。

预解析和预连接

网络状况决定了请求返回速度,前端也可以做一些优化。网络请求中,使用的前缀一般是域名。域名经过 DNS 解析可以找到对应 IP。请求原网站时候,域名已经解析过了,否则也不可能响应内容。假设请求别的网站资源,例如第三方脚本等,需要再次解析第三方域名。为了尽快获取第三方源信息,建立连接,可以使用 dns-prefetch 提示浏览器预解析。使用如下:

<link rel="dns-prefetch" href="https://example.com" />

还有就是大家都知道的 TCP 握手,需要经过三次握手,才能在两台电脑之间建立一个 TCP 会话。如果使用 TLS,还需要增加五次握手。这个过程 RTT 消耗是比较高的,为了减少消耗,可能会希望提前和网站建立连接。可以使用 preconnect 提前进行连接,这种情况比较少见。适用于需要获取某个网站的资源,但是不确定具体什么内容,需要运行时才知道的情况:

<link rel="preconnect" href="https://example.com">

使用 CDN 和缓存

这两个也是常见的优化手段,大家多少也都接触过。物理距离上越远通讯耗时也就越长,使用 CDN 查找物理距离更近的服务器,可以提升响应速度。使用缓存,尽量使其命中强缓存。普通的前端项目,都是通过 html 文件导入其他资源。html 文件使用协商缓存,其余资源使用强缓存,并用 hash 保证修改时文件名会变。资源文件名修改,就会改变 html 中的引入路径,能保证不使用缓存的老资源,设置缓存时长,定时清理不使用的缓存。

预加载

为了优化渲染,通常我们会把 CSS 内容放在头部(CSSOM 并不会阻塞渲染,希望提早进行构建),JavaScript 内容放在 DOM 结构之后(阻塞渲染行为推后)。代码的执行是从上倒下的,默认的资源请求行为,是在执行到具体位置发出(img 的资源请求,js 文件导入等)。浏览器的网络请求,和页面的渲染进程并不是一个,也就是说页面的解析渲染过程可以和网络请求同步进行。代码执行到特定位置再请求,会增加等待时长。我们可以手动提示浏览器预取部分资源,同步进行网络请求和 DOM 的解析,这就是预加载。

常见的预加载包括两种,preload 和 prefetch,两者使用的场景有区别。preload 用于声明获取请求,指定页面很快就需要的资源,提升对应资源的请求优先级。相当于把马上用到的资源请求提前,并不执行,只是把返回的资源缓存下来。prefetch 是用于预取将在下一次导航/页面加载时使用的资源,对于当前页面没有用,优先级更低。

理解这两者区别可以看下面的代码:

<head>
    <!-- preload 必须使用 as 执行类型,浏览器对于不同类型会有不同的请求优先级 -->
    <link rel="preload" href="image1.webp" as="image">
    <link rel="prefetch" href="image2.webp">
</head>
<body>
    <img src="image1.webp" alt="image1">
    <img src="image2.webp" alt="image2">
</body>

打开控制台,可以看到网络请求中,image1 只被请求一次,而 image2 被请求两次,第二次请求 304 使用了缓存。也就是说 preload 真的把当前页请求提前了,prefetch 只是提前请求数据,并缓存起来,没有做更多的预处理。prefetch 预取的是将来导航页面的资源,导航行为也就不会取消网络请求。

Chrome lighthouse 文档

:外部资源链接元素

浏览器的工作方式