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。
2.3 IntersectionObserver:让浏览器替你"看"视口
IntersectionObserver 是浏览器原生提供的异步观察 API,它会在被观察元素与根元素(默认是视口)的交集变化时触发回调,而且是浏览器内部优化的,不会像 getBoundingClientRect() 那样强制同步布局。用它对图片做懒加载非常干净:
javascript复制const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img); // 加载完就停止观察
}
});
});
document.querySelectorAll('img[data-src]').forEach((img) => {
observer.observe(img);
});
这段代码的核心是 entry.isIntersecting,直接告诉你这个元素是否与视口相交。相交时把 data-src 赋给 src,并立刻 unobserve,避免后续重复回调。相比滚动监听方案,IntersectionObserver 不需要计算任何坐标,不需要节流,也不会产生布局抖动。
实际使用时有几个参数值得调:
javascript复制const observer = new IntersectionObserver(callback, {
root: null, // 根元素,null 代表视口
rootMargin: '200px 0px', // 扩大视口范围,提前 200px 开始加载
threshold: 0.1 // 元素进入视口 10% 时才触发
});
rootMargin 是我最常动的参数。设置为正数相当于把视口"扩大",图片在进入视口前 200px 就开始加载。这样做的目的是给图片加载留出缓冲时间——用户滚动到那里时,图片已经下载完或接近下载完,直接显示出来,不会有"边滚边加载"的白屏感。阈值 threshold 一般用 0 或 0.1 就够,除非你要做滚动动画,否则不需要高阈值。
关于浏览器兼容性,IntersectionObserver 已经非常普及了,主流浏览器都支持。但要兼容非常老旧的浏览器,可以引入 intersection-observer-polyfill,或者退回滚动监听方案。我个人的判断是:新项目直接上 IntersectionObserver,老项目如果用户群还在用老浏览器,再评估是否值得垫 polyfill。
3. 生产级落地:占位、解码与响应式图片的完整配置
3.1 data-src 和 data-srcset:不要只替换一个 src
把 src 换成 data-src 是最基础的写法,但生产环境里图片往往不止一个地址。响应式图片通常用 srcset 配合 sizes 属性,让浏览器根据屏幕宽度选择最合适的图片。如果只处理 src,srcset 里的地址在懒加载完成后才会被浏览器解析,但如果你只是简单地把 data-srcset 赋给 srcset,还是会遇到一个坑:srcset 的解析时机跟 src 同步,两者必须同时赋值。
我推荐的做法是:
html复制<img
data-src="https://cdn.example.com/img/photo-800.jpg"
data-srcset="https://cdn.example.com/img/photo-400.jpg 400w, https://cdn.example.com/img/photo-800.jpg 800w, https://cdn.example.com/img/photo-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw, 800px"
alt="示例图片"
/>
javascript复制if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
img.src = img.dataset.src;
observer.unobserve(img);
}
顺序上要先赋值 srcset 再赋值 src,因为浏览器解析 srcset 并确定最终图片后,src 是兜底方案。如果先赋 src,某些浏览器会立刻请求 src 指向的图片,然后 srcset 赋值后又可能触发一次新的请求,产生两个请求,浪费流量。
还有一个容易被忽略的小技巧:真正生产环境的 src 不应为空。我给所有懒加载图片的 src 填的是一个极小的、经过压缩的占位图,这个占位图可以是 1×1 像素的透明 GIF(几十字节),也可以是用 CSS 渐变生成的背景。src 有值的话,爬虫和禁用 JavaScript 的场景还能看到一张模糊的占位,总比破图好。
3.2 占位策略:模糊占位图、背景色还是骨架屏
懒加载有一个不可避免的副作用:图片在真正加载完成前,占地空间是空的。如果不处理,布局会收缩,加载完成后突然撑开,用户会看到页面内容上下跳动,这就是 CLS 指标爆掉的来源。
解决跳动的第一步是给图片预留准确的宽高。最直接的办法是 HTML 里写死 width 和 height 属性,或者用 CSS 的 aspect-ratio 属性:
css复制.lazy-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
aspect-ratio 让浏览器在图片加载之前就知道这个元素要占多大空间,布局从一开始就是稳定的,这是个非常关键的细节。
第二步是决定图片加载前显示什么。我总结过三种常见方案:
| 方案 | 实现成本 | 视觉效果 | 适用场景 |
|---|---|---|---|
| 纯背景色 | 低,CSS 一行 | 一般,略显生硬 | 列表密集、单张图面积小的场景 |
| 模糊占位图(LQIP) | 中,需生成 tiny 图 | 好,接近真实预览 | 运营活动页、内容流 |
| 骨架屏 | 中高,需模板 | 好,现代感强 | 依赖图片为主的内容页 |
模糊占位图(Low Quality Image Placeholder)的做法是:服务端在生成图片时额外输出一份宽度只有 20px 的迷你版,体积通常几百字节到几 KB。前端用这张小图做背景,加一层 CSS filter: blur(20px) 模糊处理,等大图加载完成后再切换。这种方案在 Pinterest 和 Medium 上很流行,视觉过渡自然,用户体验最好。实现时可以用图片加载事件来切换:
javascript复制img.addEventListener('load', () => {
img.classList.add('loaded');
});
css复制.lazy-image {
filter: blur(20px);
transition: filter 0.4s ease;
}
.lazy-image.loaded {
filter: blur(0);
}
如果你不打算做 LQIP,最低成本的做法是让懒加载图片的背景色接近图片主色调(服务端可以输出 average_color 字段),视觉上不会太突兀。我做过一次统计,加了 LQIP 后页面 CLS 从 0.12 降到了 0,LCP 虽然变化不大,但用户主观反馈"页面流畅了很多"。
3.3 图片解码与内存:decode() 与 loading="lazy" 的配合
图片加载包括两个阶段:下载和解码。下载走网络,解码发生在主线程上。一张特别大的 JPEG,解码可能要几十毫秒甚至上百毫秒,如果这个操作发生在图片进入视口的那一刻,用户就会感到滚动"卡一下"。现代浏览器为这个问题提供了一个异步解码 API:img.decode()。
javascript复制const img = new Image();
img.src = bigImageUrl;
img.decode().then(() => {
target.appendChild(img);
}).catch(() => {
target.appendChild(img); // 解码失败也要兜底插入
});
在懒加载场景里,我通常这样整合:当 IntersectionObserver 触发时,先设置 src,但等到 decode() 完成后再把图片放到可见的容器中。这样解码过程不会阻塞主线程,用户看到图片时它已经准备就绪了。
另一个需要掌握的武器是 HTML 原生属性 loading="lazy"。这个属性从 Chrome 76 开始支持,现在主流浏览器都已经支持,它是浏览器层面的原生懒加载:
html复制<img src="photo.jpg" loading="lazy" width="800" height="600" alt="示例" />
原生懒加载的好处是零成本、不需要写一行 JavaScript。但它的行为细节不完全受你控制:浏览器决定加载时机,无法自定义 rootMargin 提前量;而且它只在图片与视口一定距离内才触发,这个距离由浏览器内部算法决定。所以我的建议是:能用原生 loading="lazy" 的静态页面直接用;对加载时机有精确控制需求(比如首页 banner 希望提前加载、轮播图要预加载)的项目,用 IntersectionObserver 自己控制。
我还见过一个混合方案的误区:给图片同时加 loading="lazy" 和 IntersectionObserver 懒加载。这两个逻辑会互相干扰,浏览器和 JS 各管各的,可能出现图片被浏览器提前加载了但 JS 仍然在观察的情况,浪费资源。二者选其一即可。
4. 不止 img:CSS 背景图、视频与 iframe 的懒加载改造
4.1 CSS 背景图的懒加载没有捷径
很多开发者以为把 img 改成 CSS 背景图就不会被浏览器自动加载,这是个误解。CSS 里的 background-image 一旦应用到元素上,并且元素在渲染树中可见,浏览器就会去请求这张图。换句话说,不能仅靠把图片藏进 CSS 就实现懒加载。真正可行的思路有两种。
第一种是拆分 class 控制法。初始状态下不写 background-image,等元素进入视口时再加一个 class:
css复制.bg-block {
background-color: #f0f0f0;
}
.bg-block.is-visible {
background-image: url('https://cdn.example.com/big-bg.jpg');
}
javascript复制observer.observe(target); // 触发后给 target 加 is-visible
这种做法实现成本低,但有个局限:无法做细粒度的加载状态管理,比如你不知道背景图具体加载完成没有,因为 CSS 背景图没有像 img 那样的 load 事件。
第二种是预加载到内存再赋值。用 Image 对象提前把图片拉下来,等缓存好后再把 URL 赋给 background-image,因为浏览器已经缓存了这张图,赋值时不会产生新的网络请求,执行速度极快:
javascript复制const img = new Image();
img.src = bgUrl;
img.onload = () => {
element.style.backgroundImage = `url(${bgUrl})`;
};
我在实际项目里偏好第二种,它更接近"图片学会等你看到再出场"的本意:资源真正被下载下来,并且确保下载完成后才展示,不会出现半张图加载的状态。代价是多写一点 JS 逻辑,但对于运营页的大 banner 背景图来说,这个代价完全值得。
4.2 视频、iframe 与字体的懒加载:容易被忽略的隐性资源
图片之外,视频是另一个资源大户。<video> 标签有一个 preload 属性,默认值在移动端是 metadata,但桌面端可能是 auto,这时浏览器会预加载视频数据,消耗大量带宽。推荐的懒加载方法是完全不设置 src,只放 data-src,等视频进入视口时再赋值并调用 video.play():
html复制<video controls preload="none" data-src="movie.mp4" poster="poster.jpg"></video>
javascript复制if (entry.isIntersecting) {
const video = entry.target;
video.src = video.dataset.src;
video.load();
observer.unobserve(video);
}
preload="none" 让浏览器一开始不下载视频内容,只有设置 src 且调用 load() 后才开始拉取数据。poster 图是视频未加载时的封面,能提供视觉占位,建议一定要设。
iframe 懒加载同样可以使用 loading="lazy" 原生属性,也可以手动控制:把真正的 URL 放在 data-src 里,需要时再设置给 src。这在嵌入地图、视频播放器时很常用。字体文件也是隐性资源,CSS 里 @font-face 指向的字体文件在某些浏览器里会在页面加载时统一预取,可以用 font-display: swap 控制加载策略,但这属于字体优化范畴,使用场景跟图片懒加载不同,这里就不展开细说了。
5. 组件库场景:从 el-table 的树形懒加载到 toggleRowExpansion
5.1 树形表格为什么要"按需加载"
懒加载这个词在前端图片之外还有一个高频场景:表格的树形数据加载。很多做后台管理系统的人会在实际项目中遇到 Element Plus 的 el-table 的树形数据功能。默认情况下,如果你给 el-table 传入带有 children 字段的数据,表格会一次性把整棵树渲染出来。数据量小看不出问题,一旦出现成千上万行,DOM 节点数量暴增,页面交互立刻变得卡顿。
这种情况下的解决方案就是"树形懒加载":先只渲染根节点,当用户点击展开按钮时,再去请求该节点的子节点数据。Element Plus 的 el-table 对树形懒加载提供了内置支持。
5.2 load 函数、row-key 与展开状态管理的完整用法
在使用 Element Plus 的表格树形懒加载时,核心配置有三个:row-key、:load 和 lazy 属性。下面是一个典型的最小配置:
vue复制<template>
<el-table
:data="tableData"
row-key="id"
lazy
:load="loadNode"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<el-table-column prop="name" label="名称" />
<el-table-column prop="type" label="类型" />
</el-table>
</template>
<script setup>
import { ref } from 'vue';
const tableData = ref([
{ id: 1, name: '一级节点A', hasChildren: true },
{ id: 2, name: '一级节点B', hasChildren: false },
]);
const loadNode = (row, treeNode, resolve) => {
// 模拟接口请求
setTimeout(() => {
const children = [
{ id: `${row.id}-1`, name: `${row.name} 的子节点1`, hasChildren: false },
{ id: `${row.id}-2`, name: `${row.name} 的子节点2`, hasChildren: false },
];
resolve(children);
}, 300);
};
</script>
这里有几个关键细节需要解释。
第一,row-key 必须设置且值唯一。树形懒加载的展开状态、子节点缓存都依赖这个字段来标识每一行。如果不设或者值不唯一,表格内部会把行数据搞混,出现展开 A 节点却显示了 B 节点子数据的诡异问题。
第二,hasChildren 字段(你可以在 tree-props 里改名字)告诉表格当前行是否还有子节点。如果这一行返回 hasChildren: false,表格就不会显示展开箭头。这个字段必须在初始数据里就存在,否则即使是懒加载模式,表格也不知道该不该渲染展开按钮。如果初始数据里没有这个字段,所有行都会被当作"没有子节点",懒加载根本触发不了——这是初学者最容易踩的坑。
第三,load 函数接收三个参数:row 是当前行的数据,treeNode 是内部节点对象,resolve 是回调函数。请求完成后必须调用 resolve(children) 把子节点数据交给表格。这里要注意的是,resolve 只能调用一次,如果调用了两次,控制台会报错而且后一次的数据会被忽略。
数据的接口设计也需要配套。我在实际项目中通常这样约定:接口返回子节点列表,同时要求每条数据必须包含 hasChildren 字段,这个字段由后端根据该节点下是否还有子级来决定。如果没有这个约定,前端就得在 load 函数里额外查询一次"该节点有没有子节点",多一次请求不说,逻辑也绕。
5.3 toggleRowExpansion 的实战坑与排查链路
有一次我在做一个"节点管理"页面,产品要求:选中某个节点后,点击外部按钮自动展开它的所有父级,并高亮该节点。这个需求就涉及 toggleRowExpansion 方法。Element Plus 的表格实例暴露了 toggleRowExpansion(row, expanded) 方法,用于手动控制某一行的展开状态。听起来很简单,但在树形懒加载模式下,这个方法的背后藏着一个典型的坑。
我的排查链路是这样的:
第一步,我先在按钮点击事件里直接调用:
javascript复制const expandRow = (row) => {
tableRef.value.toggleRowExpansion(row, true);
};
结果表格毫无反应。我先是怀疑 row-key 没配对,检查之后发现主键是 id,但接口返回的数据用的是 nodeId,row-key="id" 导致表格内部找不到对应节点。修改为 row-key="nodeId" 后仍然没有反应。
第二步,我打印了 toggleRowExpansion 的返回值,发现它返回的是 Promise。这让我意识到它可能是异步操作,展开动作没有立即完成。于是改成:
javascript复制const expandRow = async (row) => {
await tableRef.value.toggleRowExpansion(row, true);
console.log('expanded');
};
但日志确实输出了,UI 依然没有展开。我进一步怀疑是目标行的父级没有被加载出来。树形懒加载和普通树形数据不一样:懒加载模式下,某个行必须是"已加载的节点",toggleRowExpansion 才能对它生效。如果它的父级尚未展开,子节点数据根本不存在于表格内部,直接从数据源里找子节点行去展开,当然不会有任何效果。
第三步,我打印了表格的 store.states.treeData(Element Plus 内部状态),发现只有根节点被加载了。目标行位于第三层,而它的父节点还没展开,所以第三层节点在表格内部根本不存在,toggleRowExpansion(row) 操作的对象就是一个"幽灵节点"。
结论是:在懒加载模式下,要展开某个深层节点,必须保证它所有的祖先节点都已经完成加载。正确的做法是逐个向上递归展开祖先节点,每次等 load 完成后再展开下一层。我的实现思路是写一个递归函数,从目标节点的父级链顶层开始,逐层执行 toggleRowExpansion,中间用 nextTick() 或 await 等待 DOM 更新:
javascript复制const expandAncestorsAndSelf = async (row, parentChain) => {
for (let i = 0; i < parentChain.length; i++) {
const parent = parentChain[i];
if (!tableRef.value.store.states.treeData[parent.nodeId]) {
// 父节点尚未加载,需要先触发 load
tableRef.value.store.states.lazyTreeNodeMap.set(parent.nodeId, { loaded: false });
}
tableRef.value.toggleRowExpansion(parent, true);
await nextTick();
}
tableRef.value.toggleRowExpansion(row, true);
};
这里我补充一个关键认识:toggleRowExpansion 能准确工作,依赖两个前提——目标行已在表格的节点映射中存在,且该行有懒加载的子节点数据。所以在编写这类功能时,最好的策略是"先确保加载链路完整,再执行展开动作"。
这个问题排查到最后,我从 Element Plus 源码里确认了:toggleRowExpansion 本身只负责切换 UI 的 expanded 状态,它不会代替 load 函数去加载子节点。如果你强行调用它展开一个尚未加载的节点,唯一的后果就是表格里出现一个"展开图标状态与数据状态不一致"的 bug。
6. 收益怎么量化,以及哪些场景不该用懒加载
6.1 用 Performance 面板量化懒加载的真实收益
"优化了什么"不能只靠感觉,要有数据。我通常用浏览器 DevTools 的 Performance 面板和 Network 面板结合来量化。
操作步骤是:打开无痕窗口,在 Network 面板里把网络限速设为 Fast 3G,CPU 降频 4 倍(这些设置在 DevTools 的 Performance 面板上方或 Network 面板的 Throttling 里),然后分别记录开启和关闭懒加载两种情况下的指标:
- 首屏请求数:Network 面板底部会显示总共发起了多少个请求。启用懒加载后,这个数字会明显下降,因为视口外的图片请求被延后。
- 总传输体积:看 " transferred" 列的总和。我做过一个真实项目,优化前总资源体积 5.8MB,优化后首屏 1.9MB,因为页面底部 20 张图片没在首屏加载。
- LCP:Performance 面板里找到 Largest Contentful Paint 标记,优化前是 4.2s,优化后 2.1s。
- DOMContentLoaded:懒加载对 DCL 的影响不明显,但如果图片请求阻塞了连接池,DCL 也会被拖慢。
我还习惯在代码里埋点做更精确的上报:所有图片的 load 事件打点,统计 "首屏内图片加载总耗时" 和 "页面总图片加载总耗时"。这两个指标能让人清楚地看到,懒加载不是把加载时间凭空消除了,而是把一部分加载任务从"首屏关键路径"挪到了"用户滚动后的空闲时间"。
6.2 三种不适合懒加载的场景与原因
懒加载不是万能的,我见过不少项目不分青红皂白给所有图片都加懒加载,效果反而更差。至少有以下三类场景不适合。
第一类是首屏图。懒加载的本质是"延迟",但首屏图片是用户打开页面立刻能看到的内容,延迟它没有意义。首屏图的优化重点应该是压缩、CDN、预加载,而不是懒加载。如果一张首屏 banner 用了 IntersectionObserver 懒加载,浏览器在初始渲染时会先占位再加载,反而会造成首屏图片晚出现。
第二类是 SEO 关键图。搜索引擎爬虫在执行 JavaScript 方面有进步,但和现代浏览器仍有差距。如果一个图片对搜索排名至关重要(比如商品主图),把它放在 data-src 里靠 JS 驱动加载,爬虫可能根本拿不到图片内容。这种情况下应该用原生 loading="lazy" 或者干脆不懒加载。
第三类是"即将出现"的图片。有些图片虽然在视口外,但用户几乎必然会在几秒内看到它,比如轮播图的第二张、悬停预览图、翻页下一页的前几张。这部分图片如果懒加载,用户交互时会出现明显等待。更合理的做法是预加载:在页面空闲时主动请求这些图片。
判断是否该用懒加载有一个简单的标准:这张图片出现在视口内的概率有多大?如果概率很低(长列表、页面底部内容),懒加载收益明显;如果概率很高(首屏轮播、商品详情主图区域),懒加载的负面效果大于收益。
6.3 选型建议:原生属性、自研还是组件库
综合前面的内容,我对不同场景的选型建议如下:
- 静态展示页、博客:直接用
loading="lazy"原生属性,零成本,浏览器兼容性已经足够好。 - 需要精确控制的运营活动页:用 IntersectionObserver 自研方案,配合 rootMargin 设置提前量,可以精确控制图片加载时机。
- 复杂的后台管理系统:优先使用组件库自带能力,比如 Element Plus 的树形懒加载。同时注意
row-key唯一性、hasChildren字段、load函数只调用一次resolve。 - 数据流驱动的图片列表(电商、资讯流):考虑使用 VueUse 的
useIntersectionObserver或者现成的懒加载指令封装,避免每个组件重复写观察者逻辑。
我在一个中型项目里沉淀过一个 v-lazy 指令,封装了 IntersectionObserver 和 loading 状态管理,团队使用成本很低。如果你也是用 Vue 3,可以参考 VueUse 的 v-lazy 指令实现,核心代码比自己写滚动监听干净得多。
最后分享一个我在多次优化项目中得出的体会:懒加载的最终目标不是让 Network 面板里的请求数量变少,而是让"用户感知到的等待时间"变短。对于图片进入视口才加载的体验,尽量让它在用户看到前就准备好;对于用户永远不会看到的图片,让浏览器永远不会去请求它。把握住这两个方向,懒加载的思路就不会跑偏。
