使用 Hooks 和 Intersection Observer 在 React 中构建无限滚动

2025-06-10

使用 Hooks 和 Intersection Observer 在 React 中构建无限滚动

了解网页上哪些内容可见、哪些内容不可见,可能非常有用。您可以延迟加载进入视野的图片,在视频离开视野时停止播放,甚至可以获取用户在博客上阅读内容数量的准确分析。然而,这通常很难实现。过去,没有专门的 API 来实现这一点,人们不得不寻找其他方法(例如Element.getBoundingClientRect())来解决这个问题,但这会对应用程序的性能产生负面影响。

介绍:Intersection Observer API

一种更高效的方式来实现我们的目标。Intersection Observer API 是一个浏览器 API,可用于跟踪 HTML 元素相对于浏览器实际视口的位置。官方文档指出:“Intersection Observer API 提供了一种异步观察目标元素与其祖先元素或顶级文档视口交集变化的方法。” — MDN

我想探索一下如何使用 Intersection Observer 在 React 中实现无限滚动。我想总结一下我的经验,希望能帮助你避免犯和我一样的错误。

熟练使用React 的 ref API非常重要,因为它用于在 React 中实现 DOM 节点与交叉观察器之间的连接。否则,React 是一个声明式视图层库,不打算访问 DOM 节点。

交叉口观察器 API 如何工作?

为了全面了解 Intersection Observer API,我建议您查看MDN 上的文档

交叉点观察器的工作方式分为两部分:一个观察器实例,该实例可以附加到特定节点或整个视口;以及一个请求,请求该观察器监视其后代中的特定子节点。创建观察器时,还会提供一个回调函数,用于接收一个或多个交叉点条目。

简而言之,您需要创建一个观察者 (Observer),它将“观察”一个 DOM 节点,并在满足其一个或多个阈值选项时执行回调。阈值可以是 0 到 1 之间的任意比率,其中 1 表示元素 100% 在视口内,0 表示 100% 在视口外。默认情况下,阈值设置为 0。

// Example from MDN

let options = {
  root: document.querySelector('#scrollArea') || null, // page as root
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

/* 
   options let you control the circumstances under which
   the observer's callback is invoked
*/
Enter fullscreen mode Exit fullscreen mode

一旦创建了观察者,就必须给它一个要观察的目标元素:

let target = document.querySelector('#listItem');
observer.observe(target);
Enter fullscreen mode Exit fullscreen mode

每当目标达到为 指定的阈值时IntersectionObserver,就会调用回调。回调接收对象列表IntersectionObserverEntry和观察者:

let callback = (entries, observer) => { 
  entries.forEach(entry => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });


 console.log(entries, observer)
};
Enter fullscreen mode Exit fullscreen mode

门槛

阈值指的是相对于根部观察到的交叉点的程度IntersectionObserver

让我们考虑下面这张图片:
A 页面的阈值为 25%,B 页面的阈值为 50%,C 页面的阈值为 75%

首先要做的是将页面/滚动区域声明为我们的root。然后,我们可以将图像容器视为目标。将目标滚动到根元素会提供不同的阈值。阈值可以是单个项,例如 0.2,也可以是阈值数组,例如 [0.1, 0.2, 0.3, ...]。需要注意的是,root 属性必须是被观察元素的祖先,并且默认情况下是浏览器视口。

let options = {
  root: document.querySelector('#scrollArea'), 
  rootMargin: '0px',
  threshold: [0.98, 0.99, 1]
}

let observer = new IntersectionObserver(callback, options);
Enter fullscreen mode Exit fullscreen mode

我们有了一个观察者,但它还没有观察任何东西。要让它开始观察,你需要将一个 DOM 节点传递给 observe 方法。它可以观察任意数量的节点,但一次只能传入一个。当你不再希望它观察某个节点时,可以调用 unobserve() 方法并传入你想要它停止观察的节点,或者你可以调用 disconnect() 方法来停止它观察任何节点,如下所示:

