
9.1 滚动交互体系
滚动交互是 Web 页面最常见的交互场景之一,覆盖资源加载、列表浏览、布局联动等多个业务场景。传统滚动交互依赖 scroll 事件配合位置计算实现,存在高频触发、性能开销大、实现繁琐等问题。结合 IntersectionObserver、CSS 粘性定位等原生能力,可以实现高性能、低代码量的滚动交互方案,同时配套滚动穿透等边界问题的处理,构建完整的滚动交互体系。
9.1.1 基于 IntersectionObserver 的图片懒加载
图片懒加载是最经典的滚动性能优化手段,核心思路是仅当图片进入可视区域时才加载真实图片,减少首屏加载流量与请求数,提升页面加载速度。
一、传统方案的痛点
早期基于 scroll 事件 + getBoundingClientRect 实现的懒加载,存在明显缺陷:
scroll事件高频触发,即使加节流也存在持续的性能开销- 每次都需要手动计算元素位置,频繁触发强制同步布局
- 多图片场景逻辑繁琐,维护成本高
IntersectionObserver 异步监听元素交叉状态,不阻塞主线程,是当前懒加载的标准实现方案。
二、实现原理
- 图片默认不设置
src属性,真实地址存放在data-src自定义属性中,占位使用统一默认图或空白背景 - 创建 IntersectionObserver 实例,批量监听所有懒加载图片
- 图片进入可视区域时,将
data-src赋值给src属性,触发真实图片加载 - 加载完成后停止监听该图片,避免重复处理
三、完整实现代码
HTML 结构:
html
<img class="lazy-img" data-src="[https://example.com/real-img.jpg](https://example.com/real-img.jpg)" alt="懒加载图片">
JS 实现:
javascript
// 创建交叉观察者
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 结构:
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 实现:
javascript
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 粘性定位是原生实现吸顶吸底的标准方案,浏览器底层优化,性能最优,代码极简。
基础用法
javascript
/* 吸顶:滚动到顶部后固定在 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 - 滚动回原位时,恢复默认定位,隐藏占位元素
基础实现
javascript
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 属性,专门用于控制滚动边界行为,代码最简单,性能最优。
javascript
/* 浮层可滚动内容容器 */
.modal-content {
overflow-y: auto;
overscroll-behavior: contain;
}
- 原理:设置为
contain后,容器滚动到边界时不会向父元素传递滚动行为,彻底隔离滚动 - 优势:一行 CSS 解决,无副作用
- 局限:低版本浏览器不支持,部分移动端 webview 兼容性略差
二、方案二:body 固定定位(PC 端全屏弹窗常用)
弹窗打开时,给 body 设置固定定位,记录当前滚动位置;关闭弹窗后恢复样式与滚动位置,完全禁止底层滚动。
实现代码
javascript
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 事件,判断当前滚动位置与滑动方向,在边界处阻止默认行为,避免滚动穿透。
实现逻辑
- 触摸开始时记录初始纵坐标
- 触摸移动时,判断滚动方向与当前滚动位置
- 若已在顶部且继续下拉,或已在底部且继续上拉,则阻止事件
- 中间滚动区域正常响应,不阻止事件
javascript
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 事件阻止方案