tc9011

【译】纯 CSS 图片占位符:用数学和颜色挑战诸神

20 min

原文:Image placeholders in pure CSS — or: Defying gods with math and color 作者:Freek 发布日期:2025 年 8 月 18 日

看好了

……只用一个十六进制值,CSS 就能做出这种狠活:

RGBA 十六进制值 #413E0E07
RGBA 十六进制值 #413E0E07

……然后生成这个:

三色网格渐变
三色网格渐变

看起来也许没多惊艳,但这是一个由三种颜色组成的「网格渐变」图,可以拿来充当 LQIP(Low Quality Image Placeholder,低质量图片占位符)。

前段时间,Lean Rada 用一个绝妙的技巧震住了 Web 开发圈:只用 CSS 实现 LQIP。他们只用一个介于 -999,999 和 999,999 之间的值,就能完全通过 CSS 生成原图的模糊低分辨率表示,并把它用作占位图。我强烈建议你去读读那篇文章,给大脑多拧出几道褶子,因为这个点子实在太聪明了。

受此启发,我也开始琢磨,还有没有别的办法能用纯 CSS 实现 LQIP。不是想做得更好(我哪有那个本事),而是想换一种方式调用 CSS 的能力,看看它还能做到什么。

在进入技术细节前,我们得先聊聊 LQIP 是什么,以及用 CSS 实现 LQIP 有哪些好处。如果你对此早就很熟,或者压根不关心,可以直接跳到 🧪 代码与原理

别忘了来这里看看实际效果,还可以用你自己的图片试试。

LQIP 到底是个什么鬼?

首先,说话注意点。其次,你肯定见过它!打开一个网站或 App 时,在图片完全下载之前,原位置会先出现一幅抽象的替代图。这就是 LQIP,也就是低质量图片占位符。它有很多种形式:可能只是一种颜色(通常取主色),也可能是两种颜色组成的简单渐变,或者原图极其模糊的低分辨率版本。

一张海豹照片和三种 LQIP,从左到右分别是主色、双色线性渐变和模糊的低分辨率位图
一张海豹照片和三种 LQIP,从左到右分别是主色、双色线性渐变和模糊的低分辨率位图

照片由 Lachlan 拍摄,来自 Unsplash

这是个花哨的小功能,能稍微改善一点用户体验:页面刚打开时不会显得空空荡荡;用户也许还能提前辨认出图片内容;但最重要的是,它真的很精致,也能让人看出你这个开发者在乎细节。😎

话虽如此,LQIP 并没有我们想象中那么普遍。

实现 LQIP 需要后端提前做些准备。先分析图片,再把 LQIP 编码成某种「表示」,然后把这种「表示」保存起来,以便之后使用,例如存进数据库。最后,前端还得拿到这些「表示」(最好就在最早返回的那批数据里),并在下载、显示原图之前生成占位图。

第二个难题是,LQIP 终究是要额外发送给前端的数据。开发者得避免收益递减:传输这些额外数据占掉的带宽和时间,本来可能已经足够多下载一点原图了。

真正的较量就在这里:找到一种性能不错、所需数据又尽可能少的 LQIP 实现。因为嫌这些步骤麻烦,有些开发者干脆改用渐进式 JPEG。它不能完全替代 LQIP,但确实能更早把图片呈现给用户,从而改善体验。

各种 LQIP 实现

最常见也最朴素的做法,可能是先做一张原图的低分辨率版本,让前端在下载原图之前(或同时)下载它。

听上去很简单。但缺点也很明显:前端要向后端发起更多请求,下载更多文件,来回传输的数据也更多。

另一种办法是把低分辨率图片编码成 base64 字符串,直接内嵌进 HTML 或 CSS。在 npmjs.com 搜索 「lqip」,可以找到一大堆专门给 Vite 和 Webpack 做这件事的包。

<img src="big.jpg" style="background:url('data:image/jpeg;base64,..')">

它的缺点当然是,base64 会让文件增大 33%。所以……嗯,确实少了请求,但载荷更大。¯_(ツ)_/¯

Blurhash 试图解决这个问题。它不再内嵌一整张该死的图片,而是把一个非常非常非常低分辨率的版本编码成 base83 字符串,再把字符串作为属性放进 HTML 元素。JavaScript 读取并解码这些 base83 字符串,然后生成一张 m×n 的网格渐变图。

<img src="img.jpg" data-lqip="B5JaQT0000{b~W0e">

至于它的缺点……我留到下一节再说。

