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

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>
    )
}

关键优化点

  1. 替换state为ref:初始offsetTop只需要获取一次,不需要触发组件重渲染,用useRef存储更合适。
  2. 监听函数内定义isSticky:确保函数能访问到最新的ref值,避免闭包陷阱。
  3. 移除不必要的状态更新:滚动时不再修改offset相关状态,只更新粘性类名,减少重渲染次数。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.24 23:06:27