/** * 滚动相关 hooks —— 统一全站的滚动监听。 * * 背景:此前滚动监听散落三处,各写一遍 addEventListener / * removeEventListener / passive / 初始调用: * · components/BackTop.tsx —— 监听 window,判断是否显示回到顶部 * · pages/Matches.tsx —— 监听联赛 tab 容器,判断能否右滚 * · admin/AdminLayout.tsx —— 关闭移动端抽屉时锁滚动 * * 三份重复的生命周期管理,任何一处漏了 cleanup 就是内存泄漏; * 且 `{ passive: true }` 这个纯收益的优化只有两处记得加。 * * 这里抽成两个 hook,把「监听 → 清理」的模式收敛为单一实现。 */ import { useEffect, useState } from 'react' import type { RefObject } from 'react' /** * 监听 window 纵向滚动位置。 * * @returns 当前 scrollY(节流到动画帧,避免滚动过程中高频 setState) */ export function useWindowScrollY(): number { const [y, setY] = useState(() => typeof window === 'undefined' ? 0 : window.scrollY, ) useEffect(() => { let ticking = false const onScroll = () => { // rAF 节流:滚动事件可能每帧触发多次,直接 setState 会造成 // 大量无意义的重渲染 if (ticking) return ticking = true requestAnimationFrame(() => { setY(window.scrollY) ticking = false }) } window.addEventListener('scroll', onScroll, { passive: true }) onScroll() // 初始同步:刷新后可能已在页面中部 return () => window.removeEventListener('scroll', onScroll) }, []) return y } /** * 监听某个可滚动元素是否还能继续向右滚动。 * * 用于横向溢出的导航条:能右滚时在右缘显示渐隐提示,提示用户 * 「右边还有内容」。同时监听 window resize —— 视口变宽后可能 * 就不再溢出了,不重新计算会留下错误的提示。 * * @param ref 目标滚动容器 * @param tolerance 容差(px),避免亚像素误差导致提示闪烁 * @param deps 触发重新测量的依赖(如列表内容变化时) */ export function useCanScrollRight( ref: RefObject, tolerance = 8, deps: unknown[] = [], ): boolean { const [canScroll, setCanScroll] = useState(false) useEffect(() => { const el = ref.current if (!el) return const update = () => { setCanScroll(el.scrollWidth - el.scrollLeft - el.clientWidth > tolerance) } update() // 初次测量 el.addEventListener('scroll', update, { passive: true }) window.addEventListener('resize', update) return () => { el.removeEventListener('scroll', update) window.removeEventListener('resize', update) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref, tolerance, ...deps]) return canScroll }