网页单次滚动仅移动一个Section的实现方法与优化
2025-03-24 10:03:55
滚轮滚动问题:如何实现单次滚动只移动一个 section?
最近在做一个可滚动的网站。用鼠标测试时,一切正常,但在笔记本电脑触控板上滚动时,发现 sections 移动的幅度过大,体验很糟糕。
我希望实现的效果是:单次滚动时,每个 section 只被完整显示一次。
问题原因分析
根本原因是不同设备触发的 wheel
事件中 deltaY
值差异很大。鼠标滚轮通常每次滚动产生较小的 deltaY
值,而笔记本电脑触控板单次滑动可能会产生较大的 deltaY
值。原始代码直接根据deltaY
的正负来判断滚动方向并切换 section,没有考虑到 deltaY
值的大小,导致触控板上快速滚动时,一次性切换了多个 section。
解决方案
针对上述问题,可从以下几个方面入手解决:
1. 节流 (Throttling)
原理: 节流的目的是限制事件处理函数的执行频率。在设定的时间间隔内,无论触发多少次事件,只执行一次事件处理函数。这样可以有效防止触控板快速滑动导致 section 切换过快。
代码示例:
let sections = document.querySelectorAll(".section");
let sectionsWrap = document.getElementById("sections-wrap");
let currentSectionIndex = 0;
let isScrolling = false;
let throttleTimeout = null; // 新增:用于存储 setTimeout 的 ID
let scrollToSection = (index) => {
sectionsWrap.style.transform = `translateY(-${index * 100}%)`;
sectionsWrap.style.transition = "transform 0.8s ease-in-out"; // 可以调整过渡时间
};
window.addEventListener("wheel", (event) => {
if (isScrolling || throttleTimeout) { // 修改:检查 throttleTimeout
return;
}
isScrolling = true;
if (event.deltaY > 0 && currentSectionIndex < sections.length - 1) {
currentSectionIndex++;
} else if (event.deltaY < 0 && currentSectionIndex > 0) {
currentSectionIndex--;
} else {
isScrolling = false; //重要:避免因为超出边界,无法重置 isScrolling
return;
}
scrollToSection(currentSectionIndex);
// 使用 clearTimeout 取消之前的 setTimeout
clearTimeout(throttleTimeout);
throttleTimeout = setTimeout(() => {
isScrolling = false;
throttleTimeout = null; // 清空 throttleTimeout
}, 800); //这个时间尽量和滚动时间一致
});
说明:
- 增加
throttleTimeout
变量来存储setTimeout
返回的 ID。 - 在事件处理函数开头,检查
isScrolling
和throttleTimeout
。如果正在滚动或存在throttleTimeout
,则直接返回,不再执行后续代码。 - 每次触发事件时,
clearTimeout
都能把上一个setTimeout
清除, 并设置一个新的。
2. 防抖 (Debouncing)
原理: 防抖的目的是在事件停止触发后,延迟一段时间再执行事件处理函数。只有在用户停止滚动操作后,才会切换 section。
代码示例:
let sections = document.querySelectorAll(".section");
let sectionsWrap = document.getElementById("sections-wrap");
let currentSectionIndex = 0;
let isScrolling = false;
let debounceTimeout = null; //新增
let scrollToSection = (index) => {
sectionsWrap.style.transform = `translateY(-${index * 100}%)`;
sectionsWrap.style.transition = "transform 0.8s ease-in-out";
};
window.addEventListener("wheel", (event) => {
if (isScrolling) {
clearTimeout(debounceTimeout); //持续滚动就一直清除
}
isScrolling = true;//必须滚动停止设定时间后才会被设置为 false
debounceTimeout = setTimeout(() => {
if (event.deltaY > 0 && currentSectionIndex < sections.length - 1) {
currentSectionIndex++;
} else if (event.deltaY < 0 && currentSectionIndex > 0) {
currentSectionIndex--;
}
scrollToSection(currentSectionIndex);
isScrolling = false; //滚动结束后才能修改
}, 200); // 200ms 内再次滚动,会重新计时
});
说明:
- 和节流不同,防抖更关注"停止滚动"这个动作。
debounceTimeout
在每次滚动时被重置, 一旦停止滚动并且设定时间到了之后,才会切换 sections。
3. 调整 deltaY
阈值
原理: 通过设置一个 deltaY
阈值,忽略较小的滚动事件。只有当 deltaY
的绝对值超过这个阈值时,才进行 section 切换。
代码示例:
let sections = document.querySelectorAll(".section");
let sectionsWrap = document.getElementById("sections-wrap");
let currentSectionIndex = 0;
let isScrolling = false;
const deltaThreshold = 50; // 新增:deltaY 阈值
let scrollToSection = (index) => {
sectionsWrap.style.transform = `translateY(-${index * 100}%)`;
sectionsWrap.style.transition = "transform 1s ease-in-out";
};
window.addEventListener("wheel", (event) => {
if (isScrolling) return;
if (Math.abs(event.deltaY) < deltaThreshold) return; // 新增:检查 deltaY 阈值
if (event.deltaY > 0 && currentSectionIndex < sections.length - 1) {
currentSectionIndex++;
} else if (event.deltaY < 0 && currentSectionIndex > 0) {
currentSectionIndex--;
} else {
return;
}
isScrolling = true;
scrollToSection(currentSectionIndex);
setTimeout(() => {
isScrolling = false;
}, 1200);
});
说明:
- 增加了一个
deltaThreshold
, 只有当滚动的deltaY
的绝对值大于它的时候,才触发。
4. 结合使用节流和 deltaY
阈值 (推荐)
原理: 这是个组合拳,兼顾了以上方法的优点,应对了所有情形,推荐使用。
代码示例:
let sections = document.querySelectorAll(".section");
let sectionsWrap = document.getElementById("sections-wrap");
let currentSectionIndex = 0;
let isScrolling = false;
let throttleTimeout = null;
const deltaThreshold = 30; // 可根据实际情况调整
let scrollToSection = (index) => {
sectionsWrap.style.transform = `translateY(-${index * 100}%)`;
sectionsWrap.style.transition = "transform 0.8s ease-in-out"; //可调
};
window.addEventListener("wheel", (event) => {
if (isScrolling || throttleTimeout) {
return;
}
if (Math.abs(event.deltaY) < deltaThreshold) {
return;
}
isScrolling = true;
if (event.deltaY > 0 && currentSectionIndex < sections.length - 1) {
currentSectionIndex++;
} else if (event.deltaY < 0 && currentSectionIndex > 0) {
currentSectionIndex--;
} else{
isScrolling = false; //重要,避免 isScrolling 无法重置
return;
}
scrollToSection(currentSectionIndex);
clearTimeout(throttleTimeout);
throttleTimeout = setTimeout(() => {
isScrolling = false;
throttleTimeout = null;
}, 800); // 和动画时间相同
});
说明:
- 综合使用了
isScrolling
,throttleTimeout
,deltaThreshold
这三个方法。 - 可以比较好的处理所有情形,比较鲁棒。
进阶: 模拟惯性滚动
如果对滚动体验有更高的要求,可以进一步模拟惯性滚动效果。
原理:
使用requestAnimationFrame
来创建平滑的动画,并根据deltaY
的大小来计算每一帧的滚动距离,模拟出惯性滚动的效果。
实现 (仅供参考,可以有很多优化):
let sections = document.querySelectorAll(".section");
let sectionsWrap = document.getElementById("sections-wrap");
let currentSectionIndex = 0;
let isScrolling = false;
let targetSectionIndex = 0; //新增: 目标section
let scrollToSection = (index) => {
targetSectionIndex = index; //记录目标 section index
animateScroll(); //启动动画
};
//核心动画部分
let animateScroll = ()=>{
if(currentSectionIndex !== targetSectionIndex){
isScrolling = true;
//平滑移动, 0.1 可以控制速度。可以做更复杂的缓动函数
currentSectionIndex += (targetSectionIndex - currentSectionIndex) * 0.1;
//接近时,直接等于 targetSectionIndex, 并结束。
if(Math.abs(targetSectionIndex-currentSectionIndex) < 0.01){
currentSectionIndex = targetSectionIndex;
isScrolling = false;
}
sectionsWrap.style.transform = `translateY(-${currentSectionIndex * 100}%)`;
sectionsWrap.style.transition = "none"; // 必须取消,否则和 animation冲突。
requestAnimationFrame(animateScroll);
}
};
window.addEventListener("wheel", (event) => {
if(isScrolling) return; //正在动画就退出
if (event.deltaY > 0 ) {
targetSectionIndex = Math.min(currentSectionIndex + 1, sections.length -1); //防止越界
} else if (event.deltaY < 0) {
targetSectionIndex = Math.max(currentSectionIndex -1 , 0);//防止越界
}
scrollToSection(targetSectionIndex);
});
解释:
- 用
targetSectionIndex
存储滚动的目标 section. animateScroll
函数是核心的动画函数,requestAnimationFrame
保证了它按帧渲染。- 每次渲染, 都让
currentSectionIndex
向targetSectionIndex
靠近一点点,0.1
控制靠近速度。 - 当
currentSectionIndex
非常接近targetSectionIndex
,就停止动画。 - 去除了原来的
transition
。 - wheel 事件只负责改变 target, 不负责动画细节.
以上代码只是提供了最基础的惯性效果实现方案。 还可以继续深入,加入缓动函数, 根据 deltaY 大小控制速度等等。
安全性建议:
上述解决方案主要集中在前端交互逻辑,通常不会引入直接的安全问题。但是,在处理用户输入(例如滚动事件)时,一些通用建议还是需要注意:
- 输入验证: 如果你的网站需要根据滚动位置进行一些更复杂的操作(例如加载数据、显示/隐藏元素等),确保对用户输入进行适当的验证和过滤。 避免XSS.
- 避免在滚动事件处理程序中执行耗时操作: 这可能会导致页面卡顿,影响用户体验。如果确实需要执行耗时操作,考虑使用 Web Workers 将其移到后台线程执行。
- 避免 DOM 的频繁操作: 特别是 reflow and repaint. 这也会导致卡顿, 尽量使用 transform 这些操作。
以上就是针对"单次滚动只移动一个 section"问题的几种解决思路,根据具体的业务场景和需求,灵活组合这些技术方案即可。