网页的性能优化
从用户在浏览器输入网址,按下回车那一刻起,用户对网页的感受就开始了。
紧接着,用户滚动页面,页面是否流畅?
又或者用户点击按钮,网页是否快速响应?
我们可以简单的认为:
网页性能优化就是用最快的速度响应用户的操作,进而提升用户的体验
发现问题
首先,我们要知道网页慢在哪?我们借助以下工具来查探。
工具一:开发者工具之网络
我们需要重点关注上图中底部的三个数据
1、总请求数:越少越好
首屏请求数量最好控制在 80 个以内,最多不超过 110 个
2、资源的总体积:越小越好
首屏请求体积最好控制在 3.5MB 以内,最大不要超过 6MB
3、DOMContentloaded 时间:越小越好
DOM 解析渲染完成时间控制在 1.2 秒以内,最大不要超过 3.5 秒
工具二:开发者工具之性能
这张图对刚开始接触的人,会感觉乱,但现在只需关注红框中的内容:
1、第一个红框展示是当前网页执行时经历了一些长任务
W3C 把单个任务(比如执行一个函数)超过 50 毫秒的定义为长任务,红色条出现的越多,说明性能越差
2、接下来是 FP、FCP、LCP,这些表示某些元素渲染的时间节点
FP(First Paint) 第一次像素绘制的时间
FCP(First Contentful Paint) 第一次内容(文本,图片等)绘制的时间
LCP(Largest Contentful Paint) 最大内容块的绘制时间点
这三者,越靠左,即越早绘制,用户就能越早看见内容
工具三:开发者工具之 Lighthouse
上面两个工具,能够发现一些问题,但却没告诉我们衡量标准是什么,以及我们具体该优化哪些地方。
这个时候 Lighthouse 就登场了,它是 Google 推出的一款网页性能测试工具。
初次使用时,为了便于理解,要做一些简单的设置:
测试完成, 你将会看到测试总分和五项指标
再往下拉,你会看到一些优化建议
其实按照它给出的建议,一个个去优化,一般都能优化到一个不错的效果。
关键指标说明
- FCP(First Contentful Paint):第一次内容(文本、图片等) 绘制的时间
- LCP(First Contentful Paint): 第一次内容(文本,图片等) 绘制的时间
- TBT(Total Blocking Time):所有长任务阻塞的时间
- CLS(Cumulative Layout Shift):累积的布局偏移量
- SI(Speed Index):内容填充速度
Google Lighthouse
经过多个版本的总结,现在确定将这五个关键指标进行评估计分,接下来我就围绕这几个指标结合实际项目展开说明
解决问题
在解决指标性能问题之前,默认你已经对网页做了最基本的优化:
- 图片懒加载
- 原始资源压缩(HTML、CSS、JS、XHR资源、图片、音视频)
- 资源压缩传输(gzip、deflate、br)
- 缓存(CDN缓存、强缓存、协商缓存)
FCP(First Contentful Paint)
大致意思就是 第一个内容从发出请求到绘制出现在页面所耗费的时间,这里的内容一般指 文本
、图片
等。
如果你网页中第一个内容是图片,那它需要一定的时间请求到图片资源,然后才是绘制,那这个 FCP 势必会比普通文本的 FCP 时间长。
FCP 是包含了从发出页面请求到返回内容的时间,这就意味着下面的每一条都可能影响最终的结果:
需要排除的物理因素
- 测试设备 的性能
- 测试网络 的速度
- 服务器处理速度
- 服务器网络速度
作为前端的你,对于这个指标,能做的就是:
FCP 影响着白屏时间,应该控制在 600ms 以内
- 减小网页文档的体积
- 移除不必要的 cookie,让网络请求更快
- 优化第一内容的体积(如果是图片)
LCP(First Contentful Paint)
大致意思就是 最大内容从发出请求到绘制出现在页面所耗费的时间。
最大指的是体积还是面积?
为了验证这个猜想,我做了个实验:
<body>
<!-- 这张图片实际是300*300,但我让它显示成 100*100 -->
<img src="https://fakeimg.pl/300x300/fdd902/333/" width="100" height="100" />
<!-- 这张图片实际尺寸是100*100,但我让它显示成300*300 -->
<img src="https://fakeimg.pl/100x100/cb0000/fff/" width="300" height="300" />
</body>
试验结论
通过上述实验,我们得知这里的 最大 指的是渲染面积,而不是内容的体积。
当我得知这个结论后,我曾经为了提升一个商品详情页的 LCP 指标,我做了一个内容很简易的图片(为的是让图片体积很小,甚至能用 Base64 代码形式存在),用它来初始占位。
我想象的是这个占位图会被判定为 LCP,那指标自然就上去了。
但现实情况是,虽然我用了占位,但我最终要将真实有效的图片替换这个占位。不仅要替换,而且要快,毕竟用户体验优先。
上线以后,这个 LCP 数据不仅没有变好,反而变差了。
经过一番排查,猜测原因可能是:在短时间内,最大内容渲染时间是累积的。比如,我加载占位图需要 0.5s,那真实的图片,加载并渲染需要 1.5s,那这个 LCP,间隔短的情况下可能会变成 2s。
Contentful 指的是什么?
Contentful一般指 文本
、图片
等。
如果只是定义了一个没有内容,但有背景色的 div 标签,那它不算作是 LCP,因为对于用户来说它并不是可查阅的内容
LCP 成为关键指标的原因分析
对用户来说,当前视口内最大面积的内容的渲染完成,就意味着当前整个区域已经可以查看阅读,所以这个越早渲染,体验越好
LCP 应该怎么去优化(LCP 时间最好能控制在 1.6s 以内)
一旦确定了 LCP 元素,应该去优化这个元素本身:
- 从源头出发,在不影响质量的前提下压缩体积
- 使用恰当的尺寸,页面上需要多少尺寸,源图就应该是多少尺寸
- 使用 CDN 存储这个资源
- 使用 Webp,Avif 等压缩率更好的图片格式
TBT(Total Blocking Time)
TBT 是前端最需要也最能优化干预的一个指标,同时 Lighthouse 计分规则也将它作为最高比重,足足占了 30%
大致意思就是 阻塞的总时长,更确切的说法应该是:从第1个像素渲染开始,直接可以接收用户操作,所有长任务 超出部分 时间的总和。
TBT 的计算
长任务 是 W3C
给出的一个说法:如果一个任务(比如一段代码的执行又或者一处界面的渲染),它花费的时间大于 50ms,这就算一个长任务。
而 TBT 是将大于 50ms 的时长进行累加(举个例子):
- 整个 jquery.js 执行完需要 90ms, 这个超出 90 - 50 = 40ms
- 自己写的 render() 函数执行完需要 40ms,这个没有超出,不加入计算
- 有一个 for 循环花费了 120ms,这个超出 120 - 50 = 70ms
那把这些累加,这个网页的 TBT 时长就是 40 + 70 = 110ms
浏览器任务调度基本逻辑
在优化之前,我们先简单了解一下浏览器是如何处理任务的。
虽然浏览器应用能够开启多线程处理任务,但操作的事物最终将体现在单个界面上,为了让显示和操作井然有序,现代浏览器采用的是为一个标签页只开启一个单独的渲染主线程,由这一个主线程去处理:
- 解析HTML
- 解析CSS
- 计算样式
- 样式与布局合成
- 渲染
- 处理JS
- 处理定时器(这个比较特殊)
当主线程遇到了定时器,也就是 setTimeout 这样的计时函数,这个计时可能很长。
为了不阻塞正常的渲染,它会将这个任务交给单独的 计时线程
TBT 优化在代码中的体现
在了解浏览器任务执行逻辑后,我们似乎可以利用这个延时队列机制做一些任务处理的优先级调整。
一个内容很多的界面,尤其是像商品详情页这种,包含了主图概览区、推荐位、详情描述、评论、问答等。
其实对用户来说,刚进到商品详情页,他最迫切的是想看到商品的标题、价格、主图、购买按钮。
那么我们应该将这些主要的功能逻辑调用放在最前面,而其他没那么迫切的模块,可以通过异步操作将它们延后执行。
模块的拆分
为了方便功能调度和维护,我将商品详情页的模块都进行了拆分:
所有的模块,通过主入口 product.js 进行调度分配,我单独将首屏(即大部分用户首次进来看到的界面)所需处理的逻辑单独拎出,而其他不是那么迫切的任务,我用定时器将它们延后执行
首屏优先执行
首屏的处理也很简单,就是调用价格接口并初始化价格功能以及主图的功能
其他模块按需执行
至于其他的一些模块,诸如 评论、推荐位,很多新用户可能压根不会去查看,那我最初便不会去执行,只有当用户到达那片区域才会加载。
我们利用了图片懒加载的思想,用 IntersectionObserver
封装了一个通用的视口触达方法 lazyObserver
:
TBT的瓶颈可能出在第三方库
其实正常写业务代码,绘制页面,以现代浏览器的性能,都不太会产生太大的长任务。
很多情况下是出在庞大的第三方库,因为大部分情况,一整个第三方库可能就一个大任务去执行,这点我们通过 Lighthouse 建议面板可查探到:
站内公共脚本的 TBT 问题
自已站内抽离的一些 common.js,因为任务比较集中,也会出现这样的问题。
但这些至少我们还能去优化,比如排查一些循环,把长任务进行拆分,那么拆分长任务除了我上述用的 setTimeout 或 其他异步任务之外,还能用 Google 官方建议的用 async await
给主线程让步的方式:
function delay(duration) {
var start = Date.now();
while (Date.now() - start < duration) { }
}
function func1() {
delay(1000);
}
function func2() {
delay(2000);
}
// 创建一个即时完成的 promise
function forMain() {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
async function taskQueue(tasks) {
while (tasks.length > 0) {
const task = tasks.shift();
task();
await forMain();
}
}
除此之外,将任务拆分在不同的script 标签中,不论是行内还是外链形式,它们都将以独立的任务运行
<script>
function delay(duration) {
var start = Date.now();
while (Date.now() - start < duration) { }
}
function func1() {
delay(1000);
}
function func2() {
delay(1500);
}
// 任务1
func1();
</script>
<script>
// 任务2,这和任务1不会合并成一个任务
func2();
</script>
CLS(Cumulative Layout Shift)
大致意思就是布局元素整体偏移量的累积。 这么说还是有点抽象,再简单点理解就是比如有一个 div 元素,最初是显示在最顶部,过了一会,在 div 的上方,又插入了新内容,把 div 往下推了一段距离,造成页面的重排。
这个推的距离和推的面积,有一个计算方式,具体查看 计分规则
再说的直白些,就是要减少我们的页面元素的重排。
我在网页中的实践
由于我的商品详情页基本价格区,是由异步请求出来的结果,如果网络状态比较差的情况下,等待会比较久。
所以在一开始,用户看到的下面简介文本内容
当异步请求完成以后,价格+营销信息+购买按钮+物流信息+优惠券信息出现后,就会将简介整个版块往下推,那就造成了布局重排,这样CLS分数较,对用户的体验就比较差。 为了解决这个问题,我们想过让服务端直接输出,不采用异步的方式加载。但招到服务端的拒绝,理由是价格变化比较频繁,物流信息也跟每个用户相关,很难做到服务端直出。 改版服务输出方式不行的情况,我只好换成了现在的骨架屏方案,骨架屏可以占住将来要显示内容的区域,不会造成简介内容的重排:
过渡与动画对 CLS 的影响
我们在做一些对 margin,padding,top,left 改变的位移动画时,可能会造成 CLS 偏移 比较好的做法是采用 CSS3 的 transform,因为它不会造成重排,也不需要经过光栅化。
SI(Speed Index)
大致意思就是可见元素内容在当前可视区的一个填充时间,这个比较受网络加载的影响,如果网络保证的情况下,LCP 也优化好,这个参数一般是水到渠成。
要注意的一点是,我们在使用懒加载图片时,应该排除首屏。
也就是说首屏没必要做懒加载,而应该直出图片,这样才能提高 SI 的速度,这也符合用户的观感,最快地看到需要的内容。
这是我在首页大图的处理方式:
这是我在列表商品卡片时做的处理:
总结
性能优化主要就是在将内容最快地呈现给用户,无论你是通过缩小资源体积,还是保证服务网络带宽,又或者按需加载。
说来说去就一句话,给用户所需,让用户指哪打哪就是好的体验
不要为了参数去做优化,因为有些参数可以作假,但损失了用户体验
请,永远不要为了 KPI,或指标参数去做优化,如果你是一个用户体验优先的前端开发者。
你就只有一个目标:让内容最快地呈现给用户