陌上人如玉
公子世无双

11.8 手写无限滚动

11.8 手写无限滚动

一句话:无限滚动的推荐实现是「用 IntersectionObserver 观察一个列表底部的「哨兵元素」」——它比监听 scroll 事件更高效(不读布局、不频繁触发),配合 rootMargin 还能「提前触发」实现无感加载。

一、两种方案对比

维度 监听 scroll 事件 IntersectionObserver
触发频率 极高(每帧多次,需节流) 低(只在「进入/离开视口」时)
是否读布局 ⚠️ 常需要读 scrollTop/scrollHeight(强制同步布局) ❌ 不需要
代码复杂度 要算「距底部多少」 只需观察一个哨兵元素
提前触发 手动算阈值 ✅ rootMargin 一行搞定
性能 差(需 rAF 节流) 好
推荐 需要「精确的滚动位置」时 无限滚动首选
// ❌ scroll 事件方案(能跑,但不够好)
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
  const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;   // ⚠️ 读布局属性
  if (scrollHeight - scrollTop - clientHeight < 200) loadMore();
};
// 问题:① 每帧多次触发(需节流)② 读 scrollHeight/clientHeight 触发强制同步布局

二、IntersectionObserver 方案(推荐)

import { useCallback, useEffect, useRef } from 'react';

export interface UseInfiniteScrollOptions {
  /** 是否还有下一页 */
  hasMore: boolean;
  /** 是否正在加载(用于「防重入」) */
  loading: boolean;
  /** 提前触发的距离(如 '200px' 表示「距离底部 200px 就触发」) */
  rootMargin?: string;
  /** 滚动容器(默认视口;如果列表在「内部滚动容器」里,必须传) */
  root?: Element | null;
  /** 触发回调 */
  onLoadMore: () => void;
}

/**
 * 返回一个「哨兵 ref」——把它挂到列表底部的元素上
 */
export function useInfiniteScroll({
  hasMore,
  loading,
  rootMargin = '200px',
  root = null,
  onLoadMore,
}: UseInfiniteScrollOptions) {
  const sentinelRef = useRef<HTMLDivElement>(null);
  /** 用 ref 保存最新回调,避免「每次渲染都重建 observer」 */
  const onLoadMoreRef = useRef(onLoadMore);

  useEffect(() => {
    onLoadMoreRef.current = onLoadMore;
  });

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    // ⭐ 关键:没有更多 或 正在加载时,不注册 observer
    //    (否则会「重复触发」加载)
    if (!hasMore || loading) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        // ⭐ 双重保险:即使触发,也要再判断一次
        if (entry?.isIntersecting && !loading) {
          onLoadMoreRef.current();
        }
      },
      {
        root,                    // 滚动容器(列表在内部容器滚动时必传)
        rootMargin,              // ⭐ 提前触发
        threshold: 0,            // 只要「进入视口 1px」就触发
      }
    );

    observer.observe(sentinel);

    return () => observer.disconnect();        // ⭐ 必须清理
  }, [hasMore, loading, rootMargin, root]);    // ⭐ 依赖变化时「重新注册」(关键)

  return sentinelRef;
}
⭐ 四个关键设计:

① `hasMore` / `loading` 变化时「重新注册 observer」
   · 不在加载中 → 注册(可以触发下一次)
   · 正在加载 → 不注册(防重入)
   · 没有更多 → 不注册(不再触发)
   → 这个「依赖数组把 `loading` 也放进去」的写法是最简洁的「防重入」方案

② 用 `onLoadMoreRef` 保存最新回调
   → 避免「回调变化 → observer 重建」

③ `rootMargin: '200px'`
   → 用户还没滚到底就开始加载(体验无感)
   ⚠️ 如果列表在「内部滚动容器」里,必须传 `root`(否则 `rootMargin` 基于视口算,行为不符预期)

④ 清理时 `observer.disconnect()`
   → 否则内存泄漏 + 重复触发
   ⚠️ StrictMode 下会「注册 → 清理 → 再注册」,只要清理写了就没问题

三、完整的列表组件(四态齐全)

import { useCallback, useMemo } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';