最后还有一个值得一提的方案:SQIP。它不使用微型位图,而是把 SVG 当作 LQIP。SVG 是一种用途广泛的格式。由于它基于矢量,图形可以响应式缩放,在任何分辨率下都保持清晰。它的滤镜功能甚至还能加上一些相当炫酷的实时效果。

……不过内嵌的 SVG 仍然有几百字节。少是少了,但和 base64 编码的 JPEG、WebP 相比,还是一个数量级。

这就引出了……

纯 CSS 实现的好处

回到 Blurhash,它需要在前端运行一些 JavaScript。我们不妨在脑子里过一遍这段 JavaScript 要做的事。

首先,查询 DOM 中所有相关元素。

const images = document.querySelectorAll('img[data-lqip]')

为了让它正常工作,基本还得等 DOM 解析完。

addEventListener('DOMContentLoaded', () => {
  parseLQIPs()
})

接下来逐个遍历元素,读取 hash、解码,最后再想办法渲染出 LQIP。Blurhash 的做法是在 <canvas>一个像素一个像素地画出渐变。

这种做法符合直觉,也让你完全掌控整个过程。没错,但它也很。你知道什么更快、更直接吗?CSS!原因如下。

CSS 会阻塞渲染

也就是说,在样式表完全加载之前,浏览器不会渲染页面。这反而是件好事。浏览器这样做,是为了避免可怕的无样式内容闪烁。最妙的是,CSS 方案从页面刚刚可见的那一刻起就能工作,甚至不用等整个 HTML 解析完。

CSS 把渲染交给浏览器

做前端的应该都知道:没什么比浏览器自己更快。像 CSS 的 linear-gradient(),速度会胜过用 JavaScript 往 canvas 上绘制渐变。更好的是,只要元素(或屏幕)尺寸发生变化,浏览器还会自动重绘渐变。无论屏幕分辨率和元素尺寸怎么变,我们的渐变都会保持漂亮、清晰。

CSS 更容易接入,代码也更少

假设你的网站或 Web App 会动态加载图片。应用 LQIP 真的只要一行代码:

image.style.setProperty('--lqip', lqipValue)

使用 React 之类的框架也是一样。没必要再加额外组件处理额外逻辑,只管把 CSS 变量放进去,齐活!

const style = {
  '--lqip': lqipValue,
}

return <img src={src} style={style} />

就这么简单。

说到这里,你应该已经更清楚 LQIP 是怎么回事,也明白纯 CSS 方案有什么好处了。希望你和我一样兴奋,因为现在该上科 学 了!

🧪 代码与原理

我的目标是做一个尽可能精简的 LQIP 实现,至少支持两种颜色;同时,所有数据都得存进一个 CSS 变量。

Lean 的 LQIP 与我最初设想的双色线性渐变对比
Lean 的 LQIP 与我最初设想的双色线性渐变对比

第二格是 Lean 用单一色相生成的 LQIP,第三格是我最初设想的双色 LQIP。照片由 Andreas Gücklhorn 拍摄,来自 Unsplash

首先想到的当然是 <integer>。虽然文档里没写,但 CSS 整数的安全范围是 -1,000,000 到 1,000,000,差不多相当于一个 20 位的值。用来存些数据已经很宽裕。另一个好处是,值越接近 0,写出来需要的字节就越少。

--lqip:-1000000; /* 16 bytes */
--lqip:0; /* 9 bytes */

不过我们还能做得更好。CSS 里有一种值类型,范围比这更大,而且从 CSS 变量诞生时起就已经存在:<color>。更确切地说,是它的 RGBA 十六进制形式:#RRGGBBAA

RGBA 由四个 8 位值组成,合起来就是一个足足 32 位的值,里面能塞不少数据。我起初只想装两个颜色……后来贪心占了上风,我决定塞进三个。前两个颜色各占 11 位,最后一个占 10 位。

三个颜色被打包进单个 32 位值的位布局
三个颜色被打包进单个 32 位值的位布局

肯定会限制颜色精度,而且影响很大。位数少了,可用范围也会变小。不过这笔取舍值得。反正 LQIP 最后就是一团模糊的东西,精确的色彩并没有那么重要。何况它只会暂时出现,等原图加载完就消失了。谁会注意到呢?

24 位、11 位和 10 位颜色范围对比
24 位、11 位和 10 位颜色范围对比

颜色精度对比。(注意:图中没有展示完整范围。)

开始动手

首先,需要找出要塞进单个 RGBA 的几个颜色。实现办法很多,包括相当复杂的色彩量化。不过我决定简单点:把图片缩到小得可怜的 3×3,再从里面取色。

把一碗西红柿的照片缩成 3×3 像素块
把一碗西红柿的照片缩成 3×3 像素块

