分页加载实战:从原理到性能优化的完整指南
优化前:卡得不行
上周改一个列表页,数据量不大,也就几千条,但前端一次性全拉回来渲染,页面直接卡成PPT。滚动一下要等半秒,点个按钮半天没反应,连Chrome DevTools都差点打不开。用户反馈“像在用十年前的手机”,说实话,我自己测试的时候都想关掉页面。
一开始我以为是后端接口慢,结果看了Network面板,接口响应其实挺快,300ms左右就返回了。问题出在前端——拿到几千条数据后,用map一股脑塞进DOM,浏览器光是构建渲染树就干了4秒多。Lighthouse评分直接掉到30分,FCP(First Contentful Paint)和TTI(Time to Interactive)惨不忍睹。
找到瓶颈了!
打开Performance面板录了个操作过程,一看火焰图,好家伙,主线程被JS执行和Layout占满了。主要耗时在两块:
- 数据处理:把原始数据转成组件props,做了不少嵌套计算
- DOM渲染:一次性创建几千个列表项,每个还带图片、按钮、状态标签
我试过用React.memo和useMemo优化子组件重渲染,有点用,但治标不治本——首次加载还是卡。这时候才意识到,根本问题不是渲染效率,而是不该一次渲染这么多。
于是决定上分页加载。但不是传统的“点下一页”那种,而是滚动到底部自动加载,也就是常说的“无限滚动”(infinite scroll)。
折腾了半天,试了三种方案
第一种最简单:监听scroll事件,判断是否滚到底部,然后加载下一页。
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
loadMore();
}
});
结果发现滚动时疯狂触发loadMore,因为scroll事件太密集了。加个防抖?可以,但体验不好——用户快速滚动到底部,可能根本触发不了加载。
第二种方案:用Intersection Observer。这玩意儿天生就是干这个的,性能好还不卡主线程。立马改:
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !loading && hasMore) {
loadMore();
}
}, { threshold: 0.1 });
observer.observe(document.querySelector('#sentinel'));
这里我在列表底部放了个空div叫#sentinel,当它进入视口就触发加载。亲测有效,而且不卡。但有个坑:别忘了disconnect!不然切换页面后observer还在跑,白耗性能。我踩过两次,后来养成习惯,在useEffect里return清理函数:
useEffect(() => {
const observer = new IntersectionObserver(...);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, []);
核心代码就这几行
实际项目里我封装了个useInfiniteScroll hook,核心逻辑如下:
import { useState, useEffect, useCallback, useRef } from 'react';
function useInfiniteScroll({ fetchData, pageSize = 20 }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const pageRef = useRef(1);
const sentinelRef = useRef(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const res = await fetchData(pageRef.current, pageSize);
const newData = res.data;
setData(prev => [...prev, ...newData]);
setHasMore(newData.length === pageSize);
pageRef.current += 1;
} finally {
setLoading(false);
}
}, [fetchData, pageSize, loading, hasMore]);
useEffect(() => {
// 首次加载
loadMore();
}, []);
useEffect(() => {
if (!sentinelRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore();
},
{ threshold: 0.1 }
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [loadMore]);
return { data, loading, sentinelRef };
}
使用起来也很清爽:
function MyList() {
const fetchData = async (page, size) => {
const res = await fetch(https://jztheme.com/api/items?page=${page}&size=${size});
return res.json();
};
const { data, loading, sentinelRef } = useInfiniteScroll({ fetchData });
return (
<div>
{data.map(item => <Item key={item.id} {...item} />)}
{loading && <div>加载中...</div>}
<div ref={sentinelRef} style={{ height: '1px' }}></div>
</div>
);
}
注意几个细节:
- 用
ref存page而不是state,避免依赖导致useEffect重复执行 - loading状态要锁住,防止重复请求
- sentinel元素高度设成1px就行,不用占地方
踩坑提醒:这三点一定注意
第一,别在列表中间插入sentinel。我一开始图省事把sentinel放在列表里map出来,结果每次加载新数据都会重建整个列表,React diff成本反而更高。现在固定放在列表外面,只观察它。
第二,图片懒加载要配合做。即使分页了,如果每页20张图全加载,还是会卡。我顺手加上了loading="lazy":
<img src={item.img} loading="lazy" alt="" />
第三,服务端必须支持分页参数。如果后端只给全量数据,前端自己slice分页,那等于没优化。确保API有page和size参数,且能正确返回分页数据。
优化后:流畅多了
改完之后,首屏加载时间从原来的5.2秒直接干到800毫秒。Lighthouse评分飙到92,TTI稳定在1秒内。用户滚动时完全感觉不到卡顿,连低端安卓机都跑得很顺。
内存占用也降了不少。之前一次性渲染5000条,内存峰值到300MB;现在首屏只渲染20条,滚动过程中最多保持100条左右(配合虚拟滚动会更好,但这次没上),内存压到80MB以内。
性能数据对比
拿同一台MacBook Pro实测(Chrome 125):
- 优化前:
- 首屏加载:5200ms
- TTI:4800ms
- 内存占用:298MB
- Lighthouse性能分:31
- 优化后:
- 首屏加载:780ms
- TTI:920ms
- 内存占用:76MB
- Lighthouse性能分:92
数据不会骗人。虽然代码多了几十行,但换来的是用户体验质的飞跃。
最后说两句
这次优化其实不算复杂,核心就是“少渲染、按需加载”。分页加载不是银弹——比如需要全局搜索或排序的场景就不合适——但对于纯浏览型列表,效果立竿见影。
目前还有个小问题:快速滚动时偶尔会漏加载(因为Intersection Observer回调有延迟),不过加了个兜底逻辑——如果用户停在底部超过1秒还没数据,手动触发一次加载,基本覆盖了所有情况。
以上是我对分页加载的实战优化经验,核心就是Intersection Observer + 合理的状态管理。有更好的方案欢迎评论区交流,比如结合虚拟滚动进一步优化,我也在研究中。

暂无评论