9.1 滚动交互体系
滚动交互是 Web 页面最常见的交互场景之一,覆盖资源加载、列表浏览、布局联动等多个业务场景。传统滚动交互依赖 scroll 事件配合位置计算实现,存在高频触发、性能开销大、实现繁琐等问题。结合 IntersectionObserver、CSS 粘性定位等原生能力,可以实现高性能、低代码量的滚动交互方案,同时配套滚动穿透等边界问题的处理,构建完整的滚动交互体系。
9.1.1 基于 IntersectionObserver 的图片懒加载
图片懒加载是最经典的滚动性能优化手段,核心思路是仅当图片进入可视区域时才加载真实图片,减少首屏加载流量与请求数,提升页面加载速度。
一、传统方案的痛点
早期基于scroll事件 +getBoundingClientRect实现的懒加载,存在明显缺陷:
scroll事件高频触发,即使加节流也存在持续的性能开销- 每次都需要手动计算元素位置,频繁触发强制同步布局
- 多图片场景逻辑繁琐,维护成本高
IntersectionObserver 异步监听元素交叉状态,不阻塞主线程,是当前懒加载的标准实现方案。
二、实现原理
- 图片默认不设置
src属性,真实地址存放在data-src自定义属性中,占位使用统一默认图或空白背景 - 创建 IntersectionObserver 实例,批量监听所有懒加载图片
- 图片进入可视区域时,将
data-src赋值给src属性,触发真实图片加载 - 加载完成后停止监听该图片,避免重复处理
三、完整实现代码
HTML 结构:
<img class="lazy-img">JS 实现:// 创建交叉观察者 const lazyObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { // 元素进入可视区域 if (entry.isIntersecting) { const img = entry.target; const realSrc = img.dataset.src; if (realSrc) { // 替换真实图片地址 img.src = realSrc; // 加载完成后添加状态标识 img.onload = () => { img.classList.add('loaded'); }; // 停止监听该元素 lazyObserver.unobserve(img); } } }); }, { rootMargin: '200px 0', // 提前200px预加载,减少用户等待 threshold: 0 }); // 批量监听所有懒加载图片 document.querySelectorAll('.lazy-img').forEach(img => { lazyObserver.observe(img); });
四、优化细节
- 提前预加载:通过
rootMargin设置正向边距,图片进入视口前提前加载,避免用户看到加载过程。 - 占位与过渡:默认设置占位图或统一背景色,加载完成后加淡入过渡,避免图片加载闪烁。
- 错误兜底:监听
onerror事件,加载失败时替换为默认失败图,避免裂图。 - 动态新增兼容:动态渲染的图片,插入 DOM 后手动调用
observe即可无缝接入懒加载。 - 降级兼容:低版本浏览器不支持 IntersectionObserver 时,可降级为一次性加载所有图片或引入 polyfill。
9.1.2 无限滚动实现方案
无限滚动(下拉加载更多)是长列表、信息流场景的标准交互模式,用户滚动到列表底部时自动加载下一页数据,无需分页跳转,浏览体验更流畅。
一、核心设计思路
在列表底部放置一个「哨兵元素」(sentinel),通过 IntersectionObserver 监听该哨兵元素的可见性:
- 哨兵进入视口 → 说明滚动到底部 → 触发下一页数据加载
- 加载过程中加状态锁,防止重复触发请求
- 所有数据加载完毕后,停止监听,显示「没有更多数据」
二、完整实现
HTML 结构:
<div class="list-container"> <div class="list-item">列表项1</div> <div class="list-item">列表项2</div> <!-- 底部哨兵元素 --> <div class="load-more-sentinel"></div> <div class="no-more-tip" style="display: none;">没有更多数据了</div> </div>
JS 实现:
let page = 1; let loading = false; // 加载锁,防止重复请求 let hasMore = true; const pageSize = 20; const sentinel = document.querySelector('.load-more-sentinel'); const infiniteObserver = new IntersectionObserver(async (entries) => { const entry = entries[0]; // 哨兵进入视口、有更多数据、不在加载中,三者同时满足才触发 if (entry.isIntersecting && hasMore && !loading) { loading = true; try { const res = await fetchListData({ page, pageSize }); // 渲染列表数据 renderList(res.list); if (res.list.length < pageSize) { // 数据不足一页,说明已加载全部 hasMore = false; document.querySelector('.no-more-tip').style.display = 'block'; infiniteObserver.unobserve(sentinel); } else { page++; } } catch (err) { console.error('加载失败', err); } finally { loading = false; } } }, { rootMargin: '100px 0' // 提前100px触发,减少用户等待 }); infiniteObserver.observe(sentinel);
三、注意事项
- 加载锁机制:必须设置
loading状态锁,避免网络较慢时滚动触发多次重复请求。 - 边界处理:首屏数据不足一屏时,自动加载下一页直到填满屏幕或无更多数据。
- 加载状态反馈:加载过程中显示加载动画,给用户明确的等待预期。
- 性能对比:相比传统 scroll 监听 + 位置计算,IntersectionObserver 方案无高频触发、无强制布局计算,性能更优,逻辑更简洁。
9.1.3 吸顶与吸底效果
吸顶(元素滚动到顶部后固定在视口顶部)、吸底(元素滚动到底部后固定在视口底部)是最常见的滚动布局交互,分为 CSS 原生与 JS 两种实现方案。
一、CSS 方案:position: sticky(首选方案)
CSS 粘性定位是原生实现吸顶吸底的标准方案,浏览器底层优化,性能最优,代码极简。
基础用法
/* 吸顶:滚动到顶部后固定在 top:0 位置 */ .sticky-header { position: sticky; top: 0; z-index: 10; } /* 吸底:滚动到底部后固定在 bottom:0 位置 */ .sticky-footer { position: sticky; bottom: 0; }
特点与限制
维度
说明
优势
零 JS 代码、原生高性能、无页面跳动问题
限制1
父元素不能设置overflow: hidden/overflow: auto,否则 sticky 会失效
限制2
元素仅在父元素范围内生效,父元素整体滚出视口后,元素会一起滚走
兼容性
现代浏览器全支持,IE 不兼容
二、JS 方案:scroll 监听 + fixed 定位
复杂场景(如吸顶过程中有样式渐变、需要精确控制时机)下,使用 JS 手动实现。
实现原理
- 提前记录元素初始位置,使用占位元素保持高度,避免元素脱离文档流导致页面跳动
- 监听 scroll 事件,当滚动距离超过元素顶部偏移量时,切换为
position: fixed - 滚动回原位时,恢复默认定位,隐藏占位元素
基础实现
const header = document.querySelector('.sticky-header'); // 创建占位元素,防止高度丢失导致页面跳动 const placeholder = document.createElement('div'); placeholder.style.height = header.offsetHeight + 'px'; placeholder.style.display = 'none'; header.parentNode.insertBefore(placeholder, header); const headerTop = header.offsetTop; // 节流优化滚动事件 const handleScroll = throttle(() => { const scrollTop = window.scrollY; if (scrollTop >= headerTop) { header.style.position = 'fixed'; header.style.top = '0'; placeholder.style.display = 'block'; } else { header.style.position = 'static'; placeholder.style.display = 'none'; } }, 16); window.addEventListener('scroll', handleScroll);
三、方案选型建议
- 常规吸顶吸底优先使用
position: sticky,性能最好、实现最简单 - 需要动态样式变化、复杂交互逻辑的场景,再使用 JS 方案,配合节流优化 scroll 事件性能
9.1.4 滚动穿透处理
滚动穿透是弹窗、抽屉等浮层场景的常见问题:当浮层内部滚动到底部/顶部后,继续滚动会带动背后的底层页面一起滚动,破坏交互体验。
一、方案一:CSS overscroll-behavior(现代浏览器首选)
原生 CSS 属性,专门用于控制滚动边界行为,代码最简单,性能最优。
/* 浮层可滚动内容容器 */ .modal-content { overflow-y: auto; overscroll-behavior: contain; }
- 原理:设置为
contain后,容器滚动到边界时不会向父元素传递滚动行为,彻底隔离滚动 - 优势:一行 CSS 解决,无副作用
- 局限:低版本浏览器不支持,部分移动端 webview 兼容性略差
二、方案二:body 固定定位(PC 端全屏弹窗常用)
弹窗打开时,给 body 设置固定定位,记录当前滚动位置;关闭弹窗后恢复样式与滚动位置,完全禁止底层滚动。
实现代码
let savedScrollTop = 0; // 打开弹窗时 function openModal() { savedScrollTop = window.scrollY; document.body.style.position = 'fixed'; document.body.style.top = `-${savedScrollTop}px`; document.body.style.width = '100%'; } // 关闭弹窗时 function closeModal() { document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; // 恢复页面滚动位置 window.scrollTo(0, savedScrollTop); }
- 特点:完全禁止底层页面滚动,彻底解决穿透问题
- 注意:fixed 定位会导致页面默认回到顶部,必须记录并恢复滚动位置;适合全屏遮罩弹窗场景
三、方案三:JS 事件阻止(移动端 touch 场景)
监听浮层的touchmove事件,判断当前滚动位置与滑动方向,在边界处阻止默认行为,避免滚动穿透。
实现逻辑
- 触摸开始时记录初始纵坐标
- 触摸移动时,判断滚动方向与当前滚动位置
- 若已在顶部且继续下拉,或已在底部且继续上拉,则阻止事件
- 中间滚动区域正常响应,不阻止事件
const content = document.querySelector('.modal-content'); let startY = 0; content.addEventListener('touchstart', (e) => { startY = e.touches[0].clientY; }); content.addEventListener('touchmove', (e) => { const currentY = e.touches[0].clientY; const deltaY = currentY - startY; const scrollTop = content.scrollTop; const scrollHeight = content.scrollHeight; const clientHeight = content.clientHeight; // 顶部且继续下拉,阻止穿透 if (scrollTop <= 0 && deltaY > 0) { e.preventDefault(); } // 底部且继续上拉,阻止穿透 if (scrollTop + clientHeight >= scrollHeight && deltaY < 0) { e.preventDefault(); } }, { passive: false });
- 特点:精准控制,仅边界处阻止,内部正常滚动
- 注意:事件必须设置
passive: false才能调用preventDefault
四、方案选型
- 现代浏览器优先使用
overscroll-behavior: contain - PC 端全屏弹窗可使用 body 固定方案
- 移动端复杂交互场景使用 JS 事件阻止方案