本文是「从零打造 AI 全栈应用」系列第 九 篇。
在上一篇中,我们完成了 图片懒加载与静态资源服务,解决了“图片什么时候加载、从哪里访问”的问题。
这一篇,我们继续解决列表性能的另一半:
无限滚动(Infinite Scroll) 。
如果你正在做:
- 文章列表 / 信息流
- AI 内容生成结果列表
- 评论流 / 动态流
那么 Infinite Scroll 几乎是一个绕不开的基础能力。
一、为什么一定要做无限滚动?
很多初学者会觉得:
“后端不是已经分页了吗?前端点「下一页」不就行了?”
这在真实产品中几乎不可接受。
1️⃣ 用户体验角度
- 手动分页会不断打断阅读
- 移动端操作成本极高
2️⃣ 性能角度
- 一次性加载全部数据不可行
- 页面节点数会迅速膨胀
无限滚动 = 分页加载 + 连续体验。
二、Infinite Scroll 的本质是什么?
先给结论:
Infinite Scroll 本质上是:自动触发分页请求。
它并没有改变后端模型:
GET /api/posts?page=1&limit=10
GET /api/posts?page=2&limit=10
变化的只是:
- 触发“下一页”的方式
- 从“点击按钮” → “滚动到底”
三、核心思想:哨兵节点(Sentinel)
无限滚动的关键,不是滚动本身,而是:
“什么时候该加载下一页?”
现代前端的标准答案是:
哨兵节点 + IntersectionObserver
- 在列表底部放一个空元素
- 观察它是否进入视窗
- 一旦进入,触发
loadMore
这比监听 scroll 更精准,也更高效。
四、为什么要封装成通用组件?
如果你在每个列表页面都写一遍:
- Observer
- loading 判断
- hasMore 判断
那是明显的工程能力不足。
一个合格的做法是:
把“滚动逻辑”与“列表内容”彻底解耦。
五、InfiniteScroll 组件的设计目标
在开始写代码前,先明确组件职责:
- ✅ 负责“什么时候加载”
- ❌ 不关心“加载什么数据”
- ❌ 不关心“列表长什么样”
因此它的 API 应该是:
children:具体列表内容(高度定制)onLoadMore:加载更多的抽象hasMore:是否还有数据isLoading:防止重复触发
六、InfiniteScroll 通用组件实现
1️⃣ Props 设计
interface InfiniteScrollProps {
hasMore: boolean;
isLoading?: boolean;
onLoadMore: () => void;
children: React.ReactNode;
}
这是一个非常干净的接口定义。
2️⃣ 哨兵节点与 useRef
const sentinelRef = useRef<HTMLDivElement>(null);
原因很简单:
React 不鼓励直接操作 DOM,但允许通过 ref 精确访问。
3️⃣ IntersectionObserver 核心逻辑
useEffect(() => {
if (!hasMore || isLoading) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
onLoadMore();
}
}, {
threshold: 0.0,
});
if (sentinelRef.current) {
observer.observe(sentinelRef.current);
}
return () => {
if (sentinelRef.current) {
observer.unobserve(sentinelRef.current);
}
};
}, [onLoadMore, hasMore, isLoading]);
这段代码体现了几个专业点:
threshold: 0.0:刚进入视窗就触发hasMore:防止无意义请求isLoading:避免并发触发- 清理 observer:防止内存泄漏
这是“能上线”的无限滚动写法。
4️⃣ 组件返回结构
<>
{children}
<div ref={sentinelRef} className="h-4" />
{isLoading && (
<div className="text-center py-4 text-sm text-muted-foreground">
加载中...
</div>
)}
</>
这里的设计非常关键:
- 哨兵节点永远在列表底部
- loading UI 与滚动逻辑解耦
七、InfiniteScroll 如何与后端分页联动?
InfiniteScroll 只负责触发:
onLoadMore();
而真正的数据逻辑应该在业务层:
- page + 1
- 调用
/api/posts?page=2&limit=10 - 拼接列表数据
- 更新 hasMore / isLoading
职责边界清晰,是组件可复用的前提。
八、Infinite Scroll vs 虚拟列表
这是一个非常常见的面试问题。
| 场景 | 方案 |
|---|---|
| 数据量中等(几十~几百) | Infinite Scroll |
| 超大列表(上万) | 虚拟列表 |
Infinite Scroll 解决的是“加载策略”,不是“渲染性能”。
下一篇我们会专门讲虚拟列表。
九、面试官视角:你该怎么讲?
如果我问你:
“你们项目的无限滚动是怎么实现的?”
一个成熟的回答应该包括:
- 后端仍然是分页接口
- 前端通过哨兵节点触发加载
- 使用 IntersectionObserver
- hasMore / isLoading 的控制
- 封装成通用组件
能讲到这里,说明你具备中高级前端的工程意识。
最后
Infinite Scroll 看起来只是一个“小组件”,但它考察的是:
- API 设计能力
- 性能意识
- 抽象能力
- 与后端协作的边界感
这一篇,已经明显不是“初级前端”的内容了。
我们下一篇见。