React中IntersectionObserver仅页面刷新时失效问题求助
问题根源
- 刷新页面时
useEffect触发时机早于内容DOM的渲染和布局完成阶段:此时你查询到的标题元素已经存在于DOM树,但还未完成浏览器的布局计算,所以getBoundingClientRect返回的所有尺寸属性都是0,IntersectionObserver自然无法正确判断元素的交叉状态。 - 跳转其他页面再返回正常是因为前端路由缓存了已经渲染完成的DOM,此时
useEffect执行时元素已经完成布局,能拿到正确的尺寸信息。 - 你当前用
toc作为useEffect的依赖触发条件,但toc一般是接口返回的目录元数据,toc加载完成和DOM渲染完成没有必然的先后关系,大概率是toc先返回,然后才开始渲染内容区的标题,所以触发useEffect的时候DOM还没准备好。
解决方案
你不需要移除整个依赖数组,只需要调整触发逻辑,确保IntersectionObserver初始化时内容DOM已经完成渲染布局即可,优化方案如下:
1. 新增DOM渲染完成标识作为依赖
在使用自定义hook的父组件中新增contentMounted状态,等内容区渲染完成后再标记为true,将该状态加入hook的依赖项,确保初始化时机正确。
2. 增加布局完成兜底判断
如果查询到的标题元素还未完成布局(宽高为0),用requestAnimationFrame等待浏览器下一帧再启动监听,避免提前绑定无效。
修改后的代码示例
import { useEffect, useRef } from "react"; // 新增第三个参数:contentMounted,标识内容区DOM是否渲染完成 const useIntersectionObserver = (setActiveId, toc, contentMounted) => { const headingElementsRef = useRef({}); useEffect(() => { // 内容区未渲染完成时直接返回 if (!contentMounted) return; const callback = (headings) => { headingElementsRef.current = headings.reduce((map, headingElement) => { map[headingElement.target.id] = headingElement; return map; }, headingElementsRef.current); const visibleHeadings = Object.values(headingElementsRef.current).filter( item => item.isIntersecting ); const headingElements = Array.from( document.getElementById("content").querySelectorAll("h1, h2, h3, h4, h5, h6") ); // 提前缓存ID和索引的映射,避免重复遍历 const idToIndex = new Map(headingElements.map((el, idx) => [el.id, idx])); if (visibleHeadings.length === 1) { setActiveId(visibleHeadings[0].target.id); } else if (visibleHeadings.length > 1) { const sortedVisibleHeadings = visibleHeadings.sort( (a, b) => idToIndex.get(a.target.id) - idToIndex.get(b.target.id) ); setActiveId(sortedVisibleHeadings[0].target.id); } }; const observer = new IntersectionObserver(callback, { rootMargin: "-90px 0px -40% 0px", }); const headingElements = Array.from( document.getElementById("content").querySelectorAll("h1, h2, h3, h4, h5, h6") ); // 兜底判断:元素未完成布局时等待下一帧再监听 if (headingElements.length && headingElements[0].getBoundingClientRect().width === 0) { const timer = requestAnimationFrame(() => { headingElements.forEach((element) => observer.observe(element)); }); return () => { cancelAnimationFrame(timer); observer.disconnect(); headingElementsRef.current = {}; }; } headingElements.forEach((element) => observer.observe(element)); return () => { observer.disconnect(); headingElementsRef.current = {}; }; }, [toc, contentMounted, setActiveId]); }; export default useIntersectionObserver;
父组件调用示例
import { useState, useEffect } from 'react' import useIntersectionObserver from './useIntersectionObserver' const ContentPage = () => { const [activeId, setActiveId] = useState('') const [toc, setToc] = useState([]) const [contentMounted, setContentMounted] = useState(false) // 模拟获取toc数据逻辑 useEffect(() => { fetch('/api/toc').then(res => res.json()).then(data => setToc(data)) }, []) // 监听内容区渲染完成 useEffect(() => { if (document.getElementById('content')) { setContentMounted(true) } }, []) // 传入第三个参数contentMounted useIntersectionObserver(setActiveId, toc, contentMounted) return ( <div> {/* 目录导航 */} <div id="toc">{/* 目录渲染逻辑 */}</div> {/* 内容区 */} <div id="content">{/* 内容渲染逻辑 */}</div> </div> ) }
内容的提问来源于stack exchange,提问作者Ben
相关产品推荐
相关产品推荐