export function InfiniteList({ keyword }: { keyword: string }) {
  const {
    data,
    isLoading,
    isError,
    error,
    refetch,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isFetchNextPageError,
  } = useInfiniteQuery({
    queryKey: ['items', keyword],
    queryFn: ({ pageParam }) => api.getItems({ cursor: pageParam, keyword }),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    staleTime: 30_000,
  });

  const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);

  const sentinelRef = useInfiniteScroll({
    hasMore: !!hasNextPage,
    loading: isFetchingNextPage,
    onLoadMore: () => void fetchNextPage(),
  });

  // ① 首次加载
  if (isLoading) return <ListSkeleton count={5} />;

  // ② 错误(首屏)
  if (isError) return <ErrorView error={error} onRetry={() => void refetch()} />;

  // ③ 空状态(⭐ 最容易漏)
  if (items.length === 0) {
    return <EmptyState text={keyword ? `没有找到与「${keyword}」相关的内容` : '暂无数据'} />;
  }

  return (
    <ul>
      {items.map((item) => (
        <Row key={item.id} item={item} />
      ))}

      {/* ⭐ 哨兵元素(滚到这里就加载下一页) */}
      <li ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />

      {/* ④ 底部状态:加载中 / 加载失败 / 没有更多 */}
      {isFetchingNextPage && <li className="loading"><Spinner /> 加载中…</li>}

      {isFetchNextPageError && (
        <li className="error">
          加载失败{' '}
          <button onClick={() => void fetchNextPage()}>重试</button>
        </li>
      )}

      {!hasNextPage && items.length > 0 && <li className="end">没有更多了</li>}
    </ul>
  );
}
⭐ 无限滚动必须写全「五种状态」:
① 首次加载(骨架)
② 首屏错误(+ 重试)
③ **空状态**(最常被漏)
④ **加载下一页中**(底部 loading)
⑤ **加载下一页失败**(+ 重试)⚠️ 不处理会让用户以为「已经到底了」
⑥ 没有更多(结束提示)

四、scroll 事件方案(对比用)

// ✅ 如果确实需要「监听滚动位置」(如「回到顶部」按钮),用 rAF 节流
export function useScrollPosition(threshold = 400) {
  const [pastThreshold, setPastThreshold] = useState(false);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    const onScroll = () => {
      if (rafRef.current !== null) return;             // ⭐ 每帧最多一次
      rafRef.current = requestAnimationFrame(() => {
        rafRef.current = null;
        setPastThreshold(window.scrollY > threshold);
      });
    };

    window.addEventListener('scroll', onScroll, { passive: true });   // ⭐ passive:不阻塞滚动
    onScroll();

    return () => {
      window.removeEventListener('scroll', onScroll);
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
  }, [threshold]);

  return pastThreshold;
}
// ✅ 「加载更多」按钮方案(无 observer 依赖,最稳、最易测、对 a11y 最友好)
function ListWithButton() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ /* ... */ });
  const items = data?.pages.flatMap((p) => p.items) ?? [];

  return (
    <>
      <ul>{items.map((it) => <Row key={it.id} item={it} />)}</ul>

      {hasNextPage ? (
        <button onClick={() => void fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? '加载中…' : '加载更多'}
        </button>
      ) : (
        <p>没有更多了</p>
      )}
    </>
  );
}
⭐ 三种方案的取舍:
· 「加载更多」按钮 —— 最稳、最易测、无 a11y 问题,但需要用户多一次点击
· IntersectionObserver 自动加载 —— 体验最顺滑(推荐),但要注意「防重入」
· scroll 事件 —— 只在「需要精确滚动位置」时用(别忘了 rAF 节流 + passive)

⭐ 最佳实践:「自动加载 + 按钮兜底」
   —— 自动加载的同时保留一个按钮(万一 observer 没触发/用户想手动加载)

五、手写 IntersectionObserver 的等价 Hook(不使用 observer 时)

// ⚠️ 只有在「必须支持很老的浏览器」时才考虑(现代项目用 observer 就好)
export function useInfiniteScrollByScroll({
  hasMore,
  loading,
  onLoadMore,
  threshold = 200,
  containerRef,
}: {
  hasMore: boolean;
  loading: boolean;
  onLoadMore: () => void;
  threshold?: number;
  containerRef: React.RefObject<HTMLElement | null>;
}) {
  const onLoadMoreRef = useRef(onLoadMore);
  useEffect(() => {
    onLoadMoreRef.current = onLoadMore;
  });

  // ⭐ 预先量好「容器高度」(避免在 scroll 回调里读布局)
  const containerHeightRef = useRef(0);
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => { containerHeightRef.current = el.clientHeight; });
    ro.observe(el);
    containerHeightRef.current = el.clientHeight;
    return () => ro.disconnect();
  }, [containerRef]);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;

    let scheduled = false;

    const onScroll = () => {
      if (scheduled) return;                      // ⭐ rAF 节流
      scheduled = true;
      requestAnimationFrame(() => {
        scheduled = false;
        if (!hasMore || loading) return;

        const { scrollTop, scrollHeight } = el;   // ⭐ 只读 scrollTop/scrollHeight
        if (scrollHeight - scrollTop - containerHeightRef.current < threshold) {
          onLoadMoreRef.current();
        }
      });
    };

    el.addEventListener('scroll', onScroll, { passive: true });     // ⭐ passive
    return () => el.removeEventListener('scroll', onScroll);
  }, [containerRef, hasMore, loading, threshold]);
}