let target = document.querySelector('#listItem');
observer.observe(target);

observer.unobserve(target);
//observing only target

observer.disconnect(); 
//not observing any node
Enter fullscreen mode Exit fullscreen mode

反应

我们将通过为图片列表创建无限滚动来实现交叉观察器。我们将使用超级简单的 。这是一个很棒的选择,因为它是分页的。

注意:你应该知道如何使用钩子获取数据,如果你不熟悉,可以看看这篇文章。内容很棒!

import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';

export default function App() {
  const [loading, setLoading] = useState(false);
  const [images, setImages] = useState([]);
  const [page, setPage] = useState(1);


  const fetchData = useCallback(async pageNumber => {
    const url = `https://picsum.photos/v2/list?page=${page}&limit=15`;
    setLoading(true);

    try {
      const res = await axios.get(url);
      const { status, data } = res;

      setLoading(false);
      return { status, data };
    } catch (e) {
      setLoading(false);
      return e;
    }
  }, []);

  const handleInitial = useCallback(async page => {
      const newImages = await fetchData(page);
      const { status, data } = newImages;
      if (status === 200) setImages(images => [...images, ...data]);
    },
    [fetchData]
  );

  useEffect(() => {
    handleInitial(page);
  }, [handleInitial]);

  return (
      <div className="appStyle">

      {images && (
        <ul className="imageGrid">
          {images.map((image, index) => (
            <li key={index} className="imageContainer">
              <img src={image.download_url} alt={image.author} className="imageStyle" />
            </li>
          ))}
        </ul>
      )}

      {loading && <li>Loading ...</li>}

      <div className="buttonContainer">
        <button className="buttonStyle">Load More</button>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

这是应用程序的核心。我们希望能够加载页面,并让它调用Lorem Picsum API,然后显示一些图片。

这是一个很好的开端,因为我们已经能够处理数据获取了。接下来要做的就是思考如何编写代码来发出更多请求并更新我们存储在状态中的图像列表。为此,我们必须创建一个函数,该函数接收当前页面的值,然后将其增加1。这将触发useEffect()为我们发出调用并更新 UI。

// const [page, setPage] = useState(1);
const loadMore = () => {
    setPage(page => page + 1);
    handleInitial(page);
};
Enter fullscreen mode Exit fullscreen mode

太好了,我们已经写好了更新函数。我们可以把它绑定到屏幕上的按钮上,让它帮我们执行更新操作!

<div className="buttonContainer">
   <button className="buttonStyle" onClick={loadMore}>Load More</button>
</div>
Enter fullscreen mode Exit fullscreen mode

打开你的网络选项卡,确保它正常工作。如果你检查正确,你会发现当我们点击 时Load More,它确实有效。唯一的问题是,它将页面的更新值读取为1。这很有趣,你可能想知道为什么会这样。简单的答案是,当更新进行时,我们仍然处于函数作用域中,并且在函数执行完成之前我们无法访问更新后的状态。这setState()与你可用的回调不同。

好的,那么我们该如何解决这个问题呢?我们将利用 React useRef()Hook。useRef()返回一个对象,该对象具有指向您所引用项目的 current 属性。

import React, { useRef } from "react";

const Game = () => {
  const gameRef = useRef(1);
};

const increaseGame = () => {
  gameRef.current; // this is how to access the current item
  gameRef.current++;

  console.log(gameRef); // 2, update made while in the function scope.
} 
Enter fullscreen mode Exit fullscreen mode

这种方法将帮助我们正确处理应用程序中的数据获取。

// Instead of const [page, setPage] = useState(1);
const page = useRef(1);

const loadMore = () => {
  page.current++;
  handleInitial(page);
};

useEffect(() => {
   handleInitial(page);
}, [handleInitial]);
Enter fullscreen mode Exit fullscreen mode

现在,如果你点击Load More按钮,它应该会按照预期运行。耶!🎉。我们可以认为本文的第一部分已经完成了。现在说说正题,我们如何将学到的知识Intersection Observer应用到这个应用中?

首先要考虑的是方法。使用上面解释阈值的图示,我们希望在“加载更多”按钮出现时加载图片。我们可以将阈值设置为10.75。我们必须Intersection Observer在 React 中进行设置。

// create a variable called observer and initialize the IntersectionObserver()
const observer = useRef(new IntersectionObserver());

/*

A couple of things you can pass to IntersectionObserver() ... 
the first is a callback function, that will be called every time
the elements you are observing is shown on the screen, 
the next are some options for the observer

*/

const observer = useRef(new IntersectionObserver(entries => {}, options)
Enter fullscreen mode Exit fullscreen mode

这样,我们就初始化了IntersectionObserver()。然而,初始化还不够。React 需要知道观察还是取消观察。为此,我们将使用useEffect()hook。我们还可以设置阈值为1

// Threshold set to 1
const observer = useRef(new IntersectionObserver(entries => {}, { threshold: 1 })

useEffect(() => {
  const currentObserver = observer.current;
    // This creates a copy of the observer 
  currentObserver.observe(); 
}, []);
Enter fullscreen mode Exit fullscreen mode

我们需要传递一个元素给观察者观察。在我们的例子中,我们想要观察“加载更多”按钮。最好的方法是创建一个 ref 并将其传递给观察者函数。

// we need to set an element for the observer to observer
const [element, setElement] = useState(null);

<div ref={setElement} className="buttonContainer">
  <button className="buttonStyle">Load More</button>
</div>

/*

on page load, this will trigger and set the element in state to itself, 
the idea is you want to run code on change to this element, so you 
will need this to make us of `useEffect()`

*/
Enter fullscreen mode Exit fullscreen mode

因此,我们现在可以更新我们的观察函数,以包含我们想要观察的元素

useEffect(() => {
  const currentElement = element; // create a copy of the element from state
  const currentObserver = observer.current;

  if (currentElement) {
    // check if element exists to avoid errors
    currentObserver.observe(currentElement);
  }
}, [element]);
Enter fullscreen mode Exit fullscreen mode

最后一件事是在我们的组件卸载时useEffect()设置一个清理功能。unobserve()

useEffect(() => {
  const currentElement = element; 
  const currentObserver = observer.current; 

  if (currentElement) {
    currentObserver.observe(currentElement); 
  }

  return () => {
    if (currentElement) {
      // check if element exists and stop watching
      currentObserver.unobserve(currentElement);
    }
  };
}, [element]);
Enter fullscreen mode Exit fullscreen mode

如果我们看一下网页,似乎仍然没有任何变化。嗯,那是因为我们需要对已初始化的 进行一些处理IntersectionObserver()

const observer = useRef(
  new IntersectionObserver(
    entries => {},
    { threshold: 1 }
  )
);

/*

entries is an array of items you can watch using the `IntersectionObserver()`,
since we only have one item we are watching, we can use bracket notation to
get the first element in the entries array

*/

const observer = useRef(
  new IntersectionObserver(
    entries => {
      const firstEntry = entries[0];
      console.log(firstEntry); // check out the info from the console.log()
    },
    { threshold: 1 }
  )
);
Enter fullscreen mode Exit fullscreen mode

从中console.log(),我们可以看到每个正在观察的项目可用的对象。你应该注意 isIntersecting 属性,如果你将“加载更多”按钮滚动到视图中,它会变为 true ;如果不在视图中,它会更新为 false。

const observer = useRef(
  new IntersectionObserver(
    entries => {
      const firstEntry = entries[0];
      console.log(firstEntry);

      if (firstEntry.isIntersecting) {
        loadMore(); // loadMore if item is in-view
      }
    },
    { threshold: 1 }
  )
);
Enter fullscreen mode Exit fullscreen mode

这对我们来说是有效的,你应该检查一下网页,当你滚动到Load More按钮附近时,它会触发loadMore()。但这里面有一个 bug,如果你上下滚动,isIntersecting就会被设置为falsetrue你肯定不希望每次上下滚动时都加载更多图片吧。

为了使其正常工作,我们将利用boundingClientRect我们正在监视的项目可用的对象。

const observer = useRef(
    new IntersectionObserver(
      entries => {
        const firstEntry = entries[0];
        const y = firstEntry.boundingClientRect.y;
        console.log(y); 
      },
      { threshold: 1 }
    )
  );
Enter fullscreen mode Exit fullscreen mode

我们对按钮在页面上的位置很感兴趣Load More。我们希望找到一种方法来检查按钮的位置是否发生了变化,以及当前位置是否大于之前的位置。

const initialY = useRef(0); // default position holder

const observer = useRef(
  new IntersectionObserver(
    entries => {
      const firstEntry = entries[0];
      const y = firstEntry.boundingClientRect.y;

            console.log(prevY.current, y); // check

      if (initialY.current > y) {
                console.log("changed") // loadMore()
      }

      initialY.current = y; // updated the current position
    },
    { threshold: 1 }
  )
);
Enter fullscreen mode Exit fullscreen mode

通过此更新,当您滚动时,它应该加载更多图像,并且如果您在已经可用的内容中上下滚动,那就没问题了。

完整代码

import React, { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';

export default function App() {
  const [element, setElement] = useState(null);
  const [loading, setLoading] = useState(false);
  const [images, setImages] = useState([]);

  const page = useRef(1);
  const prevY = useRef(0);
  const observer = useRef(
    new IntersectionObserver(
      entries => {
        const firstEntry = entries[0];
        const y = firstEntry.boundingClientRect.y;

        if (prevY.current > y) {
          setTimeout(() => loadMore(), 1000); // 1 sec delay
        }

        prevY.current = y;
      },
      { threshold: 1 }
    )
  );

  const fetchData = useCallback(async pageNumber => {
    const url = `https://picsum.photos/v2/list?page=${pageNumber}&limit=15`;
    setLoading(true);

    try {
      const res = await axios.get(url);
      const { status, data } = res;

      setLoading(false);
      return { status, data };
    } catch (e) {
      setLoading(false);
      return e;
    }
  }, []);

  const handleInitial = useCallback(
    async page => {
      const newImages = await fetchData(page);
      const { status, data } = newImages;
      if (status === 200) setImages(images => [...images, ...data]);
    },
    [fetchData]
  );

  const loadMore = () => {
    page.current++;
    handleInitial(page.current);
  };

  useEffect(() => {
    handleInitial(page.current);
  }, [handleInitial]);

  useEffect(() => {
    const currentElement = element;
    const currentObserver = observer.current;

    if (currentElement) {
      currentObserver.observe(currentElement);
    }

    return () => {
      if (currentElement) {
        currentObserver.unobserve(currentElement);
      }
    };
  }, [element]);

  return (
    <div className="appStyle">
      {images && (
        <ul className="imageGrid">
          {images.map((image, index) => (
            <li key={index} className="imageContainer">
              <img src={image.download_url} alt={image.author} className="imageStyle" />
            </li>
          ))}
        </ul>
      )}

      {loading && <li>Loading ...</li>}

      <div ref={setElement} className="buttonContainer">
        <button className="buttonStyle">Load More</button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

需要注意的是,IO 在某种程度上是安全的,并且大多数浏览器都支持。但是,如果您不放心,也可以使用Polyfill 。您可以参考以下内容了解更多关于支持的信息:

浏览器支持

再见👋🏾

鏂囩珷鏉簮锛�https://dev.to/somtougeh/building-infinite-scroll-in-react-with-hooks-and-intersection-observer-3e09
PREV
为什么使用虚拟 DOM:渲染和性能
NEXT
你在 React 中使用过 `flushSync` 吗?