哨兵模式-无限滚动
前端哨兵模式(Sentinel Pattern)—— 优雅实现滚动加载
一、什么是哨兵模式?
想象你在排队买奶茶,你不知道什么时候轮到你。但如果在你前面第 3 个人身上贴了一张纸条,写着"看到我就准备点单"——这个人就是"哨兵"。
在前端开发中,哨兵模式就是在页面的某个位置放一个不可见的元素(哨兵),当用户滚动页面让这个元素进入视口时,自动触发特定操作(比如加载下一页数据)。
它的核心技术是浏览器原生 API —— IntersectionObserver。
二、原理
IntersectionObserver 是什么?
IntersectionObserver(交叉观察器)是浏览器提供的一个 API,用来异步地观察一个元素与视口(或某个祖先元素)的交叉状态。
简单说:它能告诉你——"某个元素是否出现在了屏幕上"。
工作流程
┌─────────────────────────────────────┐
│ 可视区域(视口) │
│ │
│ ┌─────────────────────────────┐ │
│ │ 已加载的列表项 │ │
│ │ ... │ │
│ │ 列表项 N │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 🚨 哨兵元素(高度 1px) │ ← 当它进入视口,触发回调
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
↓ 触发回调
fetchNextPage() → 加载更多数据
↓ 新数据渲染
哨兵被推到新列表底部 → 等待下次进入视口
关键:每次新数据渲染后,哨兵自然地被推到列表最底部,形成一个自动循环:滚到底 → 加载 → 哨兵下移 → 再滚到底 → 再加载…
三、规则
使用哨兵模式时,需要遵守以下规则:
| 规则 | 说明 |
|---|---|
| 1. 哨兵元素必须始终在列表末尾 | 只有在最后面,用户滚到底才能触发 |
| 2. 防止重复触发 | 加载中时不要重复请求,用 loading 状态锁住 |
| 3. 有数据才放哨兵 | 没有数据或已加载完毕时,不渲染哨兵元素 |
| 4. 及时断开观察 | 组件卸载或条件变化时调用 observer.disconnect() 防止内存泄漏 |
| 5. 依赖项要完整 |
useEffect 的依赖数组要包含所有会影响是否加载的状态 |
| 6. 哨兵尽量小 | 高度 1px 即可,不要影响布局和用户体验 |
四、用法
基础用法(React + TypeScript)
import { useRef, useEffect, useState } from 'react';
function InfiniteList() {
const [list, setList] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// 1️⃣ 创建哨兵元素的 ref
const sentinelRef = useRef<HTMLDivElement | null>(null);
// 2️⃣ 加载数据的函数
const fetchData = async (p: number) => {
if (loading) return;
setLoading(true);
try {
const res = await fetch(`/api/list?page=${p}`);
const data = await res.json();
setList((prev) => [...prev, ...data.items]);
setHasMore(data.items.length === 20);
setPage(p);
} finally {
setLoading(false);
}
};
// 3️⃣ 设置 IntersectionObserver
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
// 当哨兵进入视口,且满足加载条件
if (entries[0].isIntersecting && hasMore && !loading) {
fetchData(page + 1);
}
},
{ threshold: 0.1 } // 哨兵露出 10% 就触发
);
observer.observe(el);
// 4️⃣ 清理:组件卸载或依赖变化时断开观察
return () => observer.disconnect();
}, [hasMore, loading, page]);
return (
<div>
{list.map((item, i) => (
<div key={i} className="list-item">{item}</div>
))}
{/* 加载中提示 */}
{loading && <div className="loading">加载中...</div>}
{/* 5️⃣ 哨兵元素:有更多数据时才渲染 */}
{hasMore && list.length > 0 && (
<div ref={sentinelRef} style={{ height: 1 }} />
)}
{/* 没有更多了 */}
{!hasMore && <div className="no-more">没有更多了</div>}
</div>
);
}
threshold 参数说明
new IntersectionObserver(callback, {
threshold: 0.1, // 元素露出 10% 时触发(推荐)
// threshold: 0, // 元素刚刚出现就触发
// threshold: 1.0, // 元素完全可见才触发
// rootMargin: '0px 0px 200px 0px', // 提前 200px 触发(预加载)
});
💡 小技巧:设置
rootMargin: '0px 0px 200px 0px'可以让用户还没滚到底部就提前加载,体验更流畅。
五、适用场景
✅ 适合使用哨兵模式的场景
| 场景 | 说明 |
|---|---|
| 长列表滚动加载 | 商品列表、新闻流、聊天记录等 |
| 瀑布流加载 | 图片瀑布流、Pinterest 风格布局 |
| 分页数据替代方案 | 用无限滚动代替传统"上一页/下一页" |
| 图片懒加载 | 图片进入视口才开始加载 src |
| 曝光埋点 | 元素出现在屏幕上时上报埋点数据 |
| 动画触发 | 元素滚动到可视区域时播放动画 |
❌ 不适合的场景
| 场景 | 原因 |
|---|---|
| 数据量极少(< 1 页) | 没有分页需求,多此一举 |
| 需要精确跳转到某页 | 无限滚动无法直接跳到第 N 页 |
| SEO 要求高的页面 | 动态加载的内容不利于搜索引擎抓取 |
| 需要"回到顶部"后保持位置 | 无限滚动在页面刷新后无法恢复滚动位置 |
六、举个生活化的例子 🌰
场景:自助火锅的传送带
想象你在吃回转寿司:
- 传送带 = 你的页面可滚动区域
- 寿司盘子 = 一条条数据
- 你的座位前方 = 视口(你能看到的区域)
- 最后一个盘子后面的"加菜牌" = 🚨 哨兵元素
当传送带转啊转,"加菜牌"经过你面前时,后厨就知道:盘子快被拿完了,赶紧做新的放上来!
- 后厨正在做(
loading = true)→ 不会重复通知 - 盘子全上完了(
hasMore = false)→ 把"加菜牌"撤掉 - 还没开始吃(
list.length === 0)→ "加菜牌"也不需要放
这就是哨兵模式的全部思想!
七、对比传统方案
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 监听 scroll 事件 | addEventListener('scroll', ...) |
兼容性好 | 频繁触发、需要节流、计算滚动位置复杂 |
| "加载更多"按钮 | 用户手动点击 | 简单直接 | 用户体验差,需要主动操作 |
| 🚨 哨兵模式 (IntersectionObserver) | 观察哨兵元素 | 性能好、代码简洁、自动触发 | 极老浏览器不支持(IE 不支持) |
性能对比
scroll 事件:每秒可能触发 60+ 次 → 需要 throttle/debounce
哨兵模式: 只在交叉状态变化时触发 → 天然高性能 🚀
八、注意事项
-
浏览器兼容性:
IntersectionObserver在现代浏览器中均支持(Chrome 51+、Safari 12.1+)。如需兼容老浏览器,可引入 polyfill:
npm install intersection-observer
-
避免闪烁:如果页面初始内容不够长(不足以滚动),哨兵会立即可见并触发加载,这其实是正确行为——它会连续加载直到内容填满屏幕或没有更多数据。
-
配合
useCallback:如果fetchData函数作为依赖传入useEffect,建议用useCallback包裹,避免不必要的 observer 重建。
总结
哨兵模式 = 放一个隐形元素在底部 + 用 IntersectionObserver 监听它是否出现 + 出现就加载数据
三句话,就是全部核心。剩下的只是条件判断和状态管理。它是目前前端实现无限滚动最优雅、性能最好的方案。