基于JavaScript的图片懒加载与无限滚动小项目实例
通过一个完整的原生JavaScript小项目实例,详细讲解如何使用现代IntersectionObserver API实现高性能的图片懒加载与无限滚动,包含完整代码、原理解析、步骤说明与常见问题排查。
在一个包含大量图片的电商产品流或社交媒体动态页中,若页面加载时一次性请求数十甚至上百张大图,极易导致主线程被网络下载与解码阻塞,产生首屏白屏、卡顿与流量浪费。解决这一体验问题的核心路径在于两项技术的组合应用:一是图片懒加载(Lazy Loading),使图片仅在即将进入屏幕可视区域时才发起实际网络加载;二是无限滚动(Infinite Scroll),在用户滚动接近页面底部时异步拉取下一批列表数据并动态追加到DOM中。本文通过一个精简、独立的原生JavaScript小项目,演示如何基于现代浏览器提供的 IntersectionObserver(交叉观察器)API,以轻量、零依赖的方式协同构建这两项功能。
一、项目核心机制与选型分析
传统的滚动监听通常在 window.addEventListener('scroll', handler) 中通过调用 getBoundingClientRect() 计算目标元素距顶部的距离。这种方式存在明显的性能缺陷:用户滚动时 scroll 事件会在短时间内连续数十次触发,频繁调用几何计算会导致浏览器强制同步重排(Reflow),在低端设备上易出现掉帧。
现代前端开发中推荐使用 IntersectionObserver。该接口脱离了主线程高频事件轮询,将目标元素与根容器视口的相交检测交给底层渲染引擎异步计算,仅在相交状态跨越预设阈值时才通过回调通知脚本。本项目需要建立两个观察器对象各司其职:
- 图片观察器(imageObserver):观察所有带有
data-src的图片元素。当图片接近视口(可设置提前量)时,将真实图片地址写入src,完成加载后立即解除对其观察,释放资源。 - 触底观察器(infiniteScrollObserver):观察列表底部的占位节点(通常称为“哨兵节点”或
#sentinel)。当哨兵元素暴露在视口中时,说明用户已滚动至底部,触发获取下一批数据逻辑。
二、基础骨架与样式准备
为了让实例可独立运行,我们需要先构建HTML文档骨架。核心容器包括两个部分:用于承载图片卡片的列表容器 #gallery,以及位于列表最末端的哨兵容器 #sentinel。
在样式设计上,未加载的图片必须设置固定宽高比或骨架背景占位。若图片没有宽高占位,所有图片的初始高度均为0,会导致大量尚未加载的卡片聚集在视口内,引起观察器误判并瞬间发起全部图片的下载请求,使懒加载彻底失效。
<!-- HTML 结构 -->
<div class="container">
<div id="gallery" class="gallery-grid"></div>
<!-- 触底哨兵元素 -->
<div id="sentinel" class="loading-status">
<span class="spinner"></span> 正在加载精彩图片...
</div>
</div>
配套的基础CSS样式如下:
/* CSS 样式设计 */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
padding: 16px 0;
}
.img-card {
position: relative;
background-color: #f1f5f9;
border-radius: 8px;
overflow: hidden;
/* 关键:使用固定的高宽比占位,防止未加载时高度塌陷引发批量触发 */
aspect-ratio: 4 / 3;
display: flex;
align-items: center;
justify-content: center;
}
.lazy-img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease-in-out;
}
/* 图片真实加载成功后追加淡入类 */
.lazy-img.loaded {
opacity: 1;
}
.loading-status {
text-align: center;
padding: 24px;
color: #64748b;
font-size: 14px;
}
三、核心逻辑实现步骤
步骤一:实现图片懒加载观察器
创建 IntersectionObserver 实例并配置参数。关键参数 rootMargin: "200px 0px" 表示视口向外扩张200像素的虚拟警戒线。图片尚未完全进入屏幕、仍在视口下方200像素时,即提前开始网络下载,当用户滚动到该位置时图片往往已经加载完毕,从而消除空白闪烁感。
// 1. 创建图片懒加载观察器
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
// 检查目标是否与视口相交
if (entry.isIntersecting) {
const img = entry.target;
const realSrc = img.dataset.src;
if (realSrc) {
// 替换真实图片地址发起请求
img.src = realSrc;
img.addEventListener('load', () => {
img.classList.add('loaded');
img.removeAttribute('data-src');
}, { once: true });
}
// 关键:已触发的图片必须立即取消观察,避免重复触发与内存占用
observer.unobserve(img);
}
});
}, {
root: null, // 默认以浏览器可视窗口作为视口
rootMargin: '200px 0px', // 提前 200px 预加载
threshold: 0.01 // 出现 1% 边缘即触发
});
步骤二:实现触底无线滚动与数据拉取
无限滚动的关键在于请求锁(isLoading)与终点判定(hasMore)。当哨兵元素相交时,若当前处于请求中或已无更多数据,直接返回,避免在网络延迟期间因用户轻微晃动页面引发多次重复拉取。
// 2. 状态变量定义
let currentPage = 1;
const pageSize = 8;
const maxPages = 5; // 模拟总共5页数据
let isLoading = false;
let hasMore = true;
// 模拟异步数据请求
function fetchImages(page, limit) {
return new Promise(resolve => {
setTimeout(() => {
// 构造模拟图片数据
const list = [];
for (let i = 1; i <= limit; i++) {
const id = (page - 1) * limit + i;
list.push({
id,
title: `图集作品 #${id}`,
// 使用稳定可访问的图片生成服务(或项目中实际接口)
url: `https://picsum.photos/id/${(id * 7) % 100 + 10}/600/450`
});
}
resolve(list);
}, 600); // 模拟600ms网络延迟
});
}
// 渲染图片卡片到DOM并纳入懒加载观察
function renderGalleryItems(items) {
const gallery = document.getElementById('gallery');
const fragment = document.createDocumentFragment();
items.forEach(item => {
const card = document.createElement('div');
card.className = 'img-card';
const img = document.createElement('img');
img.className = 'lazy-img';
// 关键:初始时不设置 src,仅设置 data-src
img.dataset.src = item.url;
img.alt = item.title;
card.appendChild(img);
fragment.appendChild(card);
// 将新创建的未加载图片节点挂入图片观察器
imageObserver.observe(img);
});
gallery.appendChild(fragment);
}
步骤三:配置触底哨兵观察器
对 #sentinel 元素建立专门的观察器。当用户向下滚动,页面底部的哨兵元素暴露在视口中时,自动调用加载逻辑,并在拉取完毕后自增页码;若达到上限,则注销哨兵并展示“已加载全部内容”提示。
// 3. 触底哨兵观察器
const sentinel = document.getElementById('sentinel');
async function loadNextBatch() {
if (isLoading || !hasMore) return;
isLoading = true;
try {
const data = await fetchImages(currentPage, pageSize);
renderGalleryItems(data);
currentPage++;
if (currentPage > maxPages) {
hasMore = false;
sentinel.innerHTML = '已加载全部精彩图片';
scrollObserver.unobserve(sentinel); // 彻底注销触底观察器
}
} catch (err) {
console.error('加载图片列表异常:', err);
sentinel.innerHTML = '加载失败,请重试';
} finally {
isLoading = false;
}
}
const scrollObserver = new IntersectionObserver((entries) => {
const [entry] = entries;
if (entry.isIntersecting) {
loadNextBatch();
}
}, {
rootMargin: '100px 0px' // 距离底部100px时提前请求下一页
});
// 启动监听哨兵
scrollObserver.observe(sentinel);
#gallery 内没有任何卡片,哨兵节点 #sentinel 必然位于视口内。因此,只要调用 scrollObserver.observe(sentinel),观察器便会立即触发第一次首屏数据拉取,无需在脚本末尾手动额外调用一次加载函数。
四、验证效果与常见陷阱排查
在浏览器中运行上述代码时,可通过以下步骤进行验证:
- 打开 Chrome DevTools,切换至 Network(网络) 面板,勾选 Img 过滤项。
- 刷新页面,观察首屏网络请求:只有第一批展示出来的数张图片发起了图片格式请求,其余后续数据仅获取了URL列表,并未触发图片下载。
- 缓慢向下滚动,当视口靠近底部卡片时,新的图片请求按需在网络面板中依次出现;继续触底后,哨兵元素触发下一批卡片渲染。
- 快速拉动滚动条测试防抖与状态锁,确认不会因连续触底而发送重复页码的重复请求。
- 忘记在触发后取消观察(unobserve):如果不及时解绑,当用户向上或向下再次滚动使该图片反复进出视口时,回调函数将反复执行无意义的逻辑。
- 未对占位容器设置最小高度或固定比例:若容器在未加载图片时高度塌陷为0,初次追加的数十张图片瞬间全部位于视口内部,导致所有图片同时触发请求,懒加载彻底形同虚设。
- 忽略触底状态并发锁:异步拉取接口需要数百毫秒网络耗时,如果不设置
isLoading标识,在慢速网络或快速滑动时会导致哨兵反复相交,进而产生并发重复请求。
五、完整代码组织回顾
通过合理结合 IntersectionObserver 的两个独立实例,不仅可以实现关注点分离(图片加载与列表分页互不干扰),还能用不到100行原生代码构建出性能优异、结构清晰的瀑布流或图集加载组件。在实际生产项目中,开发者还可以根据实际业务需求,将数据源替换为真实的 RESTful 或 GraphQL 接口,并引入错误重试机制或更细腻的骨架动画,以进一步提升用户体验。
配置 Matter.js 睡眠机制以优化静止物体性能
2026-11-05 15:43:12
JavaScript console.log高级用法:用table、group和time优化调试输出
2026-11-05 08:04:56
JS变量中存储HTML代码时的特殊字符转义方法
2026-11-04 19:04:09
JS怎么安全转换带千分位或货币符号的字符串为数字
2026-11-04 11:54:14
推荐几款稳定的在线JS反混淆工具,支持Obfuscator.io解密
2026-11-04 08:16:21