六、滚动位置恢复

// ✅ 方案一:数据路由提供 <ScrollRestoration />(最省事)
import { ScrollRestoration } from 'react-router-dom';
<ScrollRestoration />;

// ✅ 方案二:自己记录/恢复
export function useScrollRestoration(key: string, containerRef: React.RefObject<HTMLElement | null>) {
  const positions = useRef<Record<string, number>>({});

  // 离开时记录
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    return () => {
      positions.current[key] = el.scrollTop;
    };
  }, [key, containerRef]);

  // 回来时恢复(⭐ 要在「数据就绪」之后,否则内容高度不够滚不到目标位置)
  useLayoutEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    el.scrollTop = positions.current[key] ?? 0;
  }, [key, containerRef]);
}
⚠️ 恢复滚动位置的三个前提:
① 数据必须已就绪(否则高度不够,滚不到目标位置)
② 已加载的页数要保留(无限列表的缓存不能丢)
③ 「前进/后退」与「点击链接」的行为应不同
   (前者恢复、后者回顶部)——`<ScrollRestoration />` 会自动处理

七、常见坑

// ① 用 scroll 事件但没节流 → 每帧多次 setState
// ② 在 onScroll 里读 scrollHeight/clientHeight → 强制同步布局
// ③ 忘记「防重入」(加载中又触发一次 fetchNextPage → 重复请求同一页)
// ④ 忘记「没有下一页时不再注册 observer」(一直触发无效加载)
// ⑤ observer 没有 disconnect(内存泄漏 + 重复回调)
// ⑥ 哨兵元素「在列表内但高度为 0 且没有兄弟」→ 可能触发时机异常
//    ✅ 给哨兵一个最小高度(如 1px)或用 padding 占位
// ⑦ 列表在「内部滚动容器」里但没传 root → rootMargin 基于视口计算,行为不符预期
// ⑧ 忘记「空状态」(用户看到一片空白)
// ⑨ 忘记「加载下一页失败」的重试(用户以为到底了)
// ⑩ 忘记「没有更多」的提示(用户一直滚)
// ⑪ 切换筛选条件时「没有重置」(新旧数据混在一起)
//    ✅ 只要筛选条件进了 queryKey,就是「新的查询」(自动隔离)
// ⑫ 返回列表时「滚动位置丢失」
// ⑬ 无限滚动 + 无限 DOM 增长(几万条会卡)→ 要配虚拟滚动(见 11.6)
// ⑭ 用「scroll 事件 + setTimeout 节流」而不是 rAF(不够精确)
// ⑮ 忘记「passive: true」(滚动监听阻塞滚动)
// ⑯ 在 StrictMode 下「observer 被创建两次」(必须有清理)
// ⑰ 认为「自动加载对 a11y 友好」(键盘/读屏用户可能触发不了)→ 保留按钮兜底
// ⑱ 用「IntersectionObserver 观察整个列表」而不是「底部哨兵」
//    → 语义不清(应该观察「底部哨兵」)
// ③ 的完整防重入方案(三重保险)
const sentinelRef = useInfiniteScroll({
  hasMore: !!hasNextPage,
  loading: isFetchingNextPage,        // ① 加载中不注册 observer
  onLoadMore: useCallback(() => {
    if (!hasNextPage || isFetchingNextPage) return;   // ② 回调里再判一次
    void fetchNextPage();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]),
});
// ③ useInfiniteQuery 的 fetchNextPage 自身也有「同 key 去重」

