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

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];
};

错误原因分析

  1. 单个ref无法绑定多个元素:React的useRef返回的是单一引用对象,当你把同一个ref绑定到多个元素时,后续元素会覆盖ref的.current属性,最终只有最后一个元素会被赋值给containerRef.current,所以Observer只监听了这最后一个元素。
  2. Hook的监听逻辑不支持多元素:当前useOnScreen Hook的设计仅针对单个元素,useEffect里每次只监听containerRef.current指向的元素,前面被覆盖的元素根本没被加入监听队列。
  3. 状态更新逻辑存在缺陷: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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.11 20:05:24