React初始渲染存储HTML元素offsetTop,用于滚动监听实现粘性头部
实现滚动触发的粘性头部(动态获取偏移量)
问题描述
想要实现当页面滚动到元素offsetTop位置时,将头部切换为粘性定位,但不想硬编码偏移量。尝试用state存储元素的初始offsetTop,在scroll监听的isSticky()方法中判断,但该方法里的state值始终为null,状态未被正确设置。
相关代码
const ProfileHeader: React.FC<ProfileHeaderData> = ({ profile }) => { const bannerUrl = getBannerImageUrl(profile.id, null, profile.bannerImageVersion); const logoUrl = getLogoImageUrl(profile.id, null, profile.logoImageVersion); const headerRef = useRef<HTMLDivElement>(null); const [sticky, setSticky] = useState(""); const [headerOffSet, setHeaderOffSet] = useState<number>(null); console.log(`Outside: ${headerOffSet}`); // on render, set listener useEffect(() => { console.log(`Render: ${headerRef.current.offsetTop}`); setHeaderOffSet(headerRef.current.offsetTop); window.addEventListener("scroll", isSticky); return () => { window.removeEventListener("scroll", isSticky); }; }, []); const isSticky = () => { /* Method that will fix header after a specific scrollable */ const scrollTop = window.scrollY; let originalOffset = headerOffSet ?? headerRef.current.offsetTop; if (headerOffSet === null) { console.log(`Setting header off set`); setHeaderOffSet(originalOffset); } console.log(`top: ${scrollTop} | offset: ${originalOffset} | state: ${headerOffSet}`); const stickyClass = scrollTop >= originalOffset ? styles.sticky : ""; setSticky(stickyClass); }; return ( <div className={styles.container}> <div className={styles.bannerContainer}> {profile.bannerImageVersion && ( <Image src={bannerUrl} layout='fill' className={styles.banner} /> )} </div> <div ref={headerRef} className={`${styles.header} ${sticky}`}> <div className={styles.logoContainer}> {profile.logoImageVersion && ( <Image src={logoUrl} layout='fill' className={styles.logo} /> )} </div> <FlagCircleIcon {...profile.country} size={32} /> <h1 className={styles.displayName}>{profile.displayName}</h1> </div> </div> ) }
问题原因
核心问题是闭包捕获旧值:
- 组件首次渲染时,
useEffect绑定了初始版本的isSticky函数,该函数捕获的headerOffSet是初始的null。 - 后续调用
setHeaderOffSet更新状态时,组件会重渲染并生成新的isSticky函数,但滚动监听仍然引用旧版本的函数,因此旧函数里的headerOffSet永远是null。 - 滚动监听中调用
setHeaderOffSet会触发不必要的组件重渲染,影响性能。
解决方案
用useRef存储初始offsetTop(而非state),因为ref的current属性是可变的,不会触发重渲染,且能在监听函数中访问到最新值:
const ProfileHeader: React.FC<ProfileHeaderData> = ({ profile }) => { const bannerUrl = getBannerImageUrl(profile.id, null, profile.bannerImageVersion); const logoUrl = getLogoImageUrl(profile.id, null, profile.logoImageVersion); const headerRef = useRef<HTMLDivElement>(null); // 用ref存储offsetTop,避免闭包问题和不必要重渲染 const headerOffSetRef = useRef<number>(0); const [sticky, setSticky] = useState(""); useEffect(() => { // 确保元素已挂载后获取offsetTop if (!headerRef.current) return; headerOffSetRef.current = headerRef.current.offsetTop; // 将isSticky定义在useEffect内,确保能访问到最新的ref值 const isSticky = () => { const scrollTop = window.scrollY; const stickyClass = scrollTop >= headerOffSetRef.current ? styles.sticky : ""; setSticky(stickyClass); }; window.addEventListener("scroll", isSticky); // 清理函数移除监听 return () => window.removeEventListener("scroll", isSticky); }, []); return ( <div className={styles.container}> <div className={styles.bannerContainer}> {profile.bannerImageVersion && ( <Image src={bannerUrl} layout='fill' className={styles.banner} /> )} </div> <div ref={headerRef} className={`${styles.header} ${sticky}`}> <div className={styles.logoContainer}> {profile.logoImageVersion && ( <Image src={logoUrl} layout='fill' className={styles.logo} /> )} </div> <FlagCircleIcon {...profile.country} size={32} /> <h1 className={styles.displayName}>{profile.displayName}</h1> </div> </div> ) }
关键优化点
- 替换state为ref:初始offsetTop只需要获取一次,不需要触发组件重渲染,用
useRef存储更合适。 - 监听函数内定义isSticky:确保函数能访问到最新的ref值,避免闭包陷阱。
- 移除不必要的状态更新:滚动时不再修改offset相关状态,只更新粘性类名,减少重渲染次数。
内容的提问来源于stack exchange,提问作者Jason Bert
相关产品推荐
相关产品推荐