八、面试延伸

  1. 「无限滚动怎么实现?」
    推荐用 IntersectionObserver 观察一个「列表底部的哨兵元素」:在列表末尾放一个(几乎不可见的)元素,当它「进入视口」时触发 onLoadMore。关键设计有三:① 依赖里放 hasMore 与 loading——这样「没有更多」或「正在加载」时不注册 observer(天然实现防重入);② 用 rootMargin(如 '200px')提前触发(用户还没滚到底就开始加载,体验无感);③ observer.disconnect() 清理(避免泄漏与重复触发)。相比监听 scroll 事件,它不读布局属性、触发频率低、代码更简单。

  2. 「为什么用 IntersectionObserver 而不是 scroll 事件?」
    四点:① 性能——scroll 每次滚动触发多次(要节流),IntersectionObserver 只在「元素进出视口」时通知;② 避免强制同步布局——scroll 回调里常要读 scrollHeight/clientHeight(触发 layout),IntersectionObserver 不需要;③ 提前触发更容易——rootMargin 一行搞定;④ 代码更简单——不用算「距底部多少像素」,只要观察哨兵。(如果确实需要「精确的滚动位置」,如「回到顶部按钮」,那才用 scroll + rAF 节流 + passive: true。)

  3. 「怎么防止重复加载?」
    三重保险:① 依赖里放 loading——「正在加载」时不注册 observer(这是最简洁的方案,因为它让「注册/注销」与「loading 状态」同步);② 回调里再判一次(if (!hasNextPage || isFetchingNextPage) return);③ 数据层的去重——TanStack Query 的 fetchNextPage 对「同一个 queryKey 的并发调用」有去重。这样即使 observer 因某些原因「重复触发」(如浏览器的边界情况),也不会「重复请求同一页」。

  4. 「无限滚动要处理哪些状态?」
    五种(缺一不可):① 首次加载(骨架屏);② 首屏错误(+ 重试按钮);③ 空状态(最容易被漏——用户看到空白以为坏了);④ 加载下一页中(底部 loading);⑤ 加载下一页失败(+ 重试,不处理会让用户以为『已经到底了』);⑥ 没有更多(结束提示)。此外「切换筛选条件」时要保证「新查询替换旧数据」(把条件放进 queryKey 即可)。

  5. 「无限滚动对无障碍有什么影响?」
    两点:① 键盘/读屏用户可能触发不了「自动加载」——因为他们「不会滚动到哨兵」(例如用 Tab 遍历、或屏幕阅读器直接跳到「列表末尾」)。所以推荐「自动加载 + 按钮兜底」——保留一个「加载更多」按钮(可聚焦、可被读屏读到);② Ctrl+F 搜不到未加载的内容(这是固有代价,可以用「加载更多 + 明确的已加载数量提示」缓解)。此外「滚动位置恢复」对键盘用户尤其重要(否则他们要从头 Tab)。

  6. 「返回列表时怎么恢复滚动位置?」
    三种方式:① 数据路由的 <ScrollRestoration />(最省事,自动处理「前进/后退恢复、普通导航回顶部」);② 手动记录/恢复——在「离开时把 scrollTop 存到 ref 或 sessionStorage(以路径为 key)」,在「重新挂载时用 useLayoutEffect 恢复」;③ 配合无限列表时要注意「已加载的页数要保留」(缓存丢了 → 列表变短 → 滚不到目标位置),所以恢复时机必须在「数据就绪」之后。

  7. 「无限滚动和虚拟滚动的关系?」
    两者解决不同问题、可以叠加:① 无限滚动解决「什么时候加载更多数据」;② 虚拟滚动解决「DOM 节点太多导致滚动卡顿」。如果只用无限滚动,用户滚动几万条后 DOM 会「无限增长」(内存与布局成本都上升)→ 最终还是会卡。所以「长列表的终极形态」是「无限滚动(按需加载数据)+ 虚拟滚动(只渲染可视的几十个节点)」——这也是为什么 TanStack Query 的 useInfiniteQuery 常与 @tanstack/react-virtual 配合使用。

  8. 「手写一个 useInfiniteScroll 要注意什么?」
    五点:① 依赖数组里放 hasMore / loading(防重入的核心);② 用 useRef 保存 onLoadMore(避免「回调变化导致 observer 重建」);③ root 要传对——如果列表在「内部滚动容器」里,必须把那个容器作为 root(否则 rootMargin 基于视口计算,行为不符预期);④ observer.disconnect() 清理(StrictMode 双执行下尤其重要);⑤ 哨兵元素给个最小高度(如 1px),避免「高度为 0 导致触发时机异常」。

一句话速记

无限滚动首选「IntersectionObserver 观察列表底部哨兵」(不读布局、触发频率低、rootMargin 提前触发、代码简单),实现要点是「依赖里放 hasMore 与 loading(不满足就不注册 observer = 防重入)、用 useRef 保存最新回调、列表在内部容器里要传 root、disconnect() 清理、哨兵给 1px 高度」;要写全五种状态(首次加载 / 首屏错误 / 空状态 / 加载下一页中 / 加载下一页失败 + 重试 / 没有更多);保留「加载更多」按钮兜底(对键盘与读屏用户友好);「自动加载 + 按钮」是最佳组合;scroll 事件只在需要「精确滚动位置」时用(rAF 节流 + passive: true + 不在回调里读布局);无限滚动 + 虚拟滚动才是长列表的终极形态;返回列表要在数据就绪后恢复滚动位置。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.8 手写无限滚动

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