Next.js中Intersection Observer无法监听多个Ref问题求助
问题:复用同一ref绑定多个元素时,Intersection Observer仅监听最后一个元素
在Next.js项目中,我通过switch语句根据传入的payload渲染特定组件,组件已用动态导入方式引入。为优化首屏加载、拆分代码块,我想结合Intersection Observer实现组件进入视口时再加载,于是写了基于useRef和Intersection Observer的自定义Hook useOnScreen。但发现单个div绑定ref时能正常监听,多个div绑定同一ref时,始终只有最后一个(对应instagram的)div被监听。我原以为可以复用同一个ref,通过.current识别当前被监听的元素,请问哪里操作有误?
IncludeComponent 组件代码
import React from 'react'; import getTCTEnv from '../../../lib/helpers/get-tct-env'; import IconWishlistButton from '../../wishlist/add-to-wishlist-button/button-types/icon-wishlist-button'; import loadable from '@loadable/component'; import { useOnScreen } from '../../../hooks/on-screen'; const PriorityCollection = loadable( () => import('@culture-trip/tile-ui-module/dist/collectionRail/PriorityCollections'), { resolveComponent: (components) => components.PriorityCollection } ); const TravelWithUs = loadable( () => import('../../../components/trips/travel-with-us/travel-with-us'), { resolveComponent: (components) => components.TravelWithUs } ); const TrustMessaging = loadable(() => import('../../../components/trips/trust-messaging/index'), { resolveComponent: (components) => components.TrustMessaging }); const PressMessaging = loadable(() => import('../../../components/trips/press-messaging'), { resolveComponent: (components) => components.PressMessaging }); const TripsChatBanner = loadable( () => import('../../../components/trips/chat-banner/chat-banner'), { resolveComponent: (components) => components.TripsChatBanner } ); const HpFeaturedArticles = loadable( () => import('../home-page-featured-articles/home-page-featured-articles'), { resolveComponent: (components) => components.HpFeaturedArticles } ); const InstagramSection = loadable(() => import('../../../components/trips/instagram'), { resolveComponent: (components) => components.InstagramSection }); const EmailForm = loadable(() => import('../../../components/trips/email-form')); const ReviewsSection = loadable(() => import('../../../components/trips/reviews')); export const IncludeComponent = ({ collections, reviewData, type }) => { const [containerRef, isVisible] = useOnScreen({ root: null, rootMargin: '0px', threshold: 0.1 }); const instagramCollection = collections.filter((collection) => collection.type === 'instagram'); const getComponents = () => collections.map((el, i) => { switch (el.type) { case 'trips': case 'article': return ( <PriorityCollection key={i} collections={[el]} tctEnv={getTCTEnv()} wishlistButton={<IconWishlistButton />} /> ); case 'reviews': return ( <> <div ref={containerRef} id={i}></div> <ReviewsSection reviewData={reviewData} /> </> ); case 'instagram': return ( <> <div ref={containerRef} id={i}></div> <InstagramSection collection={instagramCollection} /> </> ); case 'featured': return <PressMessaging />; case 'trust': return <TrustMessaging type={type} />; case 'featuredArticle': return <HpFeaturedArticles />; case 'email': return <EmailForm />; case 'chat': return <TripsChatBanner />; case 'travel': return <TravelWithUs type={type} />; default: return; } }); return getComponents(); };
useOnScreen 自定义Hook代码
import { useEffect, useState, useRef } from 'react'; export const useOnScreen = (options): any => { const containerRef = useRef<HTMLDivElement>(null); const [isVisible, setIsVisible] = useState([]); const callbackFunction = (entries) => { const [entry] = entries; if (entry.isIntersecting) setIsVisible((oldArray) => [ ...oldArray, isVisible.indexOf(entry.target.id) === -1 && entry.target.id !== undefined ? entry.target.id : console.log('nothing') ]); }; useEffect(() => { const observer = new IntersectionObserver(callbackFunction, options); if (containerRef.current) observer.observe(containerRef.current); return () => { if (containerRef.current) observer.unobserve(containerRef.current); }; }, [containerRef.current, options]); return [containerRef, isVisible]; };
错误原因分析
- 单个ref无法绑定多个元素:React的
useRef返回的是单一引用对象,当你把同一个ref绑定到多个元素时,后续元素会覆盖ref的.current属性,最终只有最后一个元素会被赋值给containerRef.current,所以Observer只监听了这最后一个元素。 - Hook的监听逻辑不支持多元素:当前
useOnScreenHook的设计仅针对单个元素,useEffect里每次只监听containerRef.current指向的元素,前面被覆盖的元素根本没被加入监听队列。 - 状态更新逻辑存在缺陷:
callbackFunction中使用isVisible.indexOf(...)依赖的是当前渲染的旧状态值,而非更新时的最新状态,会导致重复添加或判断错误,且可能向数组中混入undefined值。
解决方案
方案一:为每个需要监听的元素单独创建Hook实例
让每个需要监听的元素拥有独立的useOnScreen实例,确保每个元素都有专属的ref和监听逻辑:
export const IncludeComponent = ({ collections, reviewData, type }) => { const instagramCollection = collections.filter((collection) => collection.type === 'instagram'); const getComponents = () => collections.map((el, i) => { const elementId = `section-${i}`; // 对需要监听的类型单独初始化hook if (el.type === 'reviews' || el.type === 'instagram') { const [containerRef, isVisible] = useOnScreen({ root: null, rootMargin: '0px', threshold: 0.1 }); // 根据可见状态控制组件加载 return ( <> <div ref={containerRef} id={elementId}></div> {isVisible.includes(elementId) && ( el.type === 'reviews' ? <ReviewsSection reviewData={reviewData} /> : <InstagramSection collection={instagramCollection} /> )} </> ); } // 其他类型直接渲染 switch (el.type) { case 'trips': case 'article': return ( <PriorityCollection key={i} collections={[el]} tctEnv={getTCTEnv()} wishlistButton={<IconWishlistButton />} /> ); case 'featured': return <PressMessaging />; case 'trust': return <TrustMessaging type={type} />; case 'featuredArticle': return <HpFeaturedArticles />; case 'email': return <EmailForm />; case 'chat': return <TripsChatBanner />; case 'travel': return <TravelWithUs type={type} />; default: return null; } }); return getComponents(); };
同时修正useOnScreen的状态更新逻辑:
import { useEffect, useState, useRef } from 'react'; export const useOnScreen = (options): [React.RefObject<HTMLDivElement>, string[]] => { const containerRef = useRef<HTMLDivElement>(null); const [isVisible, setIsVisible] = useState<string[]>([]); const callbackFunction = (entries: IntersectionObserverEntry[]) => { const entry = entries[0]; if (entry.isIntersecting && entry.target.id) { setIsVisible(prev => { if (!prev.includes(entry.target.id)) { return [...prev, entry.target.id]; } return prev; }); } }; useEffect(() => { const observer = new IntersectionObserver(callbackFunction, options); const currentEl = containerRef.current; if (currentEl) observer.observe(currentEl); return () => { if (currentEl) observer.unobserve(currentEl); }; }, [options]); return [containerRef, isVisible]; };
方案二:重构Hook支持多元素监听
如果希望用一个Observer监听所有元素,可重构Hook管理多个ref:
import { useEffect, useState, useRef, useCallback } from 'react'; export const useOnScreenMulti = (options): [(el: HTMLDivElement | null) => void, string[]] => { const observerRef = useRef<IntersectionObserver | null>(null); const [isVisible, setIsVisible] = useState<string[]>([]); // 注册元素的回调函数 const registerRef = useCallback((el: HTMLDivElement | null) => { if (!el) return; if (!observerRef.current) { observerRef.current = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && entry.target.id) { setIsVisible(prev => { if (!prev.includes(entry.target.id)) return [...prev, entry.target.id]; return prev; }); // 可选:元素进入视口后停止监听,避免重复触发 observerRef.current?.unobserve(entry.target); } }); }, options); } observerRef.current.observe(el); }, [options]); // 组件卸载时清理Observer useEffect(() => { return () => { observerRef.current?.disconnect(); }; }, []); return [registerRef, isVisible]; };
在组件中使用该Hook:
export const IncludeComponent = ({ collections, reviewData, type }) => { const [registerRef, isVisible] = useOnScreenMulti({ root: null, rootMargin: '0px', threshold: 0.1 }); const instagramCollection = collections.filter((collection) => collection.type === 'instagram'); const getComponents = () => collections.map((el, i) => { const elementId = `section-${i}`; switch (el.type) { case 'trips': case 'article': return ( <PriorityCollection key={i} collections={[el]} tctEnv={getTCTEnv()} wishlistButton={<IconWishlistButton />} /> ); case 'reviews': return ( <> <div ref={registerRef} id={elementId}></div> {isVisible.includes(elementId) && <ReviewsSection reviewData={reviewData} />} </> ); case 'instagram': return ( <> <div ref={registerRef} id={elementId}></div> {isVisible.includes(elementId) && <InstagramSection collection={instagramCollection} />} </> ); // 其他case保持不变 case 'featured': return <PressMessaging />; case 'trust': return <TrustMessaging type={type} />; case 'featuredArticle': return <HpFeaturedArticles />; case 'email': return <EmailForm />; case 'chat': return <TripsChatBanner />; case 'travel': return <TravelWithUs type={type} />; default: return null; } }); return getComponents(); };
内容的提问来源于stack exchange,提问作者ashley g
相关产品推荐
相关产品推荐