照片由 Deniz Altindas 拍摄,来自 Unsplash

我不想让 LQIP 只是一条无聊的线性渐变,希望里面能有点形状和层次。所以,这个 LQIP 会以一种纯色为底,再沿对角线叠上两层径向渐变。三个颜色分别取自 3×3 版本的左上角、中心和右下角像素。

构成最终 LQIP 的底色和渐变图层
构成最终 LQIP 的底色和渐变图层

打包

好,该把这些该 ████ 的 ███ █████ █████ 颜色狠狠干碎了。

如果你熟悉位运算,把颜色打包成 11 位和 10 位整数并不难。

type RGB = { r: number, g: number, b: number }

function packColor11bit(c: RGB): number {
  const r = Math.round((c.r / 0xFF) * 0b1111)
  const g = Math.round((c.g / 0xFF) * 0b1111)
  const b = Math.round((c.b / 0xFF) * 0b111)
  return (r << 7) | (g << 3) | b
}

function packColor10bit(c: RGB): number {
  const r = Math.round((c.r / 0xFF) * 0b111)
  const g = Math.round((c.g / 0xFF) * 0b1111)
  const b = Math.round((c.b / 0xFF) * 0b111)
  return (r << 7) | (g << 3) | b
}

颜色打包好后,把它们合并成一个整数,再生成十六进制值。

const pc0 = packColor11bit(c0)
const pc1 = packColor11bit(c1)
const pc2 = packColor10bit(c2)
const combined = (BigInt(pc0) << 21n) | (BigInt(pc1) << 10n) | BigInt(pc2)
const hex = `#${combined.toString(16).padStart(8, '0')}`

把生成的十六进制值设为自定义 CSS 变量,放到所有需要 LQIP 的元素上。通常只会放在 <img> 上,不过你愿意的话,放到别的元素上也没什么不行。

<img src="image.jpg" style="--lqip:#abcdef01">

解包

好玩的部分来了:在 CSS 中,从一个 RGBA 十六进制值里拆出三个颜色。

CSS 没有位运算符,但它 round()mod() 这样的数学函数。运气不错,这已经足够「模拟」位运算,把各个位从 RGBA 十六进制值里抠出来。

可怎样才能读取某个颜色通道的位呢?CSS 又没有函数能直接从颜色里拿到红色分量。

真的没有吗……?

其实有!而且(只能)通过相对颜色语法做到。

--new-color: color(from var(--old-color) srgb r g b / alpha);
/* 也可以写成 */
--new-color: rgb(from var(--old-color) r g b / alpha);

相对颜色语法允许我们读取颜色的各个通道,还能对通道做计算。想怎么玩都行,甚至可以把通道互换!这正合适,因为我们要做的就是读取一个颜色,再创建新颜色。

第一个颜色的红色分量,位于 RGBA 十六进制值的红色通道的前 4 位。因此,需要把值向右移 4 位,再除以 15,也就是 4 位数能表示的最大值(2⁴−1)。计算式如下:

calc(round(down, r * 255 / pow(2,4)) / 15)

绿色分量需要取 RGBA 十六进制红色通道的后 4 位。这里用 mod() 模拟按位与:

calc(mod(round(down, r * 255), pow(2,4)) / 15)

基本数学说清楚了,下面是从单个 RGBA 十六进制值中拆出三个颜色的完整 CSS:

--lqip-c0: color(
  from var(--lqip)
  srgb
  calc(round(down, r * 255 / pow(2,4)) / 15)
  calc(mod(round(down, r * 255), pow(2,4)) / 15)
  calc(round(down, g * 255 / pow(2,5)) / 7)
  / 100%
);

--lqip-c1: color(
  from var(--lqip)
  srgb
  calc(mod(round(down, g * 255 / 2), pow(2,4)) / 15)
  calc(((mod(round(down, g * 255), 2) * pow(2,3)) + (round(down, b * 255 / pow(2,5)))) / 15)
  calc(mod(round(down, b * 255 / pow(2,2)), pow(2,3)) / 7)
  / 100%
);

--lqip-c2: color(
  from var(--lqip)
  srgb
  calc((((mod(round(down, b * 255), pow(2,2)) * 2)) + round(down, alpha * 255 / pow(2,7))) / 7)
  calc(mod(round(down, alpha * 255 / pow(2,3)), pow(2,4)) / 15)
  calc(mod(round(down, alpha * 255), pow(2,3)) / 7)
  / 100%
);

嚯。😑 处理完这些,我们终于可以构建 LQIP 了。

Lean 发现,径向渐变采用缓动插值会比线性插值好看。(这里是我的转述。)所以我们也这样做:每隔 10% 设置一个色标,模拟缓动。最后一块拼图,也就是 background 属性,长这样:

