关于 LQIP 的一切


LQIP 是什么?

LQIP,即Low Quality Image Placeholders(低分辨率占位图),它通过加载一个极低分辨率的图片,来让图片看起来加载很快。

比如下面这张图片:

原图
原图
透明占位图
加载时,携带 LQIP
透明占位图
加载时,不携带 LQIP

实现 LQIP 的几种方式

渐进式 JPEG

JPEG 有两种保存方式,一种是Baseline(基线),另一种是Progressive(渐进式)。还是使用我们的示例图片来举例,这里通过 JS 来模拟加载图片的过程:

Baseline JPEG
Baseline JPEG
Progressive JPEG
Progressive JPEG

但渐进式加载的图片并不优雅,虽然它能够实现 LQIP 的效果,也被大多数浏览器所支持,但不管怎么样,低分辨率的图片没有嵌入到 HTML 中,加载起来必然需要额外的网络请求而带来延迟,无法和 HTML 一起同步加载。

渐进式图片还有很大的缺点就是,用户不会知道一张图什么时候加载完毕,尤其是对于网速较慢的用户来说,加载一张较大的渐进式图片的时候,很可能卡在中间某个较为模糊的阶段非常久,导致用户认为这图本来就是这么模糊。

此外,不是任何格式都可以使用渐进式加载也是一个致命的缺陷,目前仅有 JPEG 的渐进式加载被支持的较好,其他格式则一言难尽:

  • PNGGIF 虽支持渐进式加载,但这两个格式启用渐进式加载会使文件大小增加,以及它们的浏览器的支持度也不高…
  • 对于现代图片格式,WebPAVIF 压根不支持渐进式加载,WebP 更是直接在文档中说明了因为性能考虑,不会去支持渐进式解码。对于复杂度更高的 AVIF,更是可想而知性能压力会有多大。

    WebP 会支持渐进或隔行扫描加载吗?

    WebP 不像 JPEG 或 PNG 那样提供渐进或或隔行扫描加载。这会对解码端的 CPU 和内存造成过大压力,因为每一次刷新都需要完整地再走一遍解压流程。

    平均来说,解码一张渐进式 JPEG 的开销,大约相当于解码同一张基线 JPEG 三次。

  • 对于更加现代的图片格式 JPEG XL,虽然它支持渐进式加载,但是到我写下这篇文章的时候,脱离了苹果的 Safari 后,几乎没有任何浏览器支持它。

嵌入极低质量的图片

实现 LQIP 的另一种较好的方式是直接嵌入一张极低分辨率的图片:

<!-- 直接在 img 硬编码背景 -->
<img src="xxxxx.png" alt="LQIP 测试" style="background-image: url('data:image/webp;base64,xxxxx')">

由于是硬编码图片,可以直接被浏览器解码,故不会带来更多解码开销、网络请求。嵌入图片对 HTML 的体积影响也不会特别大,一般来说,一张图只需要额外的几十上百字节就足够。

要实现这一点,可以使用 lqip-modern 这个库来做到。当然,不使用 lqip-modern 也是可以的,毕竟这些库的底层全部都是缩放图片到极小的大小(16px、8px),可以自己想办法将图片缩小即可。

lqip-modern 的底层使用了 sharp 来处理图片,比如下面这段代码就是将一张图片简单的缩放到 32px 并继续压缩:

  task: async ({ path, dist }) => {
     // 使用 sharp 读取图片并缩放到 32px 宽,转换为 JPEG,再转成 Buffer
     const rawThumbnail = await sharp(path).resize(32).jpeg().toBuffer()
     // 生成一个临时文件
     const tmpPath = resolve(tmpdir(), `sqip-demo-tmp-lqip-${Date.now()}.jpg`)
     // 将生成的缩略图写入临时文件
     await writeFile(tmpPath, rawThumbnail)
     // 调用 mozjpeg 压缩临时图片,并输出到目标 dist 路径
     await execa(mozjpeg, ['-outfile', dist, tmpPath])
     // 删除临时文件
     await unlink(tmpPath)
     // 读取压缩后的缩略图内容
     const optimizedThumbnail = await readFile(dist)
     // 将缩略图转为字符串并返回
     return optimizedThumbnail.toString()
   }

从技术上讲,嵌入极低质量的图片可能是最好的选择之一,它不需要前端 JS 介入、图片的处理速度非常快、产物的大小也算被控制在了可接受范围。

纯 CSS 实现的 LQIP

原文在 Minimal CSS-only blurry image placeholders,我在这里仅简述实现细节和优缺点。

