我的项目实战(八)- 无限滚动加载组件与性能优化,图片懒加载

90 阅读7分钟

在现代前端开发中,用户对页面性能和体验的要求越来越高。尤其是在内容型应用(如资讯、社区、博客)中,首页往往需要展示大量文章列表,如何高效地加载数据和资源,成为提升用户体验的关键。

你可能觉得:“不就是渲染个列表吗?map 一下就完事了。”
但当你真正去做一个体验流畅、性能稳定、可复用的列表页时,会发现里面藏着不少细节:

  • 数据太多是一次性全加载,还是分批拉?
  • 滚动到底部如何触发下一页?直接监听 onscroll 可靠吗?
  • 图片很多会不会拖慢首屏?要不要做懒加载?
  • 组件能不能抽出来给别人用?

之前我们聊了后端数据的分页请求业务,今天继续拓展下前端的详细实现:首页文章列表的实现。 本文将围绕一个典型的“首页文章列表”场景,深入剖析 无限滚动加载图片懒加载 的实现原理与最佳实践。我们不堆砌代码,而是从问题出发,层层递进,带你理解每一个设计背后的思考。


一、为什么需要无限滚动?

传统分页模式虽然简单直观,但在移动端或信息流场景下显得割裂且操作频繁。相比之下,“无限滚动”更符合用户的浏览习惯——向下滚动自动加载新内容,体验流畅自然。

但实现它并不只是“滚动到底就请求数据”这么简单。直接监听 window.onscroll 事件会带来严重的性能问题:滚动过程中事件触发过于频繁,可能导致卡顿甚至内存泄漏。

更优雅的选择:Intersection Observer API

浏览器原生提供了 Intersection Observer 接口,用于异步观察目标元素与其祖先容器或视窗的交叉状态。它的优势在于:

  • 非同步执行:不会阻塞主线程。
  • 自动节流:浏览器内部优化了检测频率。
  • 精准控制:可以设置阈值(threshold),决定何时触发回调。

这正是我们构建通用无限滚动组件的基础。


二、封装一个可复用的 InfiniteScroll 组件

为了提高代码复用性,我们将无限滚动逻辑抽象为一个通用组件 <InfiniteScroll>,它可以包裹任何列表结构,并在其底部监听“是否接近可视区域”,从而触发加载更多数据的操作。

核心思路:使用“哨兵元素”

所谓“哨兵元素”(sentinel),是一个隐藏在列表末尾的空 div 元素,专门用来被 IntersectionObserver 监听。当这个元素进入视窗时,说明用户已经快滚到底部了,此时就可以发起下一页请求。

const sentinelRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  if (!hasMore || isLoading) return;

  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) {
      onLoadMore();
    }
  }, { threshold: 0 });

  if (sentinelRef.current) {
    observer.observe(sentinelRef.current);
  }

  return () => observer.disconnect();
}, [hasMore, isLoading, onLoadMore]);

关键点解读:

  1. threshold: 0
    表示只要哨兵元素有一像素出现在视窗内,就会触发回调。这样能尽早发起请求,避免用户看到空白。

  2. 依赖控制防重复加载
    我们通过 hasMore 控制是否还有数据可拉取,isLoading 防止在加载中再次触发请求,避免重复调用接口。

  3. 清理副作用
    useEffect 返回函数中调用 observer.disconnect(),确保组件卸载时解除监听,防止内存泄漏。

  4. ref 的作用
    React 不推荐直接操作 DOM,但我们可以通过 useRef 安全地引用真实节点,供 IntersectionObserver 使用。

这样的设计让 <InfiniteScroll> 成为一个真正意义上的“通用容器”——你传入任意列表结构,它都能为其附加无限滚动能力。


三、结合业务:Home 页面的数据加载流程

回到我们的首页组件 Home.tsx,它是如何利用上述机制完成数据加载的?

状态管理驱动 UI 更新

我们使用 Zustand 创建了一个全局 store —— useHomeStore,集中管理以下状态:

  • banners: 轮播图数据
  • posts: 当前已加载的文章列表
  • hasMore: 是否还有更多数据
  • loading: 加载状态标识
  • loadMore(): 加载下一页的方法

