图片还没出来,页面先塌了:vibe 项目的图片懒加载与占位实战
图片是页面里最重的资源,也是最容易失控的:原生懒加载一行属性先顶九成场景,快进入视口提前 200 像素再接管、宽高比占位站住位置、模糊小图与主色填充、首屏图优先、失败兜底,以及上线前必须跑的七条检查。

图还没出来,页面先塌了一截:先讲一个虚构的教训
先讲一个虚构的教学例子。阿梨一个人用 AI 搭了个旅行相册应用“走走看看”,首页是一面照片墙,一次能刷出上百张旅行照片。上线第一天,她自己在地铁上打开首页,页面先是一片空白,接着图片一张一张往外蹦,每蹦出一张,下面的文字就往下跳一截;她正想点一张大理的照片,手指落下去的瞬间图片还没加载完、位置突然往下挪了半个屏幕,结果点到了一张丽江的照片。她在反馈群里收到三条一模一样的话:“你们首页怎么老是跳来跳去?”
阿梨查了之后才发现,问题出在最基础的地方:首页一次把上百张原图全加载了,首屏还没看完,流量已经烧掉几十兆;更糟的是,每张图片都没有预留位置,浏览器在图片回来之前根本不知道它有多高,只能先按零高度排版,等图片一到,整个页面就往下撑一截。图片一张一张地到,页面就一张一张地跳。用户不是在看照片墙,是在玩打地鼠。
这就是图片加载最常见的死法:把图片当成“放上去就完事”的装饰,而不是一次需要排队、需要占位、需要兜底的加载过程。图片恰恰是页面里最重的资源,也是最容易失控的资源:一张没压缩的原图能顶几百行代码的体积,一张加载失败的图能留下一个难看的裂图图标,一个没占位的图能把整个页面顶得上下乱跳。用户对图片的要求其实很简单:打开就看到位置、滚动到哪加载到哪、加载失败也有个说法。
本文讲的就是怎么给 vibe 项目做好图片懒加载与占位:浏览器原生的懒加载怎么写才够用、什么时候需要自己接管、占位怎么做才不让页面跳动、首屏图和非首屏图怎么分优先级、加载失败了怎么办,以及上线前必须跑的检查清单。以下示例均为虚构的教学示例,应用、人物与数据均为编造,仅用于说明做法;文中出现的具体数字(如提前 200 像素触发、模糊小图 20 像素宽)为本指南给出的工程经验建议,非引用数据。

