React 用 IntersectionObserver 写 useInView 与无限滚动:列表滚动到底自动加载

test12026-10-072 次阅读

为什么不直接监听 scroll 事件

做无限滚动最常见的写法是监听 window 的 scroll 事件,然后在回调里算 scrollTop、clientHeight、scrollHeight 判断有没有到底。这个做法有三个麻烦:回调触发极频繁,需要自己做节流;计算依赖具体容器的尺寸,列表换成内部滚动容器时就得重写;组件卸载时容易忘记解绑监听,留下内存泄漏。

IntersectionObserver 把"元素是否进入视口"这件事交给了浏览器,它只会在交叉状态真正变化时回调一次,天然节流,也不关心滚动发生在 window 还是某个 div。把它包成一个 hook,前端列表加载就能写得既稳又省。

先把 useInView 写出来

这个 hook 接收一个 ref 和一个配置项,返回元素当前是否在可视范围内。注意它返回的是 ref 本身和布尔值,组件把 ref 挂到目标元素即可。

import { useEffect, useRef, useState } from "react";

export function useInView<T extends HTMLElement = HTMLDivElement>(
  options: IntersectionObserverInit = { rootMargin: "200px" }
) {
  const ref = useRef<T | null>(null);
  const [inView, setInView] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(([entry]) => {
      setInView(entry.isIntersecting);
    }, options);
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return { ref, inView } as const;
}

rootMargin 设为 200px,意味着元素距离视口还有 200 像素时就算"进入",可以提前发起请求,用户滚到底时数据多半已经就绪,体验更顺。这个 hook 是纯前端能力,不绑定任何数据来源,可以复用到懒加载图片、曝光埋点等场景。

用 sentinel 节点驱动无限加载

无限列表的关键是放一个"哨兵"节点在最底部,它进入视口就说明用户快滚到底了。用上面的 useInView 把 ref 挂到哨兵上,inView 变 true 时触发下一页请求。

type Item = { id: number; name: string };

export function InfiniteList() {
  const [items, setItems] = useState<Item[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [done, setDone] = useState(false);
  const sentinel = useInView<HTMLDivElement>();

  useEffect(() => {
    if (!sentinel.inView || loading || done) return;
    setLoading(true);
    fetch(`/api/items?page=${page}`)
      .then((r) => r.json())
      .then((data: Item[]) => {
        setItems((prev) => [...prev, ...data]);
        setPage((p) => p + 1);
        if (data.length === 0) setDone(true);
      })
      .finally(() => setLoading(false));
  }, [sentinel.inView, loading, done, page]);

  return (
    <div>
      <ul>
        {items.map((it) => (
          <li key={it.id}>{it.name}</li>
        ))}
      </ul>
      <div ref={sentinel.ref}>
        {loading ? "加载中…" : done ? "没有更多了" : "下拉加载更多"}
      </div>
    </div>
  );
}

几个容易踩的坑

  • inView 是布尔状态,进入和离开都会触发。加载逻辑必须靠 loading 和 done 两个标志位兜底,否则同一页会被重复请求多次。
  • 依赖数组里要带上 page、loading、done,保证 effect 在这些值变化后重新评估,而不是只在 inView 翻转时跑一次。
  • 如果列表项本身高度不确定,rootMargin 提前量可以调大些;若服务端返回空数组要立刻置 done,防止哨兵一直停在视口里循环请求。
  • SSR 环境下 IntersectionObserver 不存在,需要在 useEffect 里访问(它本就只在客户端跑),首屏渲染不要依赖 inView 的值。

小结

把 IntersectionObserver 封进 useInView,无限滚动就从"算尺寸加节流加解绑"三件套,退化成一个布尔状态加一个 effect。这个模式在长列表、瀑布流、评论区加载里都能直接套用,也是现代前端替代 scroll 监听的标准做法。

T

test1

文章作者

为什么不直接监听 scroll 事件 做无限滚动最常见的写法是监听 window 的 scroll 事件,然后在回调里...

分类
技术
发布时间
2026-10-07
字数
约 2691 字
阅读
2 次