
一句话:无限滚动的推荐实现是「用
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 去重」
八、面试延伸
- 「无限滚动怎么实现?」
推荐用IntersectionObserver观察一个「列表底部的哨兵元素」:在列表末尾放一个(几乎不可见的)元素,当它「进入视口」时触发onLoadMore。关键设计有三:① 依赖里放hasMore与loading——这样「没有更多」或「正在加载」时不注册 observer(天然实现防重入);② 用rootMargin(如'200px')提前触发(用户还没滚到底就开始加载,体验无感);③observer.disconnect()清理(避免泄漏与重复触发)。相比监听scroll事件,它不读布局属性、触发频率低、代码更简单。 -
「为什么用
IntersectionObserver而不是scroll事件?」
四点:① 性能——scroll每次滚动触发多次(要节流),IntersectionObserver只在「元素进出视口」时通知;② 避免强制同步布局——scroll回调里常要读scrollHeight/clientHeight(触发 layout),IntersectionObserver不需要;③ 提前触发更容易——rootMargin一行搞定;④ 代码更简单——不用算「距底部多少像素」,只要观察哨兵。(如果确实需要「精确的滚动位置」,如「回到顶部按钮」,那才用scroll+ rAF 节流 +passive: true。) -
「怎么防止重复加载?」
三重保险:① 依赖里放loading——「正在加载」时不注册observer(这是最简洁的方案,因为它让「注册/注销」与「loading 状态」同步);② 回调里再判一次(if (!hasNextPage || isFetchingNextPage) return);③ 数据层的去重——TanStack Query 的fetchNextPage对「同一个queryKey的并发调用」有去重。这样即使observer因某些原因「重复触发」(如浏览器的边界情况),也不会「重复请求同一页」。 -
「无限滚动要处理哪些状态?」
五种(缺一不可):① 首次加载(骨架屏);② 首屏错误(+ 重试按钮);③ 空状态(最容易被漏——用户看到空白以为坏了);④ 加载下一页中(底部 loading);⑤ 加载下一页失败(+ 重试,不处理会让用户以为『已经到底了』);⑥ 没有更多(结束提示)。此外「切换筛选条件」时要保证「新查询替换旧数据」(把条件放进queryKey即可)。 -
「无限滚动对无障碍有什么影响?」
两点:① 键盘/读屏用户可能触发不了「自动加载」——因为他们「不会滚动到哨兵」(例如用Tab遍历、或屏幕阅读器直接跳到「列表末尾」)。所以推荐「自动加载 + 按钮兜底」——保留一个「加载更多」按钮(可聚焦、可被读屏读到);②Ctrl+F搜不到未加载的内容(这是固有代价,可以用「加载更多 + 明确的已加载数量提示」缓解)。此外「滚动位置恢复」对键盘用户尤其重要(否则他们要从头Tab)。 -
「返回列表时怎么恢复滚动位置?」
三种方式:① 数据路由的<ScrollRestoration />(最省事,自动处理「前进/后退恢复、普通导航回顶部」);② 手动记录/恢复——在「离开时把scrollTop存到ref或sessionStorage(以路径为 key)」,在「重新挂载时用useLayoutEffect恢复」;③ 配合无限列表时要注意「已加载的页数要保留」(缓存丢了 → 列表变短 → 滚不到目标位置),所以恢复时机必须在「数据就绪」之后。 -
「无限滚动和虚拟滚动的关系?」
两者解决不同问题、可以叠加:① 无限滚动解决「什么时候加载更多数据」;② 虚拟滚动解决「DOM 节点太多导致滚动卡顿」。如果只用无限滚动,用户滚动几万条后 DOM 会「无限增长」(内存与布局成本都上升)→ 最终还是会卡。所以「长列表的终极形态」是「无限滚动(按需加载数据)+ 虚拟滚动(只渲染可视的几十个节点)」——这也是为什么 TanStack Query 的useInfiniteQuery常与@tanstack/react-virtual配合使用。 -
「手写一个
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+ 不在回调里读布局);无限滚动 + 虚拟滚动才是长列表的终极形态;返回列表要在数据就绪后恢复滚动位置。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!