首先说明,这里的指标,指的是使用 Chrome 提供的分析工具 Lighthouse 所评估出来的分数。
这个分数里面,主要有以下指标以及它们所占评分的比重: 不小心暴露了哈,开始这次优化之前我们项目的分数是 55 分。
首先来介绍一下这几个指标的具体含义:
- FCP (First Contentful Paint) 指的是从页面开始加载到页面内容首次被渲染到屏幕上的时间,"内容"是指文本、图像(包括背景图像)、 元素或非白色 元素
- SI (Speed Index) 衡量页面加载期间内容视觉显示的速度。 Lighthouse 首先捕获浏览器中页面加载的视频,并计算帧之间的视觉进度(根据 Chrome 文档上的说法,其他指标改进了这个指标自然而然就改进了)。
- LCP (Largest Contentful Paint) 页面中可见的最大图片或者文本块被渲染的时间
- TBT (Total Blocking Time) 总阻塞时间,指标测量首次内容绘制 (FCP) 之后主线程被阻塞足够长的时间以阻止输入响应的总时间量。
- CLS (Cumulative Layout Shift) 衡量页面的整个生命周期内发生的每次意外布局偏移的最大突发性布局偏移分数。
web.dev/explore/met... 官方文档定义指标含义
可以看到,我们这个项目中比较差的是 FCP、SI 以及 LCP 指标,所以此次优化的就是着重优化这三个个指标,先定个小目标,优化之后分数到 70 分。
开始优化之前,我想先说明一下从访问我们项目的一个首页(这个页面主要有四个模块,我的服务信息、商品列表和服务介绍和支付组件) ,到页面渲染出来经历了哪些阶段,然后再去说明每个优化手段要提升的是其中哪一块的速度。
- 首先,访问页面的请求到达 next.js 的服务端,服务端使用 fetch 或者应用程序需要的数据(多语言等数据),然后根据路由地址将应用程序呈现为 html ,此时 我的服务信息和服务介绍都是显示的占位元素。
- 客户端收到 html 后开始解析 html,这个过程中会开始加载 html 中包含的 CSS 文件和 JS 文件。页面首次渲染时,此时页面展示的是占位元素,我的服务信息、服务介绍模块都是在 client 端请求到接口数据之后再展示真实的 UI, 商品列表模块需要先看我的服务信息模块的数据才决定是否展示,也就是说查询我的服务信息接口和查询商品列表接口顺序是同步的。
- js文件加载完成后,然后开始 React 的 hydration 流程,hydration 完成之后,页面就变成可交互的了。
以下说到的优化手段,有些是我们项目开始优化之前就已经完成(我会将其标注出来),这里我也会列举出来方便自己以后回顾。然后有些也会介绍这个优化手段优化的是哪个指标
1.静态资源开启 gzip 压缩
(当前已开启)
next.js 打包后会生成 server 端和 client 端的产物,一般来说 client 端的产物会上传到静态资源服务器上,上面第二步提到的加载 css 和 js 文件就是在加载 client 端需要的文件。
网络传输,最关键的当然是资源的大小,资源越小传输的就越快,所以开启 gzip 压缩既不用花费太多精力,收益又很大的措施,建议一定要检查自己项目是否开启了,没开启一定要开起来。
对于我们当前的项目而言,这个能够有效的优化 FCP 指标和 LCP 指标。虽然服务端渲染返回的 html 内容已经有占位图了,但是 lighthouse 评估项目中的 LCP 元素为商品列表模块的商品。在js 文件加载完成之前页面会一直展示占位元素,js文件加载完成之后,client 端的组件才会开始渲染,才会发起接口请求,所以这步的意义在于使 client 端的内容能够更快的渲染出来。
link 标签加载 css 资源不会阻塞浏览器对于 dom 的构建,但会影响其渲染。因为浏览器最终需要 dom tree 和 css tree 都构建完毕后才开始页面渲染。所以,这里即使服务端渲染返回的 html 里已经有占位元素了,link 标签加载的 css 资源的时间还是会导致页面白屏
2.静态资源、图片等开启强缓存
(当前已开启)
第一点能有效的减小网络资源的大小,第二点在重复加载网络资源的时候有奇效。
如果命中强缓存,就不会发起网络请求,能够瞬间加载到本地缓存的网络资源。
这点也是跟第一点一样能够优化 FCP 和 LCP 指标,因为它也是"减少"了网络请求所花费的时间。
还有一个点在于,它能减少当前网络资源并发数。举个例子,当前页面在当前需要在同一个域名下加载总共50个js、css文件以及30个图片资源,即使它们是同一优先级的,浏览器肯定也不会瞬间就打出80个网络请求。以 chrome 为例子,它针对同一个域名允许的最大并发请求数为6,也就说同一个域名下一次最多只能处理6个网络请求,80个请求就得排队。
3.优化 javascript 文件的引入方式
(当前已开启)
使用 Script 标签的 async 和 defer 属性来优化脚本的执行时机,以优化页面的渲染。使用预加载属性。
得从浏览器第一次渲染的流程开始说起了。
首先浏览器会将拿到 html 进行解析,生成 dom tree ,遇到css样式会生成 css tree(dom tree 和 css tree 生成的过程互不干扰),然后这两生成一个 render tree,然后绘制屏幕。
当解析 html 遇到 script 标签时,是会阻塞 dom tree 的生成,会先加载script标签的资源,这就意味着,写在 script 标签之后的元素,要等到 script 标签加载完毕且执行完毕才能被绘制到屏幕上。
有以下三种手段可供选择:
- 将 script 标签插入到 body 的最下方,使其不要阻塞页面内容 dom tree 的构建。
- async: 立即下载脚本,但是不妨碍页面中其他操作,当脚本下载成功后立即执行,不保证 script 的执行顺序,先加载即先执行。加了 async 之后类似于把 script 标签插入到 body 最下方,但是注意有区别,有多个 script 的话先加载完毕先执行。
- defer: 立即下载脚本,但是在解析文档之后,触发 DOMContentLoaded 之前执行,该属性将阻止 DOMContentLoaded 事件触发,直到脚本加载完成且执行。脚本按照顺序执行。
我们项目中使用的是 async,但是这里其实我是有疑问的,使用 async 是怎么保证执行顺序的呢?最起码的 webpack 的 runtime 肯定得是第一个执行的脚本才行的吧?这里我得去请教下写这块逻辑的老板,或者有懂的同学欢迎分享一下见解。
还有一点就是,rel=preload 来设置 javascript 资源加载的优先级
bash
<head>
<link rel="preload" as="script" href="xxxx" />
</head>
写在 header 里面,这样浏览器可以一开始就知道要加载这些资源,可以让 javascript 尽早可用但是又不阻塞页面的渲染。
相关参考文章:
<script> 脚本以及 <link> 标签对 DOM 解析渲染的影响
4.webpack 打包公共依赖拆分
(当前已开启)
在 next.js 中,不同的页面肯定属于不同的 chunk,比如现在有5个页面,5个页面都引用了模块A,如果不进行拆分的话,那5个页面打包生成的代码里边都会包含模块A。
使用 Webpack 的 splitChunks 去拆分公共代码,可以减小打包出来的产物的体积,webpack 提供了精细的配置,让开发者可以选择:从哪种 chunk 里进行拆分、模块最小体积为多少才拆分、模块最少被引用多少次才拆分等等。
这里是需要做取舍的,如果拆分的太细,虽然打包产物可能会减小,但是同一时间的并发请求数就增加了。
5.动态加载组件 dynamic
使用 dynamic 动态的加载组件。
我们的项目中有一个支付组件,是在选中商品之后才会展示出来。所以根本就没有必要在页面的首屏代码中包含这个组件。
dart
const ComponentC = dynamic(
() => import('../components/C'), { ssr: false }
)
// 使用 ssr false, 服务端代码也不会包含此组件
这样能减小服务端生成的 html 代码的体积提高 FCP,也能减少 client 端首屏的代码,提高 LCP。
6.检查项目中的依赖,尽量减小项目的依赖包体积
- 检查项目中是否存在被打包但是没有被使用的资源
- 看下是否依赖被是否有更轻量的实现
举个例子。
在项目引入 lodash 这个库的时候,不要使用
javascript
import _ from 'lodash'
而是用哪个方法就引入那个方法。
javascript
import _get from 'lodash/get'
// 或者
import { get } from 'lodash'
第一种方法无法 tree shaking,会导致 lodash 整个库被打包进来。
比如这次我们项目中,分析打包产物发现 lodash 被整个打包了,但是看我们项目中引入的方式是正确的,后面接着分析发现是项目的一个依赖包中使用了:import _ from 'lodash'
尝试吧这个依赖包去掉一下再接着看打包的产物:
还有就是,即使项目中使用的是 import { XX } from 'xxx'
tree shaking 也不一定会生效,需要引入的那个包的也支持 tree shaking 才行。
比如我们项目中使用另外一个包:
这种情况下只能:import useAsync from '@tuya-fe/react-hooks/lib/useAsync';
去按需加载
减小项目依赖包体积其实就是在减少产物的体积
遗憾的是在写这篇文章的时候,lodash 的那个问题还无法解决,因为那个包是项目多语言方案使用的且没人维护了,要升级的话改动特别大,需要从长规划。
7 图片采用 webp 格式,首屏图片确定宽高
webp 格式压缩效率更高。
项目中的静态资源图片都替换成 webp 且压缩。
还有一点是,位于首屏的图片尽量把宽高确定下来,提前占住图片的位置,防止出现图片渲染出来后整个布局向下推移。CLS 指标就是测量这个事情的。
8.结合业务情况,利用 next.js server side 的能力
回想一下之前说明的业务中的逻辑,页面中主要有四个模块,我的服务信息、商品列表和服务介绍和支付组件。
支付组件由于不必再首屏渲染,使用动态加载的形式导入了。
关键在于我的服务信息和商品列表组件,server side 仅是展示了我的服务信息的占位元素,到了 client 端才会发出接口获取我的服务信息,接口响应之后再根据结果去判断是否要展示商品列表组件,判定要展示商品列表组件时,组件才会去查询商品列表接口然后将结果展示出来,而在 Lighthouse 的判定中,LCP的元素为商品列表中的商品卡片。
需要明白一个道理是在客户端调用一条接口到响应的成本跟在服务端调用的成本是不一样的。next.js 的应用跟服务端的应用都处于我们公司的集群之内,也就是说从在 next.js 的服务端去调用这个接口,省去了从客户的电脑连接到服务端接口所在服务器的网络延时。
根据统计出来的数据来看,查询服务信息接口在 client 端超过 25%的用户花费 200ms,而在服务端调用这条接口所需时间仅为 42ms 左右,也就是说,如果把这条接口放到服务端去调用,理论上 LCP 时间能减少 160ms 左右。
这里 LCP 时间减少了,FCP 时间却会增加。因为 next.js 要在接口获取到数据后且将 html 字符渲染出来了接口才会响应。但是从 Lighthouse 的分值比重来看,LCP 占25% FCP 占 10%,所以这里牺牲一点 FCP 的时间来加快一点 LCP 的时间。
正是由于在 server side 在等待接口响应的时候,页面是白屏状态的,所以这里不把商品列表请求也放到服务端,商品页面接口较慢,即使在服务端也要花费近 500 ms。
9.升级 react18,使用 react18 服务端 suspense 新特性来优化 FCP
待应用
第8点就是对7点遗憾的完美解决。
在第7点中,我们牺牲了 FCP 的时间去加快了 LCP 的时间。react 18 中的新特性完美的解决了这一遗憾。
New Suspense SSR Architecture in React 18
React 18 增加了服务端渲染对于 Suspense 的支持,实现了 http 返回一个 html 数据流,支持在服务端获取数据等待时,先返回 html ,Suspense包裹的组件先展示 fallback 的组件,等待获取到数据之后再发送一个数据包将屏幕上的占位元素替换掉,这对优化 FCP 及其友好。
写了个 demo 体验了一下:github.com/Chechengyi/...
React 观望对于 Suspense 组件的介绍:react.dev/reference/r...
但是很遗憾,我司使用的基于 next.js 某个版本魔改的框架目前还没有对这波特性进行支持。后续会跟维护者提需求,或者参与共建这个事情。待那时在改造我们的项目,我觉得做到 Lighthouse 80 分以上是没问题的。
有条件的开发者真推荐在项目中用起来了,这个特性是真的优点到最痛的点上了!
结尾
经过一波操作后,目前将这个分数提升到了 69 分,如果后续将多语言包的那个问题解决掉把 lodash 全量包剔除出去,应该还能增加一点。
用 Lighthouse 做分析只能在自己的电脑上做,后续发布上线之后,还会关注 Sentry 的 performance 面板指标是否有提升,不出意外的话应该是有的。
其实前端能做的性能优化的手段就那些,面试题背的熟的肯定不只能说出这9条来。
个人认为重要的是,会采用工具分析从哪里着手优化,然后结合自身业务去做调整。
参考文章
Fast load times - (详细记录了各种优化手段,有性能优化需求的建议看)
Optimize Next.js App Bundle and Improve Its Performance
New Suspense SSR Architecture in React 18