简单来说,由于 CSS 中,操作字符串的函数不能说多余牛毛也可以说是几乎没有了,所以,即使可以在后端将颜色信息编码到一段字符串中,前端的 CSS 也无法解码出来,要想解码字符串必须要有 JS 的参与。

但 CSS 可以处理数字,尤其是最近的 CSS 规范增加了 mod() round() 方法,使得在 CSS 中使用位运算成为了可能。

所以,作者使用了整数来编码颜色信息,其取值在 999,999-999,999 之间。也就是说,在这种编码下,CSS 可以显示 1,999,998 种不同的图像。

至于为什么只有 6 位有效数字,还得问问 chromium 那边为什么一旦 CSS 的计算超过了 6 位有效数字就会被强制转为浮点数来计算,从而导致精度丢失。
calc() 函数则更是重量级,一旦使用 calc() 函数计算整数,一律会被转为浮点数去计算。

既然是位运算,为了简化运算,需要将位数控制为整数位,最好的选择则是使用 20bit 来编码颜色:

将一张图片分割为 7 个区域,分别是 背景6前景,其中:

  • 背景 Lab: 使用 lab 色彩空间编码,L 通道仅使用 2 bit,a 和 b 通道各使用 3 bit,总共 8 位。
  • 前景 c[a-f]: 每个区域均使用 2bit 编码,仅记录亮度信息。

铛铛,类似下面这样:

ca
cb
cc
cd
ce
cf
LQIP 值: -432838
20bit 编码: 00000000000000000000 cacbcccdcecfllaaabbb

编码精度和精度丢失

你也许会注意到,上面两个 LQIP 例子的背景色有时候并不一致。这依然是因为精度丢失的原因。由于 b 通道被编码在最后 3 字节,而精度丢失往往也发生在后几字节上,进而导致 b 通道的颜色偏移,最终导致整个画面颜色错误。

由于我们仅使用 2bit 来编码 L 通道,即亮度信息,意味着亮度有且仅有 种可能。
对于 a 和 b 通道,我们有 3bit,即 种可能。

为了方便计算,将他们按照百分比归一化后,ab 通道的取值就会落在下面的区间内:

这也意味着要精确表示某些颜色变得非常困难,颜色总是会被近似到最接近的数字上,从而不可避免的产生色彩的偏移。

原图
原图
透明占位图
左边图片对应的 LQIP

编码前背景色:oklab(0.49 0.022 0.018)

解码后的背景色:oklab(0.4 0 0)

由于上述提到的编码步长问题,a 0.022b 0.018 直接被近似到 0,从而导致了色彩的丢失。

CSS 实现的 LQIP 在 Safari 上的性能问题

到目前为止,CSS 渐变混合依然不支持包含不透明图层的非线性插值,所以只能通过多个线性插值的叠加来模拟非线性插值:

