做前端这几年,我发现一个规律:页面性能的锅,图片背了一大半。不是加载慢,就是加载过程难看——图片没来之前一片空白,图片来了猛地把下面的内容顶飞,用户刚要点的按钮瞬间移位。这种体验,用户嘴上不说,跳出率会替他说。
这篇文章不谈玄学,就把图片占位这件事从头捋一遍:为什么要占位、几种主流方案各自的成本和效果、怎么锁住布局不让它抖、以及一个很多人会忽略的取舍——占位本身也是有代价的。文末我会诚实地说清楚每种方案的边界,不吹。
先搞清楚:我们到底在解决什么问题
图片加载体验烂,本质是两个独立的问题,很多人把它们混为一谈。
第一个问题是「视觉空洞」:图片还没下载完,那块区域是纯白的。用户盯着一片空白,不知道那里将来会有东西,感知上就是「这网站没加载出来」。
第二个问题是「布局抖动」(CLS,Cumulative Layout Shift):图片下载完成的一刹那,浏览器才知道它的真实尺寸,于是撑开容器,把它后面的所有内容往下推。你正准备点的链接,突然跑了。这是 Core Web Vitals 里被单独拎出来考核的指标,Google 的阈值是 CLS 小于 0.1 算「良好」。
关键认知:这两个问题要用两套手段解决,别指望一招通吃。
- 视觉空洞 → 用「占位内容」填上(纯色、模糊图、骨架屏……)
- 布局抖动 → 用「尺寸预留」锁死(aspect-ratio、width/height 属性……)
一个管「好不好看」,一个管「跳不跳」。下面分开讲。
一、先把布局抖动摁死(这一步几乎零成本)
我把 CLS 放在最前面,因为它性价比高得离谱——不需要任何图片处理、不需要额外请求,改几行就能拿到实打实的收益。
核心思路只有一句:在图片下载完成之前,就让浏览器知道它会占多大的地方。
最古早也最可靠的办法,是给 <img> 写死 width 和 height 属性(注意是 HTML 属性,不是 CSS):
<img src="hero.jpg" width="1600" height="900" alt="示意图">
现代浏览器会读取这两个属性算出宽高比(16:9),在图片没来之前就按这个比例预留出空间。你在 CSS 里再让它响应式缩放也不冲突:
img {
width: 100%;
height: auto;
}
只要 HTML 上有 width/height,浏览器就能在 height: auto 的情况下自动维持比例,不会塌成 0 高度。这是这些年浏览器补上的一个很实用的行为。
如果你的图片尺寸不固定、或者用 CSS background 而不是 <img>,那就直接上 aspect-ratio:
.thumb {
aspect-ratio: 4 / 3;
width: 100%;
background: #eceef1; /* 顺手做个纯色占位 */
}
aspect-ratio 现在的浏览器支持面已经很广了,写起来也直白。容器先按比例占好位置,图片进来往里填,前后尺寸一致,一点不抖。
一个实战里常踩的坑:响应式布局下,一张图在手机上是 4:3、在桌面上被裁成 16:9,你如果只写一个固定 aspect-ratio,另一个断点就会抖。解决办法是配合媒体查询给不同断点不同比例,或者干脆用 <picture> 给不同视口喂不同尺寸的源,每个源都带自己的 width/height。
做完这一步,你的 CLS 基本就稳了。剩下的都是「好不好看」的问题。
二、占位方案横评:从最省事到最讲究
预留好空间之后,那块地方在图片来之前长什么样?从简单到复杂,我按投入产出排个序。
方案 A:纯色 / 主色占位
最简单:一块灰。稍微讲究点:取图片的主色(平均色或主导色)填进去。
<div class="ph" style="background:#3a6ea5; aspect-ratio:3/2"></div>
主色的好处是「和最终图片调性一致」,图片淡入时的过渡很自然,不会从一块死灰突兀地跳成彩色。成本也极低——一张图的主色就是一个十六进制色值,7 个字符,可以直接内联在 HTML 属性里,零额外请求。
怎么拿主色?服务端处理图片时顺手算一下平均色,或者用 node 端的图片库(sharp 之类)resize 到 1x1 像素读那个像素值,出来的就是平均色。前端也有取色库,但更推荐在构建期/上传期算好,别让用户的浏览器干这活。
适用场景:列表页、缩略图流、对占位精细度要求不高但数量巨大的场景。它便宜、稳、不出错。
方案 B:LQIP(低质量图片占位)
LQIP 的思路很直觉:先给一张巨小、巨模糊的原图,占着位;真图来了再替换。
早期做法是把原图压成 20~50px 宽、质量拉到很低的 JPEG,base64 内联进 HTML,CSS 用 filter: blur() 糊一下遮丑:
<img
class="lqip"
src="data:image/jpeg;base64,/9j/4AAQSkZ...(几百字节)"
data-src="full.jpg"
style="filter:blur(12px); aspect-ratio:3/2">
真图加载完再切 src、去掉 blur、加个淡入。因为是原图缩下来的,色彩和构图都对得上,模糊放大后是「这张图的朦胧版」,观感比纯色好一截。
代价:即便压到很小,一张 base64 内联的 LQIP 通常也要几百字节到 1KB 出头。如果一个页面几十张图,这些内联数据会把 HTML 撑大,首屏 HTML 变肥反而拖慢解析。所以 LQIP 更适合数量不多的大图(文章头图、Banner、详情大图),不适合海量缩略图。
方案 C:BlurHash / ThumbHash(紧凑模糊占位)
这是我个人比较喜欢的一档。BlurHash 是 Wolt 团队开源的方案,思路很妙:把图片的模糊表示编码成一个 20~30 个字符的短字符串,前端拿这个字符串在 canvas 上解码成一张模糊图。
一张图的占位信息,从 LQIP 的几百字节压到了 20 来个字符,塞进 JSON 接口的字段里毫无压力:
{
"url": "photo.jpg", "hash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj" }
前端解码(以 React 生态常见用法为例,纯示意):
import {
decode } from "blurhash";
const pixels = decode(hash, 32, 32); // 解成 32x32 像素
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0); // 画到占位 canvas 上
// canvas 拉伸铺满容器,真图 onload 后淡入覆盖
ThumbHash 是同类思路的改进版,字符串更短、支持透明通道、对小图边缘和纵横比的还原更准,编码里还带了宽高比信息。如果现在从零开始选,ThumbHash 值得优先看一眼。
BlurHash/ThumbHash 的甜点在于:占位数据小到可以随列表接口一起返回,几百上千张图也不心疼;解码在客户端做,纯 CPU 无额外网络请求。代价是需要在服务端/上传管线里预先算好 hash(多一道处理),前端也要引一个解码库和一小段 canvas 逻辑。对已经有图片处理流程的项目,这个成本很好摊。
方案 D:SVG 骨架屏
前面三种都是「模糊真图」,骨架屏是另一个流派——它不还原图片内容,而是画一个灰色的「结构轮廓」:一个圆代表头像、几条横杠代表文字、一个方块代表图片位。
骨架屏适合的是整块 UI 区域,而不是单张图片。列表卡片、个人主页、商品详情这种有固定结构的地方,加载时先铺一层骨架,用户一眼就知道「这里将来是一排卡片」。
实现上,纯 CSS 就能做,配一个从左到右扫过的高光动画(shimmer)暗示「正在加载」:
.skeleton {
background: linear-gradient(90deg, #eee 25%, #f5f5f5 37%, #eee 63%);
background-size: 400% 100%;
animation: shimmer 1.4s ease infinite;
}
@keyframes shimmer {
0%{
background-position:100% 0} 100%{
background-position:0 0} }
注意力提醒:骨架屏别做得太逼真、动画别太抢眼。它是「安抚等待」的,不是「表演加载」的。见过一些页面骨架屏做得比真内容还花哨,用户反而以为加载卡住了。另外骨架屏和图片占位不冲突,很多真实页面是「外层骨架屏 + 图片位用 BlurHash」组合着用。
三、渐进式加载:让「换图」这一下不突兀
占位有了,真图来了,怎么把这两者衔接得不生硬?关键是过渡。
最简单有效的是淡入。真图 onload 之后再把它的 opacity 从 0 过渡到 1,占位层留在下面:
.real-img {
opacity: 0; transition: opacity .4s ease; }
.real-img.loaded {
opacity: 1; }
img.addEventListener("load", () => img.classList.add("loaded"));
几个能明显提升观感的细节:
- 别在真图 onload 之前就把占位撤掉,否则会闪一下白。让占位垫在真图底下,真图淡入盖住它,过渡结束再考虑要不要移除占位节点。
- 配合原生懒加载。
<img loading="lazy">让视口外的图延后加载,配合占位,用户滚到哪加载到哪,首屏请求数直接降下来。 - decode 后再显示。用
img.decode()这个 Promise,等浏览器把图片解码到「可无卡顿绘制」再淡入,能避免大图在主线程解码时的一下卡顿:
const img = new Image();
img.src = url;
img.decode().then(() => show(img)).catch(() => show(img));
- 图片格式顺手也优化一下——AVIF / WebP 比老 JPEG 小不少,同样清晰度下体积能砍掉三到五成,加载快了,占位停留的时间自然短。
四、体积与实现成本,一张表说清
我把几种方案的关键取舍列出来,方便你按项目选:
| 方案 | 占位数据大小 | 额外网络请求 | 需要预处理 | 观感 | 适合场景 |
|---|---|---|---|---|---|
| 纯色/主色 | ~7 字符 | 无 | 取色(轻) | 一般 | 海量缩略图、列表流 |
| LQIP 内联 | 几百字节~1KB+ | 无(内联) | 生成小图 | 较好 | 少量大图、头图 |
| BlurHash/ThumbHash | 20~30 字符 | 无 | 算 hash | 好 | 大量图 + 有处理管线 |
| SVG 骨架屏 | 极小 | 无 | 无 | 结构感 | 整块 UI 区域 |
选型的直觉判断:
- 图多、接口驱动、已经有图片处理链路 → BlurHash / ThumbHash,占位数据小到可以随列表下发。
- 图少、每张都重要(文章头图、活动 Banner) → LQIP,观感最贴近真图。
- 就想快速把 CLS 和空洞都摁住、不想加处理链路 → aspect-ratio + 主色占位,一下午能全站铺完。
- 要占位的是一整块结构而非单图 → 骨架屏。
真实项目往往是混着用的:外层骨架屏定结构,图片位内部用主色或 BlurHash,全局用 aspect-ratio 锁 CLS。这不是选择题,是搭配题。
五、一个容易被忽略的取舍:占位本身也有成本
聊了半天占位的好处,得泼盆冷水——占位不是免费的,堆过头会反噬。
- BlurHash/ThumbHash 解码是要 CPU 的。一个列表几十上百张图同时解码 canvas,低端机上会卡一下主线程。缓解办法:只给视口内和即将进入视口的图解码,用 IntersectionObserver 控制时机,别一进页面全解。
- LQIP 内联会撑肥 HTML。前面说过,几十张内联 base64 能让 HTML 首屏解析变慢,得不偿失。
- 骨架屏动画耗电耗合成。大面积 shimmer 动画在移动端持续跑,是有功耗的,别铺满全屏还无限循环。
- 过度设计的淡入/位移动画反而拖慢「感知完成时间」。用户要的是内容快点出来,不是欣赏你的转场。
一个务实的原则:占位方案的复杂度,要和图片的重要性成正比。 详情页那张主图值得上 LQIP + decode + 淡入;侧边栏一堆小图标,一块主色背景就够了,别给它们上 BlurHash。
工具与落地建议
如果你只是想快速验证效果、或者手上没有现成的图片处理管线,几个方向可以先摸:
- 格式压缩 用 Squoosh(浏览器端)或 TinyPNG,AVIF/WebP 转换这类在线工具直接出结果;批量场景用 sharp 写脚本。
- 主色 / 模糊占位 大多要接进上传或构建流程,sharp、blurhash、thumbhash 都有成熟的 npm 包。
- 一些在线图片工具站(比如 tinypng、squoosh、tudingai.cn 之类)能覆盖压缩、格式转换、抠图这类零散需求,适合做占位前的图片预处理。
真正落地时,别一上来就上最重的方案。先用 aspect-ratio 把 CLS 摁住——这一步收益最大、成本最低;再用主色占位铺满全站补上视觉空洞;最后针对首屏大图这类关键位置,才值得投入 LQIP 或 BlurHash 精修。按这个顺序推,每一步都能立刻看到指标变化。
诚实边界
这篇写的是通用的、公开的前端工程实践,不代表某个具体产品的内部实现。几点得说清楚:
- 文中的体积、时长都是量级参考,不是精确承诺。 占位数据大小随图片内容和参数浮动,实际项目请自己测。BlurHash 20~30 字符是常见范围,复杂图或高精度参数会更长。
- 浏览器兼容性会变。 aspect-ratio、loading="lazy"、img.decode()、AVIF 的支持面这几年一直在扩,但你的目标用户里如果有老旧环境,务必查一遍 caniuse 再决定,别照抄。
- 代码示例是说明思路的最小片段,去掉了错误处理、清理逻辑、SSR 水合等真实工程里绕不开的部分,直接抄进生产要自己补全。
- 没有「一套方案通吃」。 本文给的是取舍框架,不是标准答案。你的图片数量、来源、重要性、用户设备分布不同,结论就会不同。真正靠谱的做法是:小范围试点,用 Lighthouse / Web Vitals 量出 CLS 和 LCP 的真实变化,再决定推不推全站。
图片占位这件事不难,难的是克制——知道每种方案的代价,在对的地方用对的力气。把 CLS 摁住、把空洞补上,剩下的精修按重要性投入,这就够了。