前端分页加载选型错了,热点列表要么翻页打断浏览,要么滚到底卡成幻灯片。生产环境里真正稳的组合是:API 用游标分页(cursor-based)做数据切片,前端用无限滚动自动续加载,再用虚拟列表把 DOM 节点数钉死在几十个。下文对比三种方案并给出可直接套用的实现。
一、三种分页加载方案怎么选
先看场景,再定方案:
| 方案 | 交互特征 | DOM 压力 | 适用场景 |
|---|---|---|---|
| 传统分页 | 页码跳转,打断浏览 | 低 | 检索表、后台、需分享第 N 页 |
| 无限滚动 | 滑到底自动续加 | 高(不控制会爆) | 内容消费、社媒流 |
| 虚拟列表 | 只渲染可视区 | 恒定(约 30 节点) | 万级数据表格 |
传统分页把页码写进 URL,可收藏、可后退、对读屏友好,但「探索式浏览」里体验断层。无限滚动沉浸感强,可数据一多 DOM 疯长。虚拟列表从根上解决节点数,但实现成本偏高。监控长列表的正确答案不是二选一,而是把它们叠起来用。
二、API 先用游标分页做切片
基于偏移量(offset)的分页在实时变动的列表上会丢数据或重复——新插入的热点把后面的行整体挤后,offset 就错位了。游标分页让后端返回 nextCursor,前端拿它去要下一页,天然避开并发插入的竞态。
async function fetchHotspots(cursor?: string) {
const p = new URLSearchParams();
if (cursor) p.set('cursor', cursor);
const res = await fetch(`/api/hotspots?${p}`);
const data = await res.json();
return { items: data.items, nextCursor: data.nextCursor ?? undefined };
}
三、用 IntersectionObserver 做无限滚动
无限滚动的核心是「在用户到顶之前就把下一页请求发掉」。传统 scroll 事件每像素都同步触发、还要手写位置计算,性能差。IntersectionObserver 由浏览器异步批量判定元素是否进入视口,开销低得多。
下沉一个 1px 哨兵元素到列表底部,用 rootMargin 把触发区向前延伸 200px——用户还没滑到尽头,请求就先飞出去,等他到底时已加载完,看不到空白。
function useInfiniteScroll(onLoad: () => void) {
const sentinel = useRef<HTMLDivElement>(null);
useEffect(() => {
const ob = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) onLoad(); },
{ rootMargin: '200px', threshold: 0 }
);
if (sentinel.current) ob.observe(sentinel.current);
return () => ob.disconnect();
}, [onLoad]);
return sentinel;
}
四、防重复触发与节流
滚动到临界点时回调可能连发,必须用锁挡住重复请求。加载中置 isLoading 标志,请求返回再放开;同时用 hasNextPage 判断还有没有下一页。
const loadMore = useCallback(() => {
if (isFetching || !hasNextPage) return;
setIsFetching(true);
fetchHotspots(cursor).then(d => {
setList(prev => [...prev, ...d.items]);
setCursor(d.nextCursor);
setIsFetching(false);
});
}, [isFetching, hasNextPage, cursor]);
给滚动类请求加防抖或节流也常见:高频 scroll 事件用 500ms 节流,输入框触发筛选则用 300ms 防抖,避免接口被刷。
五、虚拟列表把 DOM 钉死
无限滚动只管数据续加,不管渲染。滚过 500 页就可能堆出两万多个 DOM 节点,中端机直接卡死。虚拟列表只渲染可视区加一小段缓冲,靠 translateY 定位,让 DOM 数恒定。
固定高度场景下,总高度等于「条数 × 行高」,当前可见区间由滚动位置算出:
const VH = 72; // 单行高度
const totalHeight = list.length * VH;
const startIdx = Math.floor(scrollTop / VH);
const endIdx = startIdx + Math.ceil(clientHeight / VH) + 2; // 多渲染 2 行缓冲
const visible = list.slice(startIdx, endIdx);
visible.forEach((it, i) => {
it.el.style.transform = `translateY(${(startIdx + i) * VH}px)`;
});
动态高度用 ResizeObserver 实时测量再修正偏移。这套组合——游标分页供给数据、无限滚动自动续加、虚拟列表控制渲染——是长列表生产实践的黄金标准:DOM 有界、网络高效、体验顺滑。
六、落地建议
按这条路线推进,先跑通再优化:
- 后端改游标分页,返回 items 与 nextCursor;
- 前端用 IntersectionObserver 接无限滚动,下沉哨兵 + rootMargin 预触发;
- 加 isLoading 锁与 hasNextPage 判断,挡掉重复请求;
- 列表超千条时引入虚拟列表,只渲染可视区 + 缓冲;
- 长列表底部放加载指示,到底无更多时给明确文案;需要分享某一页时,再补传统分页的 URL 参数。
常见问题(FAQ)
Q1:offset 分页为什么不行?
实时插入会错位,导致重复或丢失,游标分页更稳。
Q2:哨兵元素有什么用?
放列表底部,进入视口即触发加载下一页。
Q3:无限滚动卡顿怎么救?
叠加虚拟列表,只渲染可视区节点,降 DOM 开销。