我做过一个图片社区。首页是个瀑布流,一屏能塞下几十张图。最开始上线那会儿,我打开首页,网速稍微差一点,就看着页面一格一格地白——不是白屏,是图片一张一张慢慢蹦出来,像老式的幻灯片。
用户骂了几次之后我开始查:到底为什么这么慢。
答案说出来你可能不信:首页一次请求了 100 多张图片。瀑布流往下拉,图片在屏幕外,浏览器也在拉。页面要展示 100 张,我就让后端传 100 张的 URL,前端一口气全加载。结果就是:用户盯着第一屏,浏览器却在偷偷下载第十屏的图。带宽全浪费在看都没看一眼的图上。
懒加载的思路朴素得不能再朴素:图片进入视口附近才加载,没进视口的就是个占位。这个项目用的是浏览器原生 API IntersectionObserver——它专门干"检测元素是否进入视口"这件事,比监听 scroll 事件高效得多(scroll 事件每滚一像素触发一次,IntersectionObserver 由浏览器底层优化,回调频率受控)。
核心代码就这么几行:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const pictureId = entry.target.getAttribute('data-pic-id')
if (pictureId) {
visiblePictures.value.add(pictureId) // 标记为可见 → 才渲染 <img>
}
}
})
}, {
rootMargin: '200px' // 提前 200px 预加载
})
配合模板里的条件渲染:
<div :data-pic-id="picture.id" v-if="visiblePictures.has(picture.id)">
<img :src="picture.thumbnailUrl || picture.url" loading="lazy" />
</div>
逻辑是:图片的 DOM 一开始不渲染 img(只挂一个带 data-pic-id 的占位容器),等 IntersectionObserver 报告它进入视口(或进入视口下方 200px 的预加载区),才把图片 id 加进 visiblePictures 这个 Set,Vue 响应式地渲染出真正的 <img>,这时候浏览器才开始请求。
关键点:Vue 的响应式在这里立功了——visiblePictures 是个 ref<Set>,往 Set 里 add 一个 id,依赖它的组件自动重新渲染。图片从"占位"变"真图"的整个过程,不需要手动操作 DOM,数据驱动,干干净净。
光讲原理不够,我拿无头浏览器对着线上首页实测了一轮。做法:打开首页,统计浏览器发出的所有图片请求,分两个阶段:
结果:
首屏图片请求:55 张
滚动后图片请求:93 张
懒加载按需新增:+38 张
这意味着什么?页面总共 93 张图,首屏只请求了 55 张——剩下的 38 张,是用户滚动过程中才"临幸"的。如果不用懒加载,首屏就要同时发 93 个请求,带宽、连接数、后端压力全翻倍;用懒加载,首屏只有 55 个请求,剩下的按需到位。
这是真实网站的实测数据,不是实验室数字。截图如下——第一张是首屏(未滚动),第二张是滚动到底后的完整瀑布流:


