使用Intersection Observer实现无限滚动InfiniteScroll

509 阅读4分钟

写在最前

看官们好,我是JetTsang,之前都是在掘金潜水来着,现在偶尔做一些内容输出吧。

前言

无限滚动通常应用于移动端的交互上,比如我们最常见的短视频软件,看视频的时候可以一直滑,那就是一种无限滚动的典型案例。

其核心点就在于怎么去触发加载数据,在传统网页开发当中,通常是利用scroll事件,配合上scrollHeight - scrollTop <= clientHeight + threshold,来判断。

但今天可以换个思路,使用交叉观察器IntersectionObserver来判断是否触发滚动。

IntersectionObserver介绍

ps:知道这个API用法的可以直接跳过

image.png 简单来说,这是检测一个元素是否与祖先元素或者视口有交集。

它有如下优势:

  1. 异步,这点能一定程度保证页面流畅性
  2. 配置灵活,可以配置祖先元素或者视口、threshold交叉比例阈值,rootMargin偏移量
  3. 相对于传统的scroll、resize事件,性能更有保障

更多细节可以看 MDN Intersection_Observer_API

简单用法

// 创建一个 IntersectionObserver 实例
let observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    // entry 是一个 IntersectionObserverEntry 对象
    if (entry.isIntersecting) {
      // 当目标元素进入视窗时执行的操作
      entry.target.classList.add('visible');
    } else {
      // 当目标元素离开视窗时执行的操作
      entry.target.classList.remove('visible');
    }
  });
}, {
  root: null, // 默认为视窗
  rootMargin: '0px',
  threshold: 0.5 // 50% 的交集时触发回调
});

// 观察某个元素
let target = document.querySelector('.target');
observer.observe(target);

实现

假设是向上滚动,那么就需要判断滚动列表的最后1个元素是否进入视窗/祖先元素即可。

示例搭建

首先构建基础框架

请求封装,使用# JSONPlaceholder来mock数据

export const fetchPosts = async (page, pageSize = 10) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${pageSize}`
  );
  const data = await response.json();
  return data;
};

组件骨架

const PostsList = () => {
  const [posts, setPosts] = useState([]); // 渲染的数据
  const [page, setPage] = useState(1); // 当前页码
  const [loading, setLoading] = useState(false);  // 是否加载中

  return (
    <div>
      <h1>Your Feed</h1>
      <ul>
        {posts.map((post, index) => (
          <li
            key={post.id}
          >
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
      {loading && <p>Loading...</p>}

    </div>
  );
};

然后添加effect

const loadMorePosts = ()=>{
    setLoading(true)
    const newPosts = await fetchPosts(page)
    setPosts(prePosts=>[...prePosts,...newPosts]
    setLoading(false)
}
useEffect(() => {
    loadMorePosts();
  }, [page]); // 当页码变动时,重新请求

到此,已经实现了初次渲染加载10个posts了,接下来需要根据Intersection Observer这个API来检测何时开始续传。

观察逻辑

根据Intersection Observer的使用规则,那就用一个ref来存储

const observer = useRef();

observer.current = new IntersectionObserver(callback)

回调参数应该怎么写呢?

这里只需要增加page,之后对应的副作用会执行loadMorePosts,从而加载到更多的Posts

const callback = (entries) => {
    // 这里只观察最后1个,因此entries只有1个
    if (entries[0].isIntersecting) {
      setPage((prevPage) => prevPage + 1); // 增加页码
    }
}

那么什么时候开始观察呢?

每次列表渲染到视图之后,动态观察最后1个li,那如何能巧妙保证能在最后渲染之后呢?useEffect?

获取到最后的li

如何获取到渲染的最后1个li元素呢? 答案当然是ref啊,但最后一个li是会不断更新,更新li之后需要重新observer。

使用ref的回调形式,获得最后的li的ref,详细可看ref-callback - react

image.png

于是乎观察的逻辑就放在ref的回调里,同时用useCallback包裹,避免每一次渲染的时候重新创建。

const lastPostElementRef = useCallback(
    (node) => {
      if (loading) return;
      // 让当前observer停止监听
      if (observer.current) observer.current.disconnect();
      // observer.current 来指向最新的observer对象
      observer.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
          setPage((prevPage) => prevPage + 1); 
        }
      });

      if (node) observer.current.observe(node);
    },
    [loading]
  );

然后改一下jsx

{posts.map((post, index) => (
          <li
            key={post.id}
            // 给最后的li添加上ref
            ref={posts.length === index + 1 ? lastPostElementRef : null}
          >
            ...
          </li>))
}

最后看看效果

image.png

全文代码

import React, { useCallback, useEffect, useRef, useState } from "react";
import "../styles.css";
import { fetchPosts } from "../services";

const PostsList = () => {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const observer = useRef();

  const loadMorePosts = useCallback(async () => {
    setLoading(true);
    const newPosts = await fetchPosts(page, 10);
    if (newPosts.length === 0) {
      setHasMore(false); // Set hasMore to false if no more posts are returned
    } else {
      setPosts((prevPosts) => [...prevPosts, ...newPosts]);
    }
    setLoading(false);
  }, [page]);

  useEffect(() => {
    if (hasMore) {
      loadMorePosts();
    }
  }, [loadMorePosts, hasMore]);

  const lastPostElementRef = useCallback(
    (node) => {
      if (loading || !hasMore) return; // 没有更多数据或者正在加载中时,页面渲染会
      if (observer.current) observer.current.disconnect();  // 停止观察,使得observer对象失效

      observer.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
          setPage((prevPage) => prevPage + 1); // 通过设置page,来更新useCallback从而触发请求新的数据
        }
      });

      if (node) observer.current.observe(node);
    },
    [loading, hasMore]
  );

  return (
    <div>
      <h1>Your Feed</h1>
      <ul>
        {posts.map((post, index) => (
          <li
            key={post.id} // Use post.id directly
            ref={posts.length === index + 1 ? lastPostElementRef : null}
          >
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
      {loading && <p>Loading...</p>}
      {/* Message indicating no more posts */}
    </div>
  );
};

export default PostsList;