图像懒加载实现指南 - loading=lazy 与 IntersectionObserver 的选择
为什么需要图像懒加载
懒加载延迟加载视口外的图像,仅在用户即将看到时才开始加载。这显著减少初始页面加载时间和带宽消耗。
收益:
- 减少初始加载量:首屏外的图像不在页面加载时请求,加速 DOMContentLoaded 和 LCP
- 节省带宽:用户可能不会滚动到页面底部,未查看的图像无需加载
- 减少并发请求:避免数十张图像同时请求导致的带宽竞争
注意:首屏图像(Above the fold)不应懒加载,否则会延迟 LCP。懒加载仅用于首屏以下的图像。
图像懒加载 (Lazy Loading) 的定义很直接:等到图像接近用户视口时才开始下载。假设一个页面内含 20 张图像,初次渲染只取首屏的 2〜3 张,其余随滚动依次补齐——图像密集的页面据此可把初始请求数削减 70〜80%。省下的带宽会回流到 HTML、CSS、JavaScript 等关键资源上,Time to Interactive (TTI) 因此明显提前。有实测案例显示,同样是 20 张图像的页面,引入懒加载后初始加载时间从 3.2 秒降到 1.4 秒。反过来说,若把该懒加载的和不该懒加载的判断错了,Core Web Vitals 分数会下滑,进而波及 SEO。
原生 loading=lazy - 最简单的实现
HTML 原生的 loading="lazy" 属性是最简单的懒加载实现,无需 JavaScript。
用法:<img src="photo.jpg" loading="lazy" alt="描述" width="800" height="600">
浏览器行为:
- 浏览器自动判断图像是否在视口附近,决定何时开始加载
- 具体的「提前加载距离」由浏览器决定(Chrome 约 1250px-2500px,取决于网络速度)
- 不可自定义触发距离
优势:
- 零 JavaScript,最简单的实现
- 浏览器原生优化,性能最好
- 自动处理各种边缘情况(打印、搜索引擎爬虫等)
局限:
- 无法自定义触发距离(rootMargin)
- 无法添加加载动画或占位效果
- 无法实现渐进式加载(LQIP → 全图)
- 必须设置 width/height 防止 CLS
截至 2024 年,主流浏览器已全部支持该属性,因此它是无需 JavaScript 就能实现懒加载的最简路径。写法只有一行:<img src="photo.jpg" loading="lazy" alt="照片" width="800" height="600">。loading 属性共有 3 个取值,其中 eager 表示立即开始下载,也就是浏览器的默认行为。触发距离由浏览器自行决定:高速连接下大致提前 1,250 px 开始加载,换成 3G 级别的低速连接则会提前到约 2,500 px,以便留出下载时间。
IntersectionObserver - 灵活的自定义方案
IntersectionObserver API 提供高性能的元素可见性检测,可实现完全自定义的懒加载逻辑。
基本实现:
- 图像初始不设置 src,将真实 URL 存在
data-src属性 - 创建 IntersectionObserver 监听图像元素
- 元素进入视口时,将
data-src赋值给src触发加载 - 加载完成后取消观察该元素
自定义选项:
rootMargin: "200px":提前 200px 开始加载(预加载缓冲区)threshold: 0:元素刚进入视口即触发(默认)- 可配合加载动画:src 赋值前显示骨架屏,加载完成后淡入
优势:完全可控的触发时机、可添加加载动画、支持 LQIP 渐进加载、可实现优先级控制。
基本实现模式如下:先以 const observer = new IntersectionObserver(callback, { rootMargin: '200px 0px' }) 建立观察器;回调收到 entries 后用 entries.forEach 逐个处理,对每个 entry 判断 entry.isIntersecting,成立时把 entry.target 取为图像元素,将 img.dataset.src 写入 img.src 触发下载,最后调用 observer.unobserve 解除对该元素的观察以免重复触发。阈值方面,threshold: 0.1 的含义是图像有 10% 进入视口时才发火,适合需要「确实被看到」再计数的场景。
两种方案的对比与选择
根据项目需求选择合适的懒加载方案,或组合使用。
对比:
- 实现复杂度:loading=lazy(零代码)vs IO(需要 JS 实现)
- 可定制性:loading=lazy(不可定制)vs IO(完全可定制)
- 加载动画:loading=lazy(无)vs IO(可自定义)
- 触发距离:loading=lazy(浏览器决定)vs IO(rootMargin 自定义)
- 兼容性:两者都有良好的现代浏览器支持
推荐策略:
- 简单博客/文档站:直接使用 loading=lazy,零成本获得懒加载
- 图片密集型应用:IntersectionObserver + LQIP + 加载动画
- 电商产品列表:IO + 优先级控制(首屏产品优先加载)
占位方案的取舍也值得单列。主色占位是在构建阶段解析图像提取主色,再以 style="background-color: #3a7bd5" 这样的内联样式铺底;LQIP (Low Quality Image Placeholder) 则是把宽度仅 20〜40px 的极小图模糊后顶上,经 Base64 内联进 HTML 便可零额外请求地给出预览;BlurHash 更进一步,把模糊预览压缩成 20〜30 个字符的字符串,比 LQIP 更轻,代价是需借助 Canvas API 解码绘制。从占位图切到实图时建议加淡入:把 opacity 与 transition 组合,在图像的 onload 事件里改为 opacity: 1 是最常见的做法。不过动画 duration 要压在 200〜300ms 以内,否则反而给人「还在等」的印象。
防止布局偏移(CLS)的最佳实践
懒加载图像如果没有预留空间,加载完成时会导致页面布局偏移(CLS),严重影响用户体验和 Core Web Vitals 分数。
解决方案:
- 明确 width/height:始终在 img 标签上设置宽高属性,浏览器据此预留空间
- aspect-ratio CSS:
img { aspect-ratio: 16/9; width: 100%; height: auto; } - 容器占位:用固定宽高比的容器包裹图像,图像用 object-fit 填充
LQIP(低质量占位图):
- 内联极小的模糊缩略图(如 20x15px 的 Base64)作为占位
- 全图加载完成后替换占位图,使用 CSS 过渡实现平滑切换
- 占位图体积 < 1KB,可内联在 HTML 中无需额外请求
需要再三强调的是,懒加载最大的坑就是把它加到了 LCP (Largest Contentful Paint) 的目标图像上。Google 的 Lighthouse 会就「LCP 图像被设置了 lazy loading」给出警告,而真实用户的字段数据 (CrUX) 同样确认了这一负面影响并非纸面推论。判断边界可以这样划:需要滚动才可见的图像、正文中段的插图、页脚附近与侧栏的图像都该懒加载;而首图、首屏 logo、Above the fold 的商品图,以及作为大面积背景使用的图像都不该懒加载。对 LCP 图像的正解是改用 fetchpriority="high",并额外用 <link rel="preload" as="image"> 指示提前取回,这样能把 LCP 的改善拉到最大。
框架集成与高级模式
主流前端框架都提供了图像懒加载的内置支持或推荐方案。
Next.js Image:
<Image>组件默认启用懒加载- 自动生成 srcset 和 sizes
- 内置 blur placeholder 支持
- 首屏图像设置
priority属性跳过懒加载
React:
- 自定义 Hook:
useIntersectionObserver封装懒加载逻辑 - 库:
react-lazy-load-image-component
Vue:
v-lazy指令(vue-lazyload 库)- Nuxt Image 组件内置懒加载
高级模式:
- 优先级队列:根据图像在视口中的位置排序加载优先级
- 网络感知:慢速网络时加载更小的图像或降低预加载距离
- 空闲时预加载:使用
requestIdleCallback在空闲时预加载即将可见的图像
框架层面,Next.js 的 next/image 组件默认就开启了懒加载;把 priority 置为 true 即关闭懒加载,同时自动补上 fetchpriority="high" 与 preload,正好对应上一节的 LCP 处理。在 React 里自行实现时,惯用做法是用 useRef 与 useEffect 写一个自定义 Hook 来托管 IntersectionObserver,并务必在组件卸载时调用 observer.disconnect 释放观察器,否则容易留下内存泄漏。