React 用 IntersectionObserver 实现图片懒加载与无限滚动:封装一个 useInView Hook

React 用 IntersectionObserver 实现图片懒加载与无限滚动:封装一个 useInView Hook

列表页一次渲染几十张图,首屏还没露脸的图片全都在偷偷加载,首屏白屏时间被拖长;或者做「滚到底自动加载下一页」,你监听 scroll 事件算 scrollTop + clientHeight >= scrollHeight,结果滚动时卡顿、还得手动防抖。这两个需求其实是同一个问题:怎么知道某个元素进没进视口 。老办法(监听 scroll + getBoundingClientRect)又费性能又难写,现代做法是 IntersectionObserver。这篇带你封装一个可复用的 useInView Hook,再分别拿它做图片懒加载和无限滚动。

为什么别再用 scroll + getBoundingClientRect

先看看「朴素写法」为什么不好:

jsx 复制代码
// ❌ 老办法:监听 scroll,每次都算位置
useEffect(() => {
  const onScroll = () => {
    const rect = ref.current.getBoundingClientRect();
    if (rect.top < window.innerHeight) {
      // 进视口了...
    }
  };
  window.addEventListener("scroll", onScroll);
  return () => window.removeEventListener("scroll", onScroll);
}, []);

两个问题:

  1. scroll 事件触发极其频繁 ,滚一下能触发几十上百次,每次都调 getBoundingClientRect------而 getBoundingClientRect强制浏览器同步重排(reflow),滚动时掉帧就是这么来的。你还得自己加节流,越写越复杂。
  2. 多个元素都要监听时,每个都挂一个 scroll listener,性能雪上加霜。

IntersectionObserver 是浏览器原生 API,专门解决「元素是否进入视口」。它异步、在浏览器空闲时批量回调,不占主线程,也不触发重排,几十个元素一起观察也很轻。

封装 useInView Hook

核心思路:传一个 ref 给要观察的元素,Hook 内部用 IntersectionObserver 盯着它,返回一个 inView 布尔值。

jsx 复制代码
import { useEffect, useRef, useState } from "react";

function useInView(options) {
  const ref = useRef(null);
  const [inView, setInView] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return; // 元素还没挂载,直接跳过

    const observer = new IntersectionObserver(([entry]) => {
      // entry.isIntersecting 就是「有没有和视口相交」
      setInView(entry.isIntersecting);
    }, options);

    observer.observe(el);

    // 关键:卸载时断开观察,否则观察器和 DOM 引用泄漏
    return () => observer.disconnect();
  }, [options]);

  return [ref, inView];
}

几个要点:

  • 回调里的 entry.isIntersecting 是布尔值,true = 元素和视口(或指定容器)有交集。
  • return () => observer.disconnect() 不能省。组件卸载后不断开,观察器会一直持有 DOM 引用,既泄漏内存又可能对已卸载节点调 setState 报警告。
  • 依赖数组带 options :如果 options 是父组件每次渲染新建的对象,会导致 effect 反复重建。用法上要么把 options 定义在组件外,要么用 useMemo 包住(下面会提)。

options 支持三个常用字段:

  • root:相对哪个容器算「视口」,默认 null 即浏览器视口。做容器内滚动(不是整页滚)时传容器 ref。
  • rootMargin:给视口「扩边」,如 "200px" 表示元素距视口还有 200px 时就算「进入」------懒加载提前触发、避免用户看到图还在转圈,全靠它。
  • threshold:相交比例阈值,0 表示露头就触发,1 表示完全可见才触发。

场景一:图片懒加载

思路:图片没进视口时只渲染占位,inView 变 true 后才把真实 src 挂上去。

jsx 复制代码
function LazyImage({ src, alt }) {
  // rootMargin 200px:提前 200px 就开始加载,滚到时图已就位
  const [ref, inView] = useInView({ rootMargin: "200px", threshold: 0 });
  const [loaded, setLoaded] = useState(false);

  return (
    <div
      ref={ref}
      style={{ minHeight: 200, background: loaded ? "none" : "#f0f0f0" }}
    >
      {inView && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setLoaded(true)}
          style={{ opacity: loaded ? 1 : 0, transition: "opacity .3s" }}
        />
      )}
    </div>
  );
}

inView 为 false 时渲染一个灰色占位(撑住高度,避免布局跳动),进视口后才渲染 <img> 真正发起图片请求;onLoad 后淡入。rootMargin: "200px" 让加载提前发生,用户几乎感觉不到懒加载的存在。

补充:现代浏览器其实原生支持 <img loading="lazy">,简单场景直接用它就够了。但当你需要**自定义占位、控制加载时机、或懒加载的不只是图片(比如整个重型组件)**时,IntersectionObserver 才是通用解。

场景二:无限滚动