background:
  radial-gradient(
    150% 75% at 80% 100%,
    var(--lqip-c2),
    rgb(from var(--lqip-c2) r g b / 98%) 10%,
    rgb(from var(--lqip-c2) r g b / 92%) 20%,
    rgb(from var(--lqip-c2) r g b / 82%) 30%,
    rgb(from var(--lqip-c2) r g b / 68%) 40%,
    rgb(from var(--lqip-c2) r g b / 32%) 60%,
    rgb(from var(--lqip-c2) r g b / 18%) 70%,
    rgb(from var(--lqip-c2) r g b / 8%) 80%,
    rgb(from var(--lqip-c2) r g b / 2%) 90%,
    transparent
  ),
  radial-gradient(
    100% 75% at 40% 50%,
    var(--lqip-c1),
    rgb(from var(--lqip-c1) r g b / 98%) 10%,
    rgb(from var(--lqip-c1) r g b / 92%) 20%,
    rgb(from var(--lqip-c1) r g b / 82%) 30%,
    rgb(from var(--lqip-c1) r g b / 68%) 40%,
    rgb(from var(--lqip-c1) r g b / 32%) 60%,
    rgb(from var(--lqip-c1) r g b / 18%) 70%,
    rgb(from var(--lqip-c1) r g b / 8%) 80%,
    rgb(from var(--lqip-c1) r g b / 2%) 90%,
    transparent
  ),
  var(--lqip-c0);

🏁 完成!

就这样。只用很少的代码和非常简单的数学,我们得到了:

  • 一种极其精简的 LQIP 表示,只需向前端发送几个字节。前端实现也小得可怜,能省下大量带宽。
  • 一个性能良好、可以响应式缩放的 LQIP,因为重活都交给了浏览器。
  • 一种不只适用于网站和 Web App 的实现(示例见附录 B)。
  • 挑战诸神,掌握了一股全新的、不可思议的 CSS 力量。

别忘了,你可以在这里看看 LQIP 的实际效果,也可以拿自己的图片试试

接下来呢?

和生活中的大多数东西一样,它并不完美,仍有改进空间。

比如,因为精度不足,接近灰色的颜色可能会变得更饱和、更鲜艳。可以在把 3×3 图片里取出的颜色打包进 RGBA 之前,更仔细地分析它们。(或者干脆换一种取色方法。)

黑白建筑照片的 LQIP 因为 RGB 通道在压缩时采用不同的舍入方式,出现了棕色和绿色
黑白建筑照片的 LQIP 因为 RGB 通道在压缩时采用不同的舍入方式,出现了棕色和绿色

一张黑白图片生成的 LQIP 出现了棕色和绿色,因为 RGB 通道在压缩到低位数时采用了不同的舍入方式。照片由 Nikita Pishchugin 拍摄,来自 Unsplash

另一个想法是增加第二个 CSS 变量,再塞三个颜色,做出六色网格渐变:

<img src="image.jpg" style="--lqip0:#49bc6c88; --lqip1:#8ace6d80">
六色网格渐变演示
六色网格渐变演示

照片由 Jose G. Ortega Castro 拍摄,来自 Unsplash

非常感谢你读到这里。希望这篇文章能像 Lean 的文章启发我那样启发你。我觉得 CSS 还有一大片潜力未被发掘。每当有人想出让人目瞪口呆、聪明得离谱、堪称爱因斯坦级别的技巧时,我总是特别兴奋。请把它们分享出来!

再次感谢!🤗

附录 A:作为属性嵌入

在(不远的?)将来,我们应该可以使用 attr(),把十六进制值作为单独的属性放进元素,再在 CSS 中把它作为颜色读取。这样一来,后端设置十六进制值会更方便,不用再别扭地把代码硬塞进 style=""

<img src="image.jpg" data-lqip="#abcdef01">
[data-lqip] {
  --lqip: attr(data-lqip type(<color>), white);
  /* ... */
}

附录 B:原生应用实现

我自己也是 Swift 开发者,所以值得展示一下,在 SwiftUI App 中实现这套 LQIP 有多简单。这也能证明,这项技术并非只能用于 Web(CSS)。

SwiftUI 中的 LQIP 预览
SwiftUI 中的 LQIP 预览

照片由 Vicky Hladynets 拍摄,来自 Unsplash

Swift 代码可以在这个 GitHub Gist 中找到。

  • 本文作者: tc9011
  • 本文链接: https://tc9011.com/posts/2026/译纯-css-图片占位符用数学和颜色挑战诸神/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!