为何窗口滚动事件触发两次?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
相关产品推荐
相关产品推荐