细看第一张截图你会发现,首屏下半部分有些图片还是"占位状态"——它们就在视口边缘,IntersectionObserver 还没来得及标记,或者刚好在 rootMargin 预加载区边界。这就是懒加载的实时状态:不是所有图同时出现,而是滚到哪亮到哪。
体验上的细节是:如果严格"进入视口才加载",用户快速往下滚的时候,会看到图片"来不及加载"——滚到一张图,它才开始下载,中间有个白底闪烁。所以加了 rootMargin: '200px',让 IntersectionObserver 把"视口"向下扩 200px:图片还没进入视口,但离视口不到 200px 时就开始加载。用户滚到那里时,图已经下好了。
这个 200px 是调出来的:设太大(500px+),等于提前下载太多,懒加载效果打折;设太小(50px),快速滚动会看到白底。200px 在"感知不到的预加载"和"真正的按需加载"之间取了个平衡。移动端网络更慢,200px 的提前量尤其重要——等用户手指滚过去,图已经在路上或到了。
懒加载解决"什么时候下载",缩略图解决"下载多大的"。一张 5000×3750 的原始照片有 8MB,瀑布流列表要是直接加载原图,首屏 55 张 = 440MB——谁受得了。
所以图片上传后,COS(对象存储)会生成一套派生图:
// 缩略图规则:最大 1024px + WebP 格式
String rule = String.format(
"imageMogr2/thumbnail/1024x1024>/format/webp/quality/%d", quality);
imageMogr2 是腾讯云 COS 的图片处理接口:thumbnail/1024x1024> 表示缩到 1024px 以内(> 是"只缩小不放大"),format/webp 转成 WebP,quality 控质量。处理结果存在 COS,得到一个缩略图 URL,存进 thumbnailUrl 字段。
前端列表加载时只加载缩略图:
<img :src="picture.thumbnailUrl || picture.url" ... />
有缩略图用缩略图,没有才兜底原图。8MB → 128KB,体积降了 98%,视觉上列表页根本看不出区别(1024px 在手机和普通桌面都够清晰)。点开详情页才加载原图——那时候用户已经决定要看了,8MB 值得。
这套"懒加载 + 缩略图"组合拳下来,首屏从"100 多张原图齐飞"变成"55 张 128KB 的 WebP 按需到位",首屏体积至少降了两个数量级。
把账算细一点,你会更直观地感受到差距。不用懒加载、不缩略图,首屏 93 张原图按平均 6MB 算,是 558MB——任何手机都直接卡死,运营商流量当场爆炸。用了缩略图(128KB)+ 懒加载(首屏 55 张),首屏是 55 × 128KB ≈ 7MB;滚动到最底累计 93 × 128KB ≈ 12MB。从 558MB 到 12MB,差了 46 倍,而且这 46 倍里用户体验完全无感——因为人眼在列表页根本分不出 1024px 缩略图和原图的差别。这就是我说的"性能是算出来的":你把这两组数字往桌上一拍,谁都没话说了。
移动端是图片社区的主战场,也是性能最吃紧的地方。同一个瀑布流,移动端单独做了一套(MobilePictureList),处理几个和 PC 不一样的现实:
两列就够,别贪三列。 手机屏幕宽度就 375px 左右,三列的话每张图只剩一百来像素,缩略图再清晰也白搭——视觉上全是马赛克。两列,每张图 180px 左右,和 1024px 缩略图正好匹配,看着舒服也不浪费带宽。列数不是越多越好,是图的实际显示宽度和缩略图分辨率匹配才好。
懒加载提前量更大。 移动端网络慢、延迟高,rootMargin 我调到了 200px 以上(移动端按需微调),因为手指滑动是连续的,用户一旦开始滚,速度很快——图必须提前一步开始下载,否则滚过去就是白底。移动端还有一个特殊场景:用户从瀑布流点进详情页再返回,滚动位置和已加载的图要保留(keep-alive 那套),不能每次都重新从头加载。
触底加载更多。 移动端没有"无限滚动"的天然分页,靠 IntersectionObserver 观察一个"底部哨兵"元素:哨兵进入视口就触发加载下一页。这个哨兵和图片懒加载是同一个 Observer 体系,只是它的回调是"请求下一页数据"而不是"渲染图片"。数据到位后,新图片加入瀑布流,又要重新 observe——和前面说的"数据更新后重新观察"是同一个坑,移动端一次都没躲过。
懒加载之后,图片出现前有一小段空白。这段空白处理不好,用户会以为页面坏了。我们做了两件事:
占位背景色。 图片容器在加载前渲染一个固定比例的背景块(按图片的宽高比占位,防止加载后布局跳动——CLS 布局偏移的主要来源)。背景色用了个淡灰,图片出现后自然盖住。
渐进式出现。 图片加载完成后加一个轻微的淡入动画(opacity 0→1,300ms),配合缩略图到原图的切换,视觉上是"图片浮现"而不是"图片弹出"。
这两个都是小成本,但对"体面"贡献巨大:用户看到的是一个慢慢填充的瀑布流,而不是一格一格跳动的白块。性能优化到后面,拼的不是技术,是细节的体面——同样的加载时间,一个丝滑一个跳闪,用户记住的是那个丝滑的。
懒加载解决性能,瀑布流解决观感。图片社区用网格(等高等宽)有个问题:图片比例千差万别,硬塞进等高的格子里,要么裁剪裁掉主体,要么留白一大片。瀑布流(Pinterest 式)的思路是:列等宽、图不等高,新图总是塞进当前最短的那一列。
实现上,项目里 PC 端用多列瀑布流(BigPictureList 按列分发),移动端单独一套(MobilePictureList,两列或单列)。图片高度不同,列高天然不齐,新图补到最短列,整体高度差控制在最小——视觉密度最大化,没有刺眼的空白。
瀑布流和懒加载是一对黄金搭档:瀑布流的图是动态高度的,只有图片加载出来才知道高度,而懒加载保证"只有要显示的图才去加载",两者天然契合。如果瀑布流里全量加载,布局和性能一起崩。
这套东西踩过的坑,挑三个说:
第一,Observer 的观察时机。 数据是异步拉回来的,图片 DOM 是数据到位后才渲染的。如果你在 onMounted 里只 observe 一次,后面新渲染的图片就没人管了——永远占位。解法是在数据更新后重新观察(代码里 observeImages() 在数据到达后调用,重新 querySelectorAll 一遍)。这个坑特别隐蔽:首屏看着正常,一翻页新图全不加载,你还以为接口坏了。
第二,rootMargin 不是越大越好。 我一开始设 500px,想着多预加载总没坏处。结果首屏请求直接翻倍,懒加载形同虚设——提前下载的图,用户可能永远不滚到。后来压到 200px 才平衡。预加载是给"即将看到"的图,不是给"可能看到"的图。
第三,缩略图兜底。 老数据(缩略图功能上线前上传的)没有 thumbnailUrl,列表直接 picture.url 加载原图,一张 8MB,页面立刻卡。所以前端一定要 thumbnailUrl || url 兜底,同时后端跑一个批量任务给老图补生成缩略图。新旧数据不兼容,是每个增量功能都要面对的现实。
写到最后想说的是,性能优化最怕"我觉得"。我觉得懒加载能快——快多少?我不知道。直到我用无头浏览器数了请求数:55 → 93,38 张按需加载,首屏请求减半。数字摆出来,优化才从"感觉"变成"事实"。
如果你也在做图片类站点,这套"IntersectionObserver 懒加载 + rootMargin 预加载 + COS 缩略图 + 瀑布流"是标准答案,直接抄就行。抄的时候记住三件事:Observer 要在数据更新后重新观察(不然新图永远不加载)、rootMargin 200px 左右的提前量(别贪多)、列表只加载缩略图原图留给详情页(体积降两个数量级)。
还有一件事:用无头浏览器实测,把请求数、体积、加载时间记录下来。不是为了写博客,是为了下次有人质疑"这优化有意义吗"的时候,你能甩出一组数据,而不是一句"感觉快了不少"。