1. 为什么"图片晚点出场"能救你的页面性能
1.1 一张大图拖垮首屏的完整链路
我第一次认真研究懒加载,是被一个活动页逼的。那个页面首屏顶部是一张 1920×1080 的主视觉 banner,设计稿导出来 3.2MB,下面是十几个商品图。上线后运营反馈"页面白屏时间太长",我用 Lighthouse 一跑,首屏加载时间 6.8 秒,图片占了总资源体积的 87%。当时最扎心的一个细节是:用户根本不会滚动到页面底部的那些商品图,但浏览器在解析 HTML 时照样把它们全部下载了。
这里要先说清楚一个底层事实:<img> 标签只要出现在 DOM 里,浏览器就会立刻去请求 src 指向的地址。页面里挂了 30 张图,不管用户看不看得见,这 30 个请求在页面加载那一刻就全部出发了。图片文件是二进制资源,不像 JavaScript 可以延迟执行,也不像 CSS 可以按需拆分,浏览器拿到 HTML 后就开始并发下载图片。如果这些请求都挤在首屏阶段,就会跟 CSS、JS、字体资源争抢有限的网络连接。HTTP/1.1 时代浏览器对同一域名只开 6 个连接,图片一旦占满连接池,后面的接口请求都得排队——这才是页面白屏的元凶之一。
所以懒加载要解决的核心问题不是"图片大小"本身,而是"图片的请求时机"。把一个 3.2MB 的 banner 压缩到 400KB 当然有用,但哪怕这张图只有 200KB,只要它是首屏不需要的资源,延迟加载就能省下对应的网络开销。我在项目里给运营同学解释时用的类比是:去餐厅吃饭,不应该一进门就把整本菜单所有菜都端上桌,而是你翻到哪一页,哪一页的菜再开始做。
1.2 懒加载到底节省了什么:网络、内存与渲染三个维度
很多初学者以为懒加载只是"少加载几张大图",节省几个请求而已。实际拉长时间线看,它优化的是三个层面:
- 网络层面:首屏只加载视口内的图片,视口外的图片请求被延后。首屏资源体积减少后,页面关键渲染路径变短,LCP(Largest Contentful Paint,最大内容绘制)指标会明显改善。
- 内存层面:图片下载后会被解码成位图存放在内存里,一张 4000×3000 的 JPEG 解码后大约占用 4000×3000×4 字节,约 48MB。如果页面有 20 张这样的图且全部加载,光图片解码就能占用近 1GB 内存——移动端浏览器直接崩溃都有可能。懒加载让未进入视口的图片不被解码,内存占用是动态释放的。
- 渲染层面:大量图片同时加载会触发多次重排重绘。尤其当图片没有显式宽高、加载完成后撑开布局时,页面会出现剧烈的"跳动"。懒加载结合占位尺寸,能保证布局稳定,CLS(Cumulative Layout Shift,累计布局偏移)指标也不会差。
我在性能优化项目里通常会把懒加载放在"资源加载策略"的整体框架中看待,它跟图片压缩、CDN 加速、HTTP 缓存是互补关系,而不是替代关系。压缩解决的是"单张图片文件大小",懒加载解决的是"一次请求多少张图片"。两者配合,效果是相乘的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先动手写一个最小可用的懒加载:从滚动监听说起
2.1 基于滚动事件的经典实现与它的计算逻辑
懒加载最朴素的思路是:监听页面的滚动事件,每次滚动时判断图片元素是否进入了视口,进入就把 data-src 的值赋给真正的 src。这个思路不需要任何浏览器新 API,IE 时代就能跑,我最早在项目里用的就是这套:
javascript复制function lazyLoad() {
const images = document.querySelectorAll('img[data-src]');
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
images.forEach((img) => {
const rect = img.getBoundingClientRect();
if (rect.top >= 0 && rect.top <= viewportHeight) {
img.src = img.getAttribute('data-src');
img.removeAttribute('data-src'); // 加载后移除标记,避免下一轮重复判断
}
});
}
window.addEventListener('scroll', throttle(lazyLoad, 200));
window.addEventListener('resize', throttle(lazyLoad, 200));
window.addEventListener('load', lazyLoad);
判断逻辑用的是 getBoundingClientRect() 拿到的元素相对于视口的位置,rect.top 表示元素上边缘到视口顶部的距离。当元素顶部在视口内,也就是 0 <= rect.top <= viewportHeight 时,说明图片至少露出一部分了,这时才把真实地址交给 src。
这里有一个所有用这套方案的人都踩过的坑:如果图片在页面底部,而用户快速往下滚动,只判断 rect.top 会漏掉一些图片。因为一次滚动可能跨过几百像素,某些图片从"视口下方"直接越过了视口,到达"视口上方"。稳妥的判断条件要加上 rect.bottom >= 0:
javascript复制if (rect.top < viewportHeight && rect.bottom > 0) {
// 图片在可视区域内
}
这个判断的含义是:图片底部还没完全超出视口顶部,同时图片顶部还没完全越过视口底部。两个条件同时满足,意味着图片和视口存在交集。
2.2 这套方案真正的痛点在哪里
滚动监听方案能工作,但我在真实项目中越来越不想用它,原因有两个。
一是性能开销。每滚动一次,浏览器就要对页面上所有带 data-src 的图片执行一次 getBoundingClientRect()。这个方法本身会强制浏览器同步计算布局(reflow),如果页面上有 50 张图、动画还有别的样式变化,滚动时就会出现肉眼可感知的卡顿。加 throttle 只能降低触发频率,不能消除 reflow 的开销。
二是时机判定不精确。滚动监听捕获的是"滚动这个动作发生之后的瞬间",如果用户在首屏加载完成前就快速滚动到页面中间,图片可能在滚动结束后才被赋值,导致短暂的空白区域。这个空白虽然不是致命的,但很影响体验。
所以现在我不太推荐从零手写滚动监听方案了,除非是极其简单的静态页面。现代浏览器提供了更优雅的工具:IntersectionObserver。
