分页加载实战:从原理到性能优化的完整指南

司空可歆 优化 阅读 548
赞 17 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周改一个列表页,数据量不大,也就几千条,但前端一次性全拉回来渲染,页面直接卡成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有pagesize参数,且能正确返回分页数据。

优化后:流畅多了

改完之后,首屏加载时间从原来的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 + 合理的状态管理。有更好的方案欢迎评论区交流,比如结合虚拟滚动进一步优化,我也在研究中。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论