思路:在列表末尾放一个「哨兵」空元素,它一进视口就说明用户快滚到底了,触发加载下一页。

jsx 复制代码
function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  // 哨兵进视口就触发
  const [sentinelRef, inView] = useInView({ rootMargin: "100px" });

  useEffect(() => {
    // 只有哨兵可见、不在加载中、还有更多,才加载
    if (!inView || loading || !hasMore) return;

    setLoading(true);
    fetch(`/api/items?page=${page}`)
      .then((r) => r.json())
      .then((data) => {
        setItems((prev) => [...prev, ...data.list]); // 追加,不是替换
        setHasMore(data.hasMore);
        setPage((p) => p + 1);
      })
      .finally(() => setLoading(false));
  }, [inView, loading, hasMore, page]);

  return (
    <div>
      {items.map((it) => (
        <div key={it.id}>{it.title}</div>
      ))}

      {hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
      {loading && <p>加载中...</p>}
      {!hasMore && <p>没有更多了</p>}
    </div>
  );
}

三个防坑点:

  • if (!inView || loading || !hasMore) return 三重闸门缺一不可 。少了 loading 判断,哨兵持续可见时会连发好几次请求(第一页数据还没回来,哨兵还在视口里,effect 又跑一遍);少了 hasMore,数据加载完后哨兵还在,会无限请求空数据。
  • 哨兵只在 hasMore 时渲染 。数据到底后哨兵消失,observe 的目标没了,自然不再触发,同时页面显示「没有更多了」。
  • setItems((prev) => [...prev, ...data.list]) 用函数式更新追加 ,别写成依赖闭包里的 items,否则容易丢数据(连续加载时拿到旧的 items 快照)。

一个容易踩的坑:ref 变化不会重新 observe

上面的 useInView 有个隐含前提:被观察的 DOM 节点在组件生命周期里是稳定 的。如果你的元素是条件渲染、ref 指向的节点会中途更换,useEffect 的空依赖不会重新执行 observe,新节点就没被观察。稳妥做法是用回调 ref 或把节点作为依赖。这里给一个更健壮的版本骨架:

jsx 复制代码
function useInView(options) {
  const [node, setNode] = useState(null);     // 用 state 存节点
  const [inView, setInView] = useState(false);

  const ref = useCallback((el) => setNode(el), []); // 回调 ref

  useEffect(() => {
    if (!node) return;
    const observer = new IntersectionObserver(
      ([entry]) => setInView(entry.isIntersecting),
      options
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, [node, options]); // 节点变了就重新 observe

  return [ref, inView];
}

用回调 ref 把节点存进 state,节点一变 effect 就重跑、重新 observe,条件渲染的元素也能正确观察。记得此时 options 最好用 useMemo 固定,否则每次渲染都重建 observer。

小结

  • 判断「元素是否进视口」用 IntersectionObserver ,别再监听 scroll + getBoundingClientRect------后者强制同步重排,滚动必卡。
  • 封装 useInView 的骨架:effect 里 new IntersectionObserverobserve(el)return () => observer.disconnect()(卸载断开,防泄漏)。
  • rootMargin 给视口扩边,让懒加载/加载下一页提前触发 ;threshold 控制露多少才算进入。
  • 无限滚动用末尾哨兵元素 + inView,并加 loading / hasMore 三重闸门 防重复请求,追加数据用函数式更新
  • 一句话记忆点:懒加载和无限滚动是同一个问题------「元素进视口没有」,用 IntersectionObserver 一把 Hook 全解决,记得卸载时 disconnect。
相关推荐
朝阳392 小时前
react19【系列实用教程】setSearchParams
react.js
带娃的IT创业者2 小时前
Puppeteer 深度解析:超越自动化测试的现代 Web 交互范式
前端·交互·puppeteer·可观测性·浏览器自动化·无头浏览器·web工程化
程序员爱钓鱼2 小时前
Rust 所有权 Ownership 详解:理解内存安全的核心机制
前端·后端·rust
Mh10 小时前
别再只会 `find` 了:Map 在前端业务里的真实用法
前端·javascript
陈随易10 小时前
FFmpeg 9.0 发布,代号 Lei,音视频处理再升级
前端·后端·程序员
爱丶狸11 小时前
Grafana_Zabbix_ImageRenderer_部署与前端操作手册
linux·前端·zabbix·grafana·kylin
用户0595401744614 小时前
把AI对话记忆存储测试从手工改成Playwright+pytest,覆盖率从20%提到96%,回归时间缩短90%
前端·css
kyriewen14 小时前
别再这样写TypeScript了——Code Review中最常见的8个反模式
前端·javascript·typescript
名字还没想好☜14 小时前
Next.js ‘use client‘ 到底加在哪:Server/Client Components 边界与常见报错
开发语言·前端·javascript·react·next.js