一、先划界:懒加载管“什么时候加载”,占位管“加载之前长什么样”
动手之前先把两件事分开。懒加载回答的是“什么时候去要这张图”:首屏的图立刻要,屏幕下面的图等用户快滚动到了再要。占位回答的是“图还没回来的时候,这个位置长什么样”:留多高、用什么颜色、要不要先放一张模糊的小图顶着。两件事的目标不一样:懒加载省的是流量和首屏时间,占位保的是版式稳定。只做懒加载不做占位,页面照样跳;只做占位不做懒加载,首屏照样慢。
还有一个常见的混淆:懒加载不是压缩。把一张 5MB 的原图改成懒加载,首屏确实快了,但用户滚动到那张图的时候,还是要下载 5MB,手机流量照样烧、加载照样慢。正确的顺序是先压缩、再懒加载:图片先按实际显示尺寸裁切、转成体积更小的格式,该 800 像素宽的不要给 4000 像素的原图;然后再决定哪些图立刻加载、哪些图滚动到了再加载。顺序反了,懒加载只能掩盖问题,不能解决问题。
与已有的站内指南划界:本篇只讲前端加载策略——什么时候加载、占位怎么做、失败了怎么办。不讲图片的上传、裁切与存储链路,那是图片处理管线的事;不讲文件预览器的交互设计;也不讲 CDN 的缓存配置。分工清楚,每篇只解决一个问题。
二、第一步:先用浏览器原生的懒加载,九成场景都够了
很多 vibe 项目一上来就自己写 IntersectionObserver,其实现代浏览器已经把最常见的懒加载做进了原生属性:在 img 标签上加一个 loading="lazy",浏览器就会等图片快进入视口的时候再去下载。一行属性,不用写一行 JavaScript,九成的列表页、文章页、照片墙都够用了。
<!-- 首屏图立刻加载,非首屏图懒加载 -->
<img src="dali.jpg" alt="大理洱海边的晚霞" loading="eager" fetchpriority="high" width="1200" height="800" />
<img src="lijiang.jpg" alt="丽江古城的清晨" loading="lazy" width="1200" height="800" />
<img src="shangri-la.jpg" alt="香格里拉的雪山" loading="lazy" width="1200" height="800" />
这段代码里藏着三个容易被忽略的判断。第一,首屏的图不要懒加载:loading="eager" 加 fetchpriority="high",让浏览器优先下载用户第一眼看到的那张图。给首屏图加 lazy 是最常见的反向操作——用户打开页面先看到一个空位,等浏览器反应过来才开始下载,白白多等一轮。第二,width 和 height 一定要写:浏览器靠这两个数字提前算出图片占多高,没有它们,占位就无从谈起,下一节会细讲。第三,alt 不要留空、也不要写“图片”两个字:写清楚这张图是什么内容,图片加载失败或者读屏用户遇到它的时候,靠的就是这句 alt。
原生懒加载还有两个使用边界要知道。第一,它只管 img 和 iframe,不管 CSS 里 background-image 设的背景图——背景图要懒加载,得自己写 IntersectionObserver,或者干脆换成真正的 img 标签。第二,浏览器决定“快进入视口”的距离各不相同,你控制不了它提前多少触发;如果你的图片从占位到清晰之间有个明显的闪烁,或者滚动快了图片跟不上,那就说明这个场景超出了原生方案的能力范围,该上自己接管的方案了,也就是下一节。
三、进阶:自己接管 IntersectionObserver,提前 200 像素开始加载
当你需要更精细的控制——比如图片进入视口之前 200 像素就开始加载、加载失败自动换一张、加载成功后做淡入动画——就该自己接管 IntersectionObserver。它的原理也是一句话:告诉浏览器“帮我盯着这些图片,哪个快进入视口了就通知我”,收到通知再把真正的图片地址填进 src。
提前量是这里最关键的数字。本指南的建议是 rootMargin: "200px":图片离视口还有 200 像素的时候就开始下载。这样用户正常滚动的速度下,图片在进入视口之前基本已经下好了,不会看到占位到清晰的闪烁;同时 200 像素也不至于提前太多,不会把屏幕下面十几张图一次性全拉下来。滚动特别快的场景(比如用户猛地拖动滚动条到底部),200 像素可能不够用,可以放宽到 400 像素;反过来,流量敏感的场景可以收紧到 100 像素。先定 200,再根据实测调。
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src; // 真实地址存在 data-src 里
img.onload = () => img.classList.add('loaded'); // 成功后淡入
img.onerror = () => img.classList.add('failed'); // 失败走兜底样式
observer.unobserve(img); // 加载过一次就不再盯着它
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));
这段代码里有三个细节值得抄走。第一,真实地址放在 data-src 里,src 一开始只放占位图或者干脆不放——这是懒加载成立的前提,地址一早就写在 src 里,浏览器一解析就去下载了,接管等于白接管。第二,加载完成之后立刻 unobserve:盯着一张已经加载完的图片不放,是白白浪费浏览器的观察名额,列表越长浪费越大。第三,加载成功和失败都要有明确的状态类名:成功加 loaded 做淡入,失败加 failed 显示兜底样式,两种状态都不能是“什么都不做”。
淡入动画也要克制:透明度从 0 到 1 用 200 到 300 毫秒就够了,再长用户会觉得图片“半天才出来”,再短又看不出过渡。还要尊重 prefers-reduced-motion:用户在系统里开了“减弱动态效果”,淡入动画要直接关掉,图片直接显示,不要为了好看去晃晕别人。