.lqip {
  background-blend-mode: hard-light, hard-light, hard-light, hard-light, hard-light, hard-light, normal;
  background-image: radial-gradient(
    50% 75% at 16.67% 25%,
    var(--lqip-ca-clr),
    rgb(from var(--lqip-ca-clr) r g b / calc(100% - var(--lqip-stop10))) 10%,
    rgb(from var(--lqip-ca-clr) r g b / calc(100% - var(--lqip-stop20))) 20%,
    rgb(from var(--lqip-ca-clr) r g b / calc(100% - var(--lqip-stop30))) 30%,
    rgb(from var(--lqip-ca-clr) r g b / calc(100% - var(--lqip-stop40))) 40%,
    rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop40))) 60%,
    rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop30))) 70%,
    rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop20))) 80%,
    rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop10))) 90%,
    transparent
  ),
  radial-gradient(
    50% 75% at 50% 25%,
    var(--lqip-cb-clr),
    rgb(from var(--lqip-cb-clr) r g b / calc(100% - var(--lqip-stop10))) 10%,
    rgb(from var(--lqip-cb-clr) r g b / calc(100% - var(--lqip-stop20))) 20%,
    rgb(from var(--lqip-cb-clr) r g b / calc(100% - var(--lqip-stop30))) 30%,
    rgb(from var(--lqip-cb-clr) r g b / calc(100% - var(--lqip-stop40))) 40%,
    rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop40))) 60%,
    rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop30))) 70%,
    rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop20))) 80%,
    rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop10))) 90%,
    transparent
  ),
  radial-gradient(
    50% 75% at 83.33% 25%,
    var(--lqip-cc-clr),
    rgb(from var(--lqip-cc-clr) r g b / calc(100% - var(--lqip-stop10))) 10%,
    rgb(from var(--lqip-cc-clr) r g b / calc(100% - var(--lqip-stop20))) 20%,
    rgb(from var(--lqip-cc-clr) r g b / calc(100% - var(--lqip-stop30))) 30%,
    rgb(from var(--lqip-cc-clr) r g b / calc(100% - var(--lqip-stop40))) 40%,
    rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop40))) 60%,
    rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop30))) 70%,
    rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop20))) 80%,
    rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop10))) 90%,
    transparent
  ),
  radial-gradient(
    50% 75% at 16.67% 75%,
    var(--lqip-cd-clr),
    rgb(from var(--lqip-cd-clr) r g b / calc(100% - var(--lqip-stop10))) 10%,
    rgb(from var(--lqip-cd-clr) r g b / calc(100% - var(--lqip-stop20))) 20%,
    rgb(from var(--lqip-cd-clr) r g b / calc(100% - var(--lqip-stop30))) 30%,
    rgb(from var(--lqip-cd-clr) r g b / calc(100% - var(--lqip-stop40))) 40%,
    rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop40))) 60%,
    rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop30))) 70%,
    rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop20))) 80%,
    rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop10))) 90%,
    transparent
  ),
  radial-gradient(
    50% 75% at 50% 75%,
    var(--lqip-ce-clr),
    rgb(from var(--lqip-ce-clr) r g b / calc(100% - var(--lqip-stop10))) 10%,
    rgb(from var(--lqip-ce-clr) r g b / calc(100% - var(--lqip-stop20))) 20%,
    rgb(from var(--lqip-ce-clr) r g b / calc(100% - var(--lqip-stop30))) 30%,
    rgb(from var(--lqip-ce-clr) r g b / calc(100% - var(--lqip-stop40))) 40%,
    rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop40))) 60%,
    rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop30))) 70%,
    rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop20))) 80%,
    rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop10))) 90%,
    transparent
  ),
  radial-gradient(
    50% 75% at 83.33% 75%,
    var(--lqip-cf-clr),
    rgb(from var(--lqip-cf-clr) r g b / calc(100% - var(--lqip-stop10))) 10%,
    rgb(from var(--lqip-cf-clr) r g b / calc(100% - var(--lqip-stop20))) 20%,
    rgb(from var(--lqip-cf-clr) r g b / calc(100% - var(--lqip-stop30))) 30%,
    rgb(from var(--lqip-cf-clr) r g b / calc(100% - var(--lqip-stop40))) 40%,
    rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop40))) 60%,
    rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop30))) 70%,
    rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop20))) 80%,
    rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop10))) 90%,
    transparent
  ),
  linear-gradient(0deg, var(--lqip-base-clr), var(--lqip-base-clr));
}

这相当于每次都要绘制 7 个图层,再将它们一个个叠加,计算量虽然大,但在现在硬件和现代浏览器的加持下,每秒绘制几百张不是问题,至少在 MacBook Air M3ChromeFirefox 上,在一个拥有几百张 CSS LQIP 的页面上疯狂滚动没有一点卡顿。

但 Safari 是一个例外,Safari 在处理多图层叠加的时候异常缓慢,滚动起来更是直接白屏,这种卡顿甚至会影响 JS 主线程的执行速度(我很好奇它们的代码是怎么写的)。所以,对于 Safari 来说,这种实现方式并不理想…

结论

毋庸置疑的,纯 CSS 使它的实现非常优雅,可以在禁用 JS 的情况下依然可以显示 LQIP、嵌入 HTML 的数据量也非常小、不会阻塞画面渲染等等等。

但缺点也显而易见,Safari 上的性能问题、编解码的精度丢失(虽然可以增加 CSS 变量来绕过,或者以后使用字符串编码)、CSS 混合模式的限制(线性插值的丢失导致无法使用多个背景色)等。

综上,到目前为止,这个纯 CSS 实现的 LQIP 更接近一种炫技,而不是一种实用的 LQIP 解决方案。目前的 CSS 对于实现这种复杂需求来说还是太弱,亦或者是我太菜不会改。

使用 JS 实现的 LQIP

这里仅讨论使用 canvas 和 JS 所实现的 LQIP,不需要在前端使用 JS 解码的 LQIP 被归类为了 嵌入极低质量的图片

使用 JS 实现 LQIP 是最灵活的方式,实现起来也和上面使用纯 CSS 的 LQIP 大同小异,只不过更灵活,可以直接调用的库也更多。

比较著名的库是 blurhashthumbhash

它们都是由后端编码一段字符串,放到 HTML,然后在前端使用 JS 将解码后的图片在 canvas 中绘制。这里给出它们两个的对比:

