返回

网页单次滚动仅移动一个Section的实现方法与优化

javascript

滚轮滚动问题:如何实现单次滚动只移动一个 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。
  • 在事件处理函数开头,检查 isScrollingthrottleTimeout。如果正在滚动或存在 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保证了它按帧渲染。
  • 每次渲染, 都让currentSectionIndextargetSectionIndex 靠近一点点, 0.1控制靠近速度。
  • currentSectionIndex非常接近targetSectionIndex,就停止动画。
  • 去除了原来的 transition
  • wheel 事件只负责改变 target, 不负责动画细节.

以上代码只是提供了最基础的惯性效果实现方案。 还可以继续深入,加入缓动函数, 根据 deltaY 大小控制速度等等。

安全性建议:

上述解决方案主要集中在前端交互逻辑,通常不会引入直接的安全问题。但是,在处理用户输入(例如滚动事件)时,一些通用建议还是需要注意:

  1. 输入验证: 如果你的网站需要根据滚动位置进行一些更复杂的操作(例如加载数据、显示/隐藏元素等),确保对用户输入进行适当的验证和过滤。 避免XSS.
  2. 避免在滚动事件处理程序中执行耗时操作: 这可能会导致页面卡顿,影响用户体验。如果确实需要执行耗时操作,考虑使用 Web Workers 将其移到后台线程执行。
  3. 避免 DOM 的频繁操作: 特别是 reflow and repaint. 这也会导致卡顿, 尽量使用 transform 这些操作。

以上就是针对"单次滚动只移动一个 section"问题的几种解决思路,根据具体的业务场景和需求,灵活组合这些技术方案即可。