首次进入页面时,在 useEffect 中调用一次 loadMore(),触发初始数据拉取:

useEffect(() => {
  loadMore();
}, []);

之后每次滚动到底部,都会再次调用该方法,拼接新的 page 参数请求后端接口(例如 /api/posts?page=2&limit=10),并将结果追加到原有列表中。

这种“按需加载 + 数据拼接”的方式,既节省了首屏时间,又保证了后续内容的平滑呈现。


四、细节打磨:PostItem 中的内容展示优化

列表中的每一项 <PostItem> 并不只是简单的文字堆叠,而是经过精心排版与交互处理的结果。

1. 结构清晰的信息层级

每篇文章包含标题、摘要、作者头像、标签、阅读量、点赞数等字段。合理的布局帮助用户快速获取关键信息:

  • 标题使用 line-clamp-1 实现单行省略,避免长文本撑开布局;
  • 摘要同样截断显示,保持行高统一;
  • 使用 <Badge> 展示标签,视觉上突出分类属性;
  • 图标 + 数字组合展示互动数据(浏览、点赞),简洁明了。

这些细节共同构成了专业的内容卡片样式。

2. 支持点击跳转的交互行为

通过 useNavigate() 实现路由跳转:

onClick={() => navigate(`/post/${post.id}`)}

采用动态路由 /post/:id 的形式,使得每篇文章都有唯一的 URL,便于分享和 SEO。

更重要的是,这种 RESTful 风格的路径具有良好的语义性,体现了“资源即 URL”的设计理念。


五、性能优化重点:图片懒加载的实现

在含有缩略图的文章列表中,如果所有图片都在首屏加载,会造成大量不必要的网络请求,拖慢整体渲染速度。

解决办法就是——懒加载(Lazy Load)。

原理回顾:延迟加载视窗外的图片

核心思想是:

只有当图片即将进入可视区域时,才将其真实地址赋给 src 属性,触发请求。

我们可以手动实现,也可以借助成熟的库。项目中选用了 react-lazy-load

<LazyLoad className="w-full h-full">
  <img loading="lazy" src={post.thumbnail} alt="" />
</LazyLoad>

注意这里同时使用了两个“懒”:

  • loading="lazy" 是 HTML5 原生支持的属性,告诉浏览器延迟加载该图片;
  • <LazyLoad> 是第三方组件,基于 IntersectionObserver 提供更精细的控制能力。

两者结合,形成双重保障:即使某些旧浏览器不支持 loading="lazy"LazyLoad 也能兜底处理。

对比手写实现:lazy-load.html 示例分析

原始 HTML 示例展示了纯 JS 实现的懒加载过程:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src; // 替换真实地址
      observer.unobserve(img);   // 加载后取消监听
    }
  });
});

这种方式非常经典,也揭示了底层原理。但在 React 中直接操作 DOM 显得不够“React 风格”。因此,我们更倾向于使用声明式组件来封装这类逻辑,保持代码的一致性和可维护性。


六、总结:从功能到架构的设计思考

通过这个实战案例,我们可以提炼出几点有价值的工程经验:

问题解法优点注意事项
滚动事件太频繁使用 IntersectionObserver性能好、无需手动节流需 polyfill 支持低版本浏览器
列表加载耦合业务抽象为 <InfiniteScroll> 组件复用性强、职责分离需正确传递 hasMore, isLoading
图片过多影响首屏懒加载 + 占位图策略提升首屏速度小心错位或抖动问题
数据状态混乱使用 Zustand 统一管理状态集中、调试方便避免过度共享状态

这套方案已在实际项目中验证有效,适用于大多数内容流类产品。你可以将其作为模板,迁移到自己的项目中。


写在最后

技术的价值不在炫技,而在解决问题。今天我们没有追求最前沿的技术栈,也没有引入复杂的动画效果,而是专注于把基础功能做扎实:让用户顺畅地浏览内容,让系统稳定地响应请求。

真正的前端工程化,往往体现在这些看似平凡却至关重要的细节之中。

如果你也在做类似的列表页,不妨试试这套组合拳:Zustand + IntersectionObserver + LazyLoad + 语义化结构。你会发现,好的用户体验,其实藏在每一次平滑的滚动里。