You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

为何窗口滚动事件触发两次?React自定义Hook滚动异常排查

问题描述

为什么我的滚动位置会被触发两次?控制台打印prevScrollPos和currentScrollPos时会输出两次。我编写了一个自定义Hook,向下滚动时返回true,向上滚动时返回false。

更新说明

改用wheel和touchmove事件后,打印两次的问题解决了,但新问题出现:点击按钮滚动到顶部时导航栏仍然不显示。而且因为事件触发逻辑问题,导航栏会频繁切换显隐状态,滚动到页面底部时恢复正常,但在中间来回滚动时问题依旧。

自定义Hook代码

import { useState, useCallback, useEffect } from "react";
const _ = require("lodash");

// This hook returns a boolean, true: if scrolling down, false: if scrolling up
export default function useScrollToggle() {
  // Scroll detection and sticky navbar states
  const [prevScrollPos, setPrevScrollPos] = useState(0);
  const [isVisible, setIsVisible] = useState(true);

  // wrap callback with debounce so doesn't trigger every scroll
  const onScrollHandler = _.debounce(
    useCallback(() => {
      // current scroll pos
      const currentScrollPos = window.pageYOffset;
      console.log(currentScrollPos);

      // Boolean reveal navbar on scroll-up (true) or false
      setIsVisible(
        (prevScrollPos > currentScrollPos &&
          // how many pixels needed to scroll up to trigger effect
          prevScrollPos - currentScrollPos > 70) ||
          // ensures that navbar always shows whenever we are at least 10px of top
          // of page
          currentScrollPos < 10
      );

      // set state to new scroll position
      setPrevScrollPos(currentScrollPos);
    }, [prevScrollPos]),
    100
  );

  useEffect(() => {
    // trigger onScrollHandler when scrolling
    window.addEventListener("wheel", onScrollHandler);

    // clean-up
    return () => window.removeEventListener("wheel", onScrollHandler);
  }, [prevScrollPos, isVisible, onScrollHandler]);

  useEffect(() => {
    // trigger onScrollHandler when scrolling
    window.addEventListener("touchmove", onScrollHandler);

    // clean-up
    return () => window.removeEventListener("touchmove", onScrollHandler);
  }, [prevScrollPos, isVisible, onScrollHandler]);

  return { isVisible, setIsVisible };
}

使用该Hook的组件代码

import styles from "./Navbar.module.css";
import NavbarMenu from "./NavbarMenu";
import MenuIcon from "@mui/icons-material/Menu";
import { useState } from "react";
import { useRouter } from "next/router";
import useScrollToggle from "../../../hooks/useScrollToggle";

export default function Navbar() {
  const router = useRouter();

  // Responsive drop-down menu for mobile
  const [menuToggle, setMenuToggle] = useState(false);

  const menuClickHandler = () => {
    setMenuToggle((prevState) => !prevState);
  };

  // True/false scroll toggle custom hook
  const { isVisible: navIsVisible } = useScrollToggle();

  // Brand Title navigate home
  const brandTitleClick = () => {
    router.push("/");
  };

  return (
    <nav
      className={`${styles.navbar} ${!navIsVisible && styles.navbarScrolling}`}
    >
      <div onClick={brandTitleClick} className={styles.brandTitle}>
        Brand Title{" "}
      </div>
      <NavbarMenu menuState={menuToggle} onMenuClick={menuClickHandler} />
      <MenuIcon onClick={menuClickHandler} className={styles.navIcon} />
    </nav>
  );
}
问题分析与修复

核心问题

  • 事件重复绑定:useEffect依赖项包含prevScrollPos、isVisible,每次状态变化都会解绑再重新绑定wheel和touchmove事件,导致重复触发。
  • 防抖与useCallback组合错误:_.debounce直接包裹useCallback,会导致useCallback每次依赖变化生成新函数,防抖逻辑失效,进而引发重复执行。
  • 事件覆盖不全:wheel和touchmove无法覆盖手动滚动(如点击按钮回到顶部)的场景,导致这类操作下导航栏状态不更新。

修复后的自定义Hook

import { useState, useCallback, useEffect } from "react";
import _ from "lodash";

// 自定义Hook:向下滚动返回true,向上滚动返回false
export default function useScrollToggle() {
  const [prevScrollPos, setPrevScrollPos] = useState(0);
  const [isVisible, setIsVisible] = useState(true);

  // 核心滚动处理逻辑,用useCallback保持引用稳定
  const scrollHandler = useCallback(() => {
    const currentScrollPos = window.pageYOffset;
    
    // 判断导航栏是否显示:向上滚动超过70px 或者 滚动到顶部10px以内时显示
    const shouldShow = (prevScrollPos > currentScrollPos && prevScrollPos - currentScrollPos > 70) 
      || currentScrollPos < 10;

    setIsVisible(shouldShow);
    setPrevScrollPos(currentScrollPos);
  }, [prevScrollPos]);

  // 用防抖包裹处理函数,确保引用稳定且防抖生效
  const debouncedScrollHandler = useCallback(
    _.debounce(scrollHandler, 100),
    [scrollHandler]
  );

  useEffect(() => {
    // 使用scroll事件,覆盖所有滚动场景
    window.addEventListener("scroll", debouncedScrollHandler);

    return () => {
      window.removeEventListener("scroll", debouncedScrollHandler);
      // 清理防抖定时器,避免内存泄漏
      debouncedScrollHandler.cancel();
    };
  }, [debouncedScrollHandler]);

  // 手动滚动到顶部时强制显示导航栏
  useEffect(() => {
    const checkTopPosition = () => {
      if (window.pageYOffset < 10) {
        setIsVisible(true);
      }
    };
    // 监听scroll事件确保实时检查
    window.addEventListener("scroll", checkTopPosition);
    // 组件挂载时先执行一次,确保初始状态正确
    checkTopPosition();
    return () => window.removeEventListener("scroll", checkTopPosition);
  }, []);

  return { isVisible, setIsVisible };
}

修复要点

  • 统一使用scroll事件:scroll事件能覆盖鼠标滚轮、触摸滚动、手动跳转等所有滚动场景,避免事件遗漏或重复绑定。
  • 正确组合防抖与useCallback:先通过useCallback稳定核心处理函数,再用_.debounce包裹,保证防抖逻辑正常工作。
  • 优化useEffect依赖:只依赖防抖后的处理函数,避免不必要的事件解绑/重绑。
  • 顶部位置强制检查:增加独立的顶部位置监听,确保点击按钮回到顶部时导航栏及时显示。
  • 清理防抖定时器:组件卸载时取消防抖定时器,防止内存泄漏。

内容的提问来源于stack exchange,提问作者importTurtle

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.24 13:06:24