四、占位:先把位置站住,页面才不会跳
占位是整篇文章里最重要的一节,因为页面跳动是用户感知最强的毛病,也是最容易量化的毛病。浏览器在图片回来之前不知道它有多高,只能先按零高度排版;图片一到,页面就被往下撑一截。解决办法只有一个:提前告诉浏览器这张图占多高、多宽,图片来不来,位置都在那里等着。
最稳的做法是 aspect-ratio 加宽高比。图片是 3 比 2,就在容器上写 aspect-ratio: 3 / 2;再配上 width: 100%、height: auto,浏览器在图片回来之前就能算出准确的高度。这个做法的成本几乎为零,效果却是最确定的——只要每张图的比例写对,页面跳动就能从根上消除。比例从哪来?上传图片的时候就把宽高存下来,前端渲染的时候直接带上;如果历史图片没有存宽高,那就按分类给一个默认比例,比如照片墙统一 4 比 3、头像统一 1 比 1,先保证不跳,再慢慢补真实比例。
只留一个空白位置还不够好,用户盯着一片空白等图片,心里是没底的。进阶的做法有两种。第一种是模糊小图:先加载一张只有 20 像素宽的极小版本,体积只有一两 KB,几乎瞬间就能回来,把它放大、加一层模糊,就成了图片的“剪影”;等原图回来之后再淡入替换。用户看到的不是空白,而是一张从模糊变清晰的图片,等待感一下子就降下来了。第二种是按图片主色调填一个纯色背景:上传时顺手取一张图的主色,存成一个色值,前端先用这个色值填充占位区域。纯色背景比空白好看,比模糊小图简单,两个做法可以二选一,不要叠加——模糊小图上面再套一层色块,纯属多余。
骨架屏是第三种做法,但要慎用。骨架屏适合整块内容都在加载的场景,比如一篇文章的正文区、一个卡片列表;不适合单张图片。给每张图片都套一个闪烁的骨架屏,页面上几十个灰块一起闪,用户会觉得整个页面都在“施工中”,比空白还烦。判断标准是:骨架屏只用在“一块区域”,不用在“一张图片”上。
五、优先级:首屏图先走,装饰图靠后
图片之间也要分个三六九等。首屏大图是用户第一眼看到的东西,优先级最高:立刻加载、最高优先级、甚至可以预加载。列表里的图按滚动顺序加载,谁先进入视口谁先走。装饰性的背景图、用户头像墙里的小头像、页面底部的图,优先级最低,等主要的图都差不多了再说。
具体做法有三条。第一,首屏图用 fetchpriority="high",并且不要给它加懒加载;一个页面里最多只能有一到两张高优先级图片,全标成 high 等于都没标。第二,预加载只留给真正确定的那一张首屏大图:在 head 里加 link rel="preload" as="image",浏览器在解析到 img 标签之前就开始下载。预加载用错了比不用更糟:预加载了一张用户根本不会看到的图,等于抢了首屏图的带宽。第三,srcset 要按显示尺寸给:手机上显示 400 像素宽的图片,不要给 1200 像素的版本;浏览器会按屏幕宽度和像素密度自己挑最合适的那一档,你只需要把几个档位都准备好。档位之间差一倍就够了,400、800、1600 三档能覆盖绝大多数场景,档位给得太细,维护成本比省下来的流量还高。
还有一个顺手就能加的属性:decoding="async"。它告诉浏览器解码图片不要挡住主线程,页面滚动的时候图片在后台慢慢解,解完再显示。加了它,滚动过程中因为图片解码造成的卡顿会少很多;不加它,一张大图在解码的那一瞬间就能让页面顿一下。这个属性没有兼容性负担,不支持的浏览器会直接忽略,属于加上没有坏处的类型。
六、失败兜底:图挂了,也要留个体面的样子
图片加载失败不是小概率事件:原图被删了、CDN 节点抖了一下、用户在地铁里信号断了,都会让一张图挂掉。没有兜底的页面,失败的图会留下一个裂图图标加一串 alt 文字,难看不说,用户还不知道这张图本来是什么。兜底要分两层做。
第一层是单张图的兜底:onerror 触发之后,不要反复重试——失败的地址重试三次还是失败的概率很高,白白烧流量。正确的做法是换一张默认图,或者把图片区域整个换成一个带图标和文字的占位块,写清楚“图片加载失败,点一下重试”。“点一下重试”是给用户的选择权,不是自动重试;自动重试三次都失败,再让用户手动点一次,用户会觉得这个应用连自己的失败都控制不住。
第二层是整页的兜底:如果一个列表里超过一半的图片都失败了,问题大概率不在单张图上,而是出在图片服务本身。这时候不要让用户一张一张点重试,要在列表顶部给一条横幅:“图片服务好像出了点问题,正在恢复中,也可以稍后再来看看。”一句话,用户就知道不是他的网络问题,也不是他操作错了。横幅出现之后,后台要立刻告警——图片服务挂了,比任何功能 bug 都紧急,因为用户第一眼看到的就是它。
七、移动端与流量:省流量就是省体验
移动端做图片懒加载,省流量就是省体验。用户在地铁上刷你的照片墙,流量是按兆算的,每张多余下载的原图都是在烧用户的钱。除了前面说的按尺寸给图,还有三条移动端特有的做法。第一,检测到用户开了省流量模式(Save-Data)时,默认只加载低清晰度版本,等用户点一下图片再加载原图;这是浏览器提供的现成信号,不用自己猜。第二,弱网环境下先不加载非首屏图,等网络恢复再继续——用 navigator.onLine 和 online 事件就能判断,不要让用户在没信号的时候看着一堆转圈。第三,图片格式优先用体积更小的现代格式,浏览器不支持再回落到老格式;格式切换的收益是实打实的,一张图省一半体积,一百张图就省一半流量。
还有一个容易被忽略的点:用户上传的图片也要管。用户从手机相册里选一张 12MB 的原图直接上传,服务器原样存下来、前端原样展示,懒加载做得再好也救不回来。上传链路里必须有一道压缩和裁切:按展示需要的最大尺寸裁切、转成合适的格式、生成几个档位,再入库。前端懒加载是最后一道防线,上传时的处理才是第一道。
八、轮播图与长列表:两种最容易翻车的场景
懒加载的通用做法都讲完了,但有两个场景特别容易翻车,值得单独拎出来说。第一个是首页轮播图。轮播图里的图片看着在首屏,其实只有第一张是用户第一眼看到的,第二张、第三张藏在轮播后面,用户可能根本不会翻到。常见的做法错误有两种:一种是把轮播里的所有图都当成首屏图、全部立刻加载,首屏一下子多出三四张大图的流量;另一种是把所有轮播图都懒加载,结果用户点到第二张的时候才开始下载,轮播切过去是一片空白。正确的做法是分开处理:第一张按首屏图处理,立刻加载、最高优先级;第二张开始下载的时间定在第一张加载完成之后、或者用户第一次和轮播产生交互的时候;第三张及以后,等轮播切到前一张的时候再开始预加载下一张。轮播的自动播放还要留个心眼:页面在后台标签页的时候不要自动切图、也不要预加载后面的图,用户切回来之前烧掉的流量全是浪费。
第二个是无限滚动的长列表。照片墙、商品流这种一刷到底的页面,懒加载的观察对象会越攒越多:一开始观察几十张图,用户往下刷一百张、一千张,观察列表里躺着上千个已经加载完的目标,浏览器的负担越来越重。解决办法是两条一起做:第一,加载完成立刻 unobserve,前面已经讲过,这里再强调一次——长列表里不及时取消观察,页面越刷越卡;第二,列表本身要做虚拟滚动或者分页回收:屏幕之外的图片节点不要无限留在文档里,滑出视口很远的图片可以把它的 img 节点回收掉、只保留占位,等用户滑回来的时候再重新挂上去。虚拟滚动听起来重,但对付上千张图的列表,它是唯一能让页面不越刷越卡的办法;如果你的列表只有几十张图,老老实实做懒加载加及时取消观察就够了,不要为了“以防万一”提前上虚拟滚动。
这两个场景还有一个共同点:都要在真机上测,不要只在桌面浏览器上拖滚动条。轮播图在桌面上切得飞快,在手机上可能因为图片太大、解码太慢而顿一下;长列表在桌面上刷一千张不卡,在千元安卓机上刷两百张就开始掉帧。测试的时候拿一台中低端手机、开慢速网络,从头刷到尾,数一数跳了几次、卡了几次,这比在性能面板上看十个指标都实在。
最后说一个最容易被忽略的流量账:图片的请求次数本身也是成本。很多列表页为了“看起来快”,会在用户滚动之前就把下面十几张图的地址全预取回来,觉得反正用户迟早会刷到。但数据不会说谎:一个普通的照片墙页面,用户平均只会看完前两屏就离开,第三屏及以后的图片预取回来,十有八九是白下的。预取的度要拿捏好:只预取下一屏的图片,不要预取下三屏;用户滚动速度突然加快的时候,预取要立刻停下来,先保证当前视口里的图能出来。流量账算清楚了,懒加载才算真正省了钱;否则只是把“首屏慢”换成了“全程都在下没用的图”。
九、上线前必须跑的检查清单
图片懒加载上线之前,把下面这张清单逐条跑一遍,跑完再发。第一,把网络限速调到慢速 4G,打开首页,首屏大图要在两秒内出来,并且页面全程不能有肉眼可见的跳动。第二,慢慢滚动到页面底部,数一下首屏之外一共发了多少图片请求——没有滚动到的图片,一个请求都不应该发。第三,快速拖动滚动条到底部,确认图片是按顺序补齐的,而不是一堆占位同时闪烁。第四,断网之后刷新页面,每张失败的图都要有兜底样式,不能出现裂图图标。第五,打开开发者工具的性能面板,确认页面跳动指标在合理范围——肉眼看不跳只是及格,指标也要过关。第六,在手机上用省流量模式打开页面,确认默认只加载低清晰度版本。第七,随机挑三张图,检查 alt 写的是不是这张图的内容,而不是“图片”两个字。
最后再说一句判断标准。图片加载做得好不好,不用看用了多高级的技术,看三个数字:首屏图片出现要几秒、页面滚动过程中跳了几次、加载失败的图有没有兜底。第一个数字越小越好,第二个最好是零,第三个必须百分之百。这三个数字都达标,说明你的图片加载真的在帮用户省时间、省流量;反过来,技术名词堆得再多,页面一打开就跳来跳去,用户也只会觉得这个应用不靠谱。图片加载的终点不是“图能出来”,是“图在该出现的时候出现,在没出现的时候位置也在,出了问题也有说法”。
评论 (0)
相关文章

搜索框看着最简单,却站在用户目的最明确的时刻:防抖 300 毫秒、取消旧请求不让它盖掉新结果、搜不到给纠错和退路、匹配词高亮、键盘走全程、中文输入法不捣乱,以及上线前必须跑的七条检查。

Toast 看着不起眼,却站在用户最脆弱的时刻出现:删了东西、付了钱、传完文件。什么时候该弹、文案怎么写、显示多久、放哪里、撤销怎么做才真能撤销,以及上线前必须跑的五组测试。

“3 分钟前”还是“2026-11-06T02:53:33.198Z”?一张可直接抄的阈值表、服务端只存 UTC 时间戳的铁律、前端分档刷新、夏令时与多语言边界,以及上线前必须跑的五组测试。