引言
图片的压缩优化是项目整体性能优化的重要一环,降低图片的体积能够更快的进行CDN分发,加快首屏速度。但在组件的处理中也需要注意图像的解码过程可能会占用主线程,从而阻塞渲染线程,导致卡顿。
场景复现
在一个播报栏组件中,我嵌套了一些懒加载组件,他们各自都有自己的动画逻辑。但是在部分场景下,懒加载触发后会导致动画卡顿,渲染线程被阻塞。
如图所示,当点击按钮后,随着右边的元素懒加载被触发,此时会产生明显的顿挫感:
Chrome性能分析中,发现了该掉帧区域存在一个明显的耗时任务,来源于系统自身:
image.decode()虽然是个异步操作,但是内存分配、解码器初始化、资源管理系统调用等操作发生在主线程上,可能会被记录为"系统(自身)"时间。
在逻辑上,该任务触发点就是懒加载被触发从缓存中加载图片的时候,因此推断该任务为图片的解码导致了渲染的阻塞。
为了验证,尝试停用Chrome缓存,因为图片比较大,所以在动画播放的过程中,图片没有被下载完毕,所以不会出现该现象。当图片从本地缓存加载的时候,图片会进行解码,从而阻塞渲染。
场景解析
为什么需要图像解码
图像文件是压缩的二进制数据,而显示器需要位图数据(像素数组),解码就是将压缩数据转换为位图的过程。渲染线程想要进行图像的绘制,必须等待解码完成才能进行后续的光栅化和绘制。
// 图像在文件中的存储格式
const imageFormats = {
JPEG: {
特点: '有损压缩',
优势: '文件小,适合照片',
存储: '压缩后的二进制数据'
},
PNG: {
特点: '无损压缩',
优势: '支持透明度,适合UI元素',
存储: '压缩后的二进制数据'
},
WebP: {
特点: '既支持有损也支持无损',
优势: '更高压缩比',
存储: '压缩后的二进制数据'
}
}
帧的时间预算
-
帧时间是指浏览器渲染一帧画面所需的时间
-
由显示器的刷新率决定
-
最常见的是60Hz刷新率,对应16.67ms的帧时间
// 常见显示器刷新率对应的帧时间
const frameTime = {
'60Hz': 1000 / 60, // ≈ 16.67ms
'90Hz': 1000 / 90, // ≈ 11.11ms
'120Hz': 1000 / 120 // ≈ 8.33ms
}
// 一帧(16.67ms)的时间分配
const frameTimeline = {
jsWork: '~5ms', // JS计算、动画等
styleCalc: '~4ms', // 样式计算
layout: '~4ms', // 布局计算
paint: '~2ms', // 绘制
composite: '~1.67ms' // 合成
}
也就是说,如果主线程长时间的被占用,那么动画相关的逻辑得不到处理,自然会掉帧。
为什么渲染线程要等待图片的解码?
在懒加载组件的逻辑中,触发后直接替换了该img的DOM元素src,这会触发资源请求(异步)。在网络请求完成并拿到资源后,浏览器开始进行图片解码。虽然主要的解码运算是在独立线程中异步进行的,但解码过程中仍然存在必须在主线程执行的同步任务,包括解码器的初始化、图像数据的格式转换、内存分配和管理等。当这些同步任务占用主线程的时间超过了一帧的时间预算(通常是16.67ms),动画相关的逻辑就无法及时执行,导致渲染线程无法获得新的绘制数据,最终造成画面丢帧。
图片解码的性能差距
测试代码
使用以下代码,测量他们的 加载耗时 、解码耗时 、 总耗时。
const decode = src => {
return new Promise((resolve, reject) => {
const image = new Image()
const startTime = Date.now()
let loadTime = 0
image.onload = () => {
loadTime = Date.now() - startTime
image
.decode()
.then(() => {
const decodeTime = Date.now() - startTime - loadTime
resolve({
loadTime,
decodeTime,
totalTime: loadTime + decodeTime,
})
})
.catch(error => reject(error))
}
image.onerror = error => reject(error)
image.src = src
}).then(times => {
console.log('加载耗时:', times.loadTime)
console.log('解码耗时:', times.decodeTime)
console.log('总耗时:', times.totalTime)
})
}
测试图片
关闭浏览器缓存,取首次加载耗时,防止缓存影响:
第一张是百度的图片,PNG,大小为
6.6kB:
加载耗时: 124
解码耗时: 383
总耗时: 507
第二张是一张全彩的插画图片(Pixiv id : 22237732),JPG,大小为
8.8MB:
加载耗时: 3002
解码耗时: 101
总耗时: 3103
第三张是一张全彩的可爱柴犬图片(Pixiv id : 111497607),JPG,大小为
16.6kB:
加载耗时: 953
解码耗时: 452
总耗时: 1405
第四张也是一张全彩的插画图片(Pixiv id : 33333),JPG,大小为
2.1MB:
加载耗时: 2867
解码耗时: 31
总耗时: 2898
可见,解码耗时和多种因素有关,比如图片编码格式、图片的像素尺寸、硬件加速等多重因素的影响。
解决方案
提前进行图像的解码
在上述的懒加载组件中,是直接对img的dom修改src。直接设置 src 时,浏览器可能会在主线程中同步解码图片,特别是对于较大的图片,这可能会导致明显的卡顿。
为了规避这个问题,在懒加载逻辑中,可以使用 decode() 方法方式预先解码,解码完成后,再给img的dom修改src。这样不会导致长时间占用主线程的现象,可以确保解码过程在后台完成,不会影响用户体验。
// 使用临时图片对象预加载
const tempImage = new Image()
// 设置 src
tempImage.src = src
tempImage.decode().then(
//修改真实DOM的src
img.src = src
)
用这种逻辑优化后,可以发现流程性显著提升:
图像预加载解码缓存
我们希望能够预加载一些图像资源,仍然可以使用提前decode()的逻辑,浏览器会缓存解码后的图像数据以供使用,尽可能的通过异步的方式实现,减少占用主线程的时间。
🌰 例子
import { startTransition, useEffect } from 'react'
const usePreloadImage = (preload: string, isLoading: boolean) => {
useEffect(() => {
const preloadImg = new Image()
const preloadImage = async (preload: string) => {
preloadImg.src = preload
try {
const start = Date.now()
await preloadImg.decode()
const decodeTime = Date.now() - start
console.log('图片预解码耗时:', decodeTime)
} catch (error) {
console.warn('图片预解码失败:', error)
}
}
if (preload && isLoading) {
startTransition(() => {
preloadImage(preload)
})
}
return () => {
preloadImg.src = '' // 清除加载
}
}, [preload, isLoading])
}
export default usePreloadImage
有效性分析:
禁用浏览器缓存,观察耗时
图片解码耗时: 2107
图片预解码耗时: 3316
图片解码耗时: 2443
图片预解码耗时: 3939
完全缓存,观察耗时
图片解码耗时: 15
图片预解码耗时: 97
图片解码耗时: 10
图片预解码耗时: 91