"use client"; import { useEffect, useState, useRef, RefObject, useCallback } from 'react'; interface UseStickyOptions { /** * 粘性触发的偏移量(相对于容器顶部) * 默认为 0 */ offset?: number; /** * 抖动防护的滞后容差(像素)。 * 会将触发边界向下偏移,避免 0 边界来回切换。 * 默认为 2 */ hysteresis?: number; /** * 根元素,用于观察交叉状态 * 默认为 null(使用视口) */ root?: Element | null; } /** * 检测元素是否处于 sticky 状态的 hook * * @param options 配置选项 * @returns [ref, isSticky] - 需要绑定到目标元素的 ref 和 sticky 状态 */ export function useSticky( options: UseStickyOptions = {} ): [RefObject, boolean] { const { offset = 0, hysteresis = 2, root = null } = options; const elementRef = useRef(null); const [isSticky, setIsSticky] = useState(false); const rafIdRef = useRef(null); const lastCheckTimeRef = useRef(0); const scrollContainerRef = useRef(null); // 检查并更新 sticky 状态 const checkStickyState = useCallback(() => { const element = elementRef.current; const scrollContainer = scrollContainerRef.current; if (!element) return; // 获取滚动容器的滚动位置 const scrollTop = scrollContainer ? scrollContainer.scrollTop : window.scrollY; // 获取元素相对于容器的位置 const elementRect = element.getBoundingClientRect(); const containerRect = scrollContainer ? scrollContainer.getBoundingClientRect() : { top: 0 }; // 计算元素相对于容器顶部的距离 const elementTopRelativeToContainer = elementRect.top - containerRect.top; // 滚动位置阈值:避免在接近顶部时还保持吸顶 const scrollThreshold = 20; // 判断是否应该吸顶 // 向下滚动时:元素顶部到达或超过 offset 位置时触发吸顶 // 向上滚动时:元素顶部回到 offset + hysteresis 以上时取消吸顶 const shouldBeSticky = elementTopRelativeToContainer <= offset && scrollTop > scrollThreshold; const shouldNotBeSticky = elementTopRelativeToContainer > offset + hysteresis; setIsSticky(prev => { if (shouldNotBeSticky) return false; if (shouldBeSticky) return true; return prev; // 保持当前状态 }); }, [offset, hysteresis]); useEffect(() => { const element = elementRef.current; if (!element) return; // 如果没有指定 root,尝试自动查找滚动容器 let scrollContainer = root as HTMLElement | null; if (!scrollContainer) { // 查找具有 overflow 属性的父容器或 main-content 容器 scrollContainer = document.getElementById('main-content') || null; } scrollContainerRef.current = scrollContainer; // 创建一个哨兵元素来检测 sticky 状态 const sentinel = document.createElement('div'); sentinel.style.position = 'relative'; sentinel.style.top = `${offset}px`; // 设置与 sticky offset 相同的位置 sentinel.style.left = '0'; sentinel.style.width = '1px'; sentinel.style.height = '1px'; sentinel.style.pointerEvents = 'none'; sentinel.style.visibility = 'hidden'; // 将哨兵元素插入到目标元素之前 element.parentNode?.insertBefore(sentinel, element); // IntersectionObserver 作为主要检测机制 const observer = new IntersectionObserver( ([entry]) => { // 使用 rAF 合并更新,避免高频切换 if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); } rafIdRef.current = requestAnimationFrame(() => { checkStickyState(); }); }, { root: scrollContainer, // 在向下滚动触发点加入滞后容差,远离 0 边界,减少抖动 rootMargin: `-${offset + Math.max(0, Math.floor(hysteresis))}px 0px 0px 0px`, threshold: 0 } ); observer.observe(sentinel); // 添加 scroll 事件监听作为后备方案 // 使用节流来优化性能,避免过于频繁的调用 let scrollTimeout: NodeJS.Timeout | null = null; const handleScroll = () => { const now = Date.now(); // 节流:至少间隔 50ms 才执行一次检查 if (now - lastCheckTimeRef.current < 50) { if (scrollTimeout) { clearTimeout(scrollTimeout); } // 设置一个延迟检查,确保滚动停止后也会执行一次 scrollTimeout = setTimeout(() => { lastCheckTimeRef.current = Date.now(); checkStickyState(); }, 50); return; } lastCheckTimeRef.current = now; checkStickyState(); }; const scrollTarget = scrollContainer || window; scrollTarget.addEventListener('scroll', handleScroll, { passive: true }); return () => { observer.disconnect(); sentinel.remove(); scrollTarget.removeEventListener('scroll', handleScroll); if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } if (scrollTimeout) { clearTimeout(scrollTimeout); } }; }, [offset, hysteresis, root, checkStickyState]); return [elementRef, isSticky]; } /** * 简化版本的 useSticky hook,使用默认配置 */ export function useStickySimple(): [RefObject, boolean] { return useSticky(); }