原图
原图
编码字符串
blurhash
blurhash
WXF?e8~Ur[RM{WB%PW-RxaxabH-;soxtjuRkWBxue.NGNGWBs:
thumbhash
thumbhash
A1 C7 09 14 84 7F 57 87 7C 97 79 8B 79 79 7F 90 F7 9B 95
原图
原图
编码字符串
blurhash
blurhash
W%G[~SkDofoLWBaz%%bIoeazjsfkI]oLWBWXofj@IBayafj[bHfQ
thumbhash
thumbhash
E3 07 12 2D 86 89 78 87 7F 78 87 86 77 48 78 77 77 70 73 07 37

明显可以看到 thumbhash 要实现的更好。至于更多的例子,可以自行去它们的官方网站对比,支持自行上传图片:

给出一个小 JS 脚本,可以用来下载它们编码后的图片:

function downloadCanvas() {
  const canvas =
    document.querySelector(".codec-output canvas") ||
    document.getElementById("demo-canvas");

  const filename = canvas.matches(".codec-output canvas")
    ? "thumbhash.png"
    : "blurhash.png";

  canvas.toBlob((blob) => {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    a.click();
    URL.revokeObjectURL(a.href);
  }, "image/png");
}

downloadCanvas();

结论

就效果来说,使用 JS 实现的 LQIP 直接选用 thumbhash,效果很不错。但缺点也很明显——它需要使用 JS,当一个页面有大量携带 LQIP 的图片时,解码图片势必会阻塞 JS 主线程,但也不是没有解决办法,将这些工作全部塞到其他线程去执行。

一个参考:以本站为例,在有 250 张图片的 青甘大环线 文章内,使用 M3 MacBook Air 解码全部 LQIP 的时间是 ~100ms,平均解码一张图片的时间为 0.4ms。

当然,更进一步还可以使用 IntersectionObserver,仅让图片进入视口时进行解码。

此外,使用 JS 还会在前端引入一个 gzip 后 2.3KB JS 来解码,虽然这点体积不怎么重要就是。

质量大横评

综上,我认为比较好的实现 LQIP 技术只有这两种,它们之间各自互有优劣:

  • thumbhash。节省 HTML 体积,嵌入大小仅有约 30 字节;但在解码时会有延迟、需要引入大约 2KB 的 JS 脚本来解码。
  • 嵌入 HTML 的 data:image。与 thumbhash 完全相反,嵌入体积大,可能在 50-300 字节不等;解码由浏览器完成,几乎没有延迟。
  • CSS LQIP。最优雅的实现,但由于在 safari 上的性能问题和精度丢失问题,目前还不实用。

至于它们之间的质量差距,查看以下对比即可:

注:TrashWebP 是我使用 lqip-modern 所生成,它底层使用 sharp 库来处理图片,所有格式和参数均为 lqip-modern 默认。

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

原图
原图
16px WebP
16px WebP
ThumbHash
ThumbHash
CSS LQIP

所以,本站最后选用的 LQIP 为 thumbhash

真的有必要实现 LQIP 吗?

在实现了 LQIP 以后回过头来思考这个问题,个人认为,如果你的图片不会在加载后挤占其他元素的空间,则实现 LQIP 是完全没有必要的

以我的理解,LQIP 的意义在于:

  • 提升用户体验。提前显示一个占位图片,用户会觉得图片加载速度更快。
  • 在图片加载的前后实现优雅的过渡。
  • 避免布局抖动。在元素加载出来的一瞬间,其他元素被挤压脱离原先的位置,一般来说问题不大,但如果用户刚好在互动的话…
    CLS 偏移

而实现 LQIP 的代价则是:

  • 增加 HTML 体积。根据技术的不同,可能会增加几字节到上百字节不等,在现代网速下,这个代价并不大。
  • 性能问题。不管是前端还是后端,都会带来额外的处理开销,
    • 对于前端:即使是使用了纯 CSS 实现的 LQIP,依然会对浏览器产生不小的压力,JS 也一样,性能问题总是绕不过的坎。
    • 对于后端:计算 LQIP 值/生成缩略图都需要在后端额外处理。对于静态站点来说还好,可以在生成站点的时候一并处理,但对于动态站点,计算 LQIP 的时间可能都大于了图片加载的时间。

任何技术都是有优点和缺点的,LQIP 的价值是用低成本实现视图过渡和保持布局稳定,但代价则是客户端的 CPU 占用或兼容性问题。

在新实现一个技术前,不妨从这些方面评估:

  • 用户的网络环境如何?
  • 用户的设备性能如何?
  • 用户的浏览器够新吗?

技术的选型本质是权衡利弊,如果一个站点已经使用了 Skeleton Screens 等技术来避免图片加载带来的布局抖动,那么实现 LQIP 的意义就不大了。



评论加载中……