前端周刊8-仅用CSS实现极简的模糊图片占位符
2025-04-16
概览
通过一个css技术,可以生成模糊/低质量图片占位符(LQIP),不会引入过多的元素,只需要LQIPs这个自定义属性。代码如下:
<img src="…" style="--lqip:756781">
通过执行这段代码,会得到如下的效果:
和其他主流方案相比,它不仅可以实现模糊的效果,而且足够简单、无侵入性,无需包裹元素、无需冗长数据属性,甚至不需要使用JavaScript。
我们可以看到更多的效果:
没使用模糊的照片:
使用了模糊的照片:
LQIP方法调查
LQIP,即低质量图像占位符(low quality image placeholders),实现LQIP的技术很多,比如有:
极低分辨率的WebP或JPEG(甚至是去头JPEG:https://engineering.fb.com/2015/08/06/android/the-technology-behind-preview-photos/The technology behind preview photos)
优化的SVG形状布局(SQIPhttps://github.com/axe312ger/sqip)
直接应用离散余弦变换(BlurHashhttps://blurha.sh/)
渐进式JPEG
隔行扫描PNG
Canva和Pinterest使用纯色占位符
还有一种更极端的低技术含量的解决方案,比如简单地用图像的平均颜色进行纯色填充。
而纯内联CSS方案的优点在于能够立即渲染,甚至background-image: url(…数据URL)这种写法也能完美生效。
Gradify生成的线性渐变,这些渐变大致上非常粗略的模拟了图像。
但是纯CSS方法的缺点是,通常标记中混杂着很长的内联样式,或者看起来不舒服的数据URL。
<!-- 典型的 gradify css -->
<img width="200" height="150" style="
background: linear-gradient(45deg, #f4a261, transparent),
linear-gradient(-45deg, #e76f51, transparent),
linear-gradient(90deg, #8ab17d, transparent),
linear-gradient(0deg, #d62828, #023047);
">
BlurHash是一种通过压缩图像数据压缩为简短的Base-83字符串,来最小化标记的解决方案,但是解码并渲染这些数据需要额外的JS支持。
<!-- blurhash标记-->
<img width="200" height="150" src="…"
data-blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj">
BlurHash示例
纯CSS解码
和BlurHash不同的是,我们无法使用字符串编码,因为CSS(2015)中几乎没有对字符串处理的函数。所以字符串方案不适用。
但是最终,我们基于自己的哈希/编码方案,我们选择的最佳载体就是:整数类型。
在单个整数中,编码数据的常规方法是通过位压缩(https://en.wikipedia.org/wiki/Bit_manipulation)——即将多个数值以比特形式打包进一个整数。更妙的是,我们可以用CSS将自带的计算功能将这些数据给解码出来。
解码比特只需要位移和位掩码操作。位移可以通过触发和向下取整实现——使用cal(x/y)和round(down, n),位掩码可以通过模运算mod(a, b)来完成。
举个例子:
* {
/* 压缩整数的例子: */
/* 0b11_00_001_101 */
--packed-int: 781;
--bits-9-10: mod(round(down, calc(var(--packed-int) / 256)), 4); /* 3 */
--bits-7-8: mod(round(down, calc(var(--packed-int) / 64)), 4); /* 0 */
--bits-4-6: mod(round(down, calc(var(--packed-int) / 8)), 8); /* 1 */
--bits-0-3: mod(var(--packed-int), 8); /* 5 */
}
这样我们通过单个CSS整数值就实现纯CSS版BlobHash的编码了。但是,要给CSS整数值能将多少信息包含进去呢?
支线任务:CSS值的限制
CSS规范中并没有规定整数范围,所以具体能用多大的数字取决于每个浏览器的实现。
通过实验发现,自定义属性中如果使用超过-999,999-999,999范围内的整数就会对视精度,出国这个范围,数值就会被四舍五入到十位数——比如1,234,567就会编程1,234,560。(这个现象太不正常了,进度居然按照十进制位计算),这估计是老IE时代遗留下的问题。
总是在[-999999, 999999]范围内共有1,999,999个可以用的数值。这意味着仅需一个整数哈希值,就能描述两百万种LQIP配置方案。 为了简化计算,我将其简化到最接近二次幂下限值220
2²⁰ = 1,048,576 < 1,999,999 < 2,097,152 = 2²¹
总之,我有20比特的信息量来编码基于CSS的LQIP哈希值。
为何叫哈希值,因为图片是有无限尺寸的,但是哈希值仅有1,999,999种可能。是将任意规模数据映射为固定长度值的过程。
实施计划
由于只有20比特位表示LQIP图,LQIP图像肯定是完整图像的极简版本。所以我最终设计了一个方案:1个基准色+6个亮度分量,以3x2网格的形式叠加在基准色上。
我是怎么将这9个数值(底色的三个参数LAB,6个亮度分量)用20比特位表示的呢?
基准色采用Oklab色彩空间编码在低8位——亮度占2比特,a/b(红绿/蓝黄)坐标各占3比特。实践表明,Oklab颜色会比RGB更均衡一点。
6个小块的灰度明暗占12位,每块用2位存(所以只能有4档亮度变化)。
我还写了一个脚本用来将图片转换成这种格式,大体的思路如下:
先扣出主色调
再把图压缩到3x2像素,提取6个点的灰度值
为了在20位整数的严格限制下,尽可能提升LQIP(低质量图像占位符)的视觉还原度。我还尝试过用遗传算法来优化,但是适应度函数难以建立。如果要精确实现LQIP的视觉还原度,需要离线CSS渲染器。在未来的迭代版本中可以用无头Chrome方案,自动对比LQIP渲染结果和原图的实际差异。
编码完成后,通过设置目标元素的style属性设置--lqip的值,随后可以在CSS中进行解码。
[style*="--lqip:"] {
--lqip-ca: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 18))), 4);
--lqip-cb: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 16))), 4);
--lqip-cc: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 14))), 4);
--lqip-cd: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 12))), 4);
--lqip-ce: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 10))), 4);
--lqip-cf: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 8))), 4);
--lqip-ll: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 6))), 4);
--lqip-aaa: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 3))), 8);
--lqip-bbb: mod(calc(var(--lqip) + pow(2, 19)), 8);
在渲染解码值前,原始数值需要转换为CSS颜色。过程相当直观,只需要通过一系列线性插值传入颜色构造函数。
/* 继续上面一块代码 */
--lqip-ca-clr: hsl(0 0% calc(var(--lqip-ca) / 3 * 60% + 20%));
--lqip-cb-clr: hsl(0 0% calc(var(--lqip-cb) / 3 * 60% + 20%));
--lqip-cc-clr: hsl(0 0% calc(var(--lqip-cc) / 3 * 60% + 20%));
--lqip-cd-clr: hsl(0 0% calc(var(--lqip-cd) / 3 * 60% + 20%));
--lqip-ce-clr: hsl(0 0% calc(var(--lqip-ce) / 3 * 60% + 20%));
--lqip-cf-clr: hsl(0 0% calc(var(--lqip-cf) / 3 * 60% + 20%));
--lqip-base-clr: oklab(
calc(var(--lqip-ll) / 3 * 0.6 + 0.2)
calc(var(--lqip-aaa) / 8 * 0.7 - 0.35)
calc((var(--lqip-bbb) + 1) / 8 * 0.7 - 0.35)
);
}
下面是不同的lqip值解码的效果:
你可以看到每个组件变量是如何映射到LQIP上的,例如,变量cb的值对应着图像顶部中间区域的相对亮度。
渲染LQIP
到最后一步了,我们来吧把LQIP给渲染出来。我使用了多个径向渐变来呈现灰度分量,并在底部设置了一个纯色的基座。
[style*="--lqip:"] {
background-image:
radial-gradient(50% 75% at 16.67% 25%, var(--lqip-ca-clr), transparent),
radial-gradient(50% 75% at 50% 25%, var(--lqip-cb-clr), transparent),
radial-gradient(50% 75% at 83.33% 25%, var(--lqip-cc-clr), transparent),
radial-gradient(50% 75% at 16.67% 75%, var(--lqip-cd-clr), transparent),
radial-gradient(50% 75% at 50% 75%, var(--lqip-ce-clr), transparent),
radial-gradient(50% 75% at 83.33% 75%, var(--lqip-cf-clr), transparent),
linear-gradient(0deg, var(--lqip-base-clr), var(--lqip-base-clr));
}
以上,是一个简化后的完整渲染器。真实版本包含平滑的渐变衰减和混合模式。这些径向渐变是基于CSS的LQIP核心。渐变的位置和半径是关键细节,决定了他们对真实图像的逼近程度。此外,另一个要求这些单独径向渐变在组合时必须无缝衔接。
双线性插值近似法搭配径向渐变
径向渐变默认采用线性插值。所谓插值,指的是如何将起始色至终止色之间的过渡色彩进行映射。而线性插值作为最基础的插值方式,它的表现并不是很好。
线性插值的效果并不理想,这样会出现很生硬的边缘,如上图。你基本上能看到每个径向渐变的椭圆形边缘极其中心点。
在真实的像素图像处理中,我们至少会用双线性插值来放大分辨率图像。双三次插值的效果更优。
要在CSS径向渐变阵列中模拟双线性插值的平滑效果,可以采用二次缓动(quadratic easing)来控制不透明的渐变。
这样渐变的不透明度在中心区域和边缘处理会更加柔和。每个渐变都将获得羽化的边缘,从而使整个合成的图像更加平滑。
下面对这些方案进行对比:
二次插值
线性插值
双线性插值
双三次插值
浏览器原生插值
没插值
然而,截至目前,CSS渐变(gradients)还不支持透明度的非线性插值(需要注意,这和浏览器已经支持的色彩空间不同)。目前的解决方案是根据二次函数公式,通过增加渐变中的色标点来获得平滑的透明曲线。
radial-gradient(
<position>,
rgb(82 190 240 / 100%) 0%,
rgb(82 190 204 / 98%) 10%,
rgb(82 190 204 / 92%) 20%,
rgb(82 190 204 / 82%) 30%,
rgb(82 190 204 / 68%) 40%,
rgb(82 190 204 / 32%) 60%,
rgb(82 190 204 / 18%) 70%,
rgb(82 190 204 / 8%) 80%,
rgb(82 190 204 / 2%) 90%,
transparent 100%
)
二次插值基于2条二次曲线(抛物线),每条对应渐变的一半——一条向上弯曲,另一条向下弯曲。
二次缓动将相邻的径向渐变混合在一起,模拟了平滑的双线性(甚至双三次)插值的效果。几乎达到了一个假装模糊的滤镜效果,从而实现了BlurHash替代方案中“模糊”效果的部分。
复现
我根据作者网站复现的代码:
附录:考虑的替代方案
四色方案
使用四种5位色彩,其中R占2位、G占2位、B仅1位(0或1)。这四种颜色会映射到图片框的四个角,以径向渐变形式渲染。这是最初的尝试,但是正确混合四种颜色需要真正的双线性插值,可能还需要着色器。简单的渐变叠加会导致色彩浑浊。而现有的CSS混合模式也无法解决。因此最终放弃,转而采用单色方案。
单色方案
单色方案简单有效。
<img src="…" style="--lqip:#9bc28e">
<style>
/* 通过‘别名’属性节省字节 */
* { background-color: var(--lqip) }
</style>
用HTML属性替代CSS自定义属性
这个方案需要等到未来attr() level 5标准实现。更简洁,CSS中可通过attr(lqlp type())引用该值,而非var(--lqip)
<img src="…" lqip="192900">