优化图片解码占用主线程导致渲染阻塞问题

173 阅读6分钟

引言

图片的压缩优化是项目整体性能优化的重要一环,降低图片的体积能够更快的进行CDN分发,加快首屏速度。但在组件的处理中也需要注意图像的解码过程可能会占用主线程,从而阻塞渲染线程,导致卡顿。

场景复现

在一个播报栏组件中,我嵌套了一些懒加载组件,他们各自都有自己的动画逻辑。但是在部分场景下,懒加载触发后会导致动画卡顿,渲染线程被阻塞。

如图所示,当点击按钮后,随着右边的元素懒加载被触发,此时会产生明显的顿挫感

2025-02-14 12.37.26.gif

Chrome性能分析中,发现了该掉帧区域存在一个明显的耗时任务,来源于系统自身

image.png

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

result.png

加载耗时: 124
解码耗时: 383
总耗时: 507

第二张是一张全彩的插画图片(Pixiv id : 22237732),JPG,大小为8.8MB

加载耗时: 3002
解码耗时: 101
总耗时: 3103

第三张是一张全彩的可爱柴犬图片(Pixiv id : 111497607),JPG,大小为16.6kB

image.png
加载耗时: 953
解码耗时: 452
总耗时: 1405

第四张也是一张全彩的插画图片(Pixiv id : 33333),JPG,大小为2.1MB

image.png
加载耗时: 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
)

用这种逻辑优化后,可以发现流程性显著提升:

2025-02-14 12.40.18.gif

图像预加载解码缓存

我们希望能够预加载一些图像资源,仍然可以使用提前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