哨兵模式-无限滚动

前端哨兵模式(Sentinel Pattern)------ 优雅实现滚动加载

一、什么是哨兵模式?

想象你在排队买奶茶,你不知道什么时候轮到你。但如果在你前面第 3 个人身上贴了一张纸条,写着"看到我就准备点单"------这个人就是"哨兵"

在前端开发中,哨兵模式 就是在页面的某个位置放一个不可见的元素(哨兵),当用户滚动页面让这个元素进入视口时,自动触发特定操作(比如加载下一页数据)。

它的核心技术是浏览器原生 API ------ IntersectionObserver


二、原理

IntersectionObserver 是什么?

IntersectionObserver(交叉观察器)是浏览器提供的一个 API,用来异步地观察一个元素与视口(或某个祖先元素)的交叉状态

简单说:它能告诉你------"某个元素是否出现在了屏幕上"。

工作流程

scss 复制代码
┌─────────────────────────────────────┐
│            可视区域(视口)            │
│                                     │
│   ┌─────────────────────────────┐   │
│   │        已加载的列表项         │   │
│   │        ...                  │   │
│   │        列表项 N              │   │
│   └─────────────────────────────┘   │
│                                     │
│   ┌─────────────────────────────┐   │
│   │  🚨 哨兵元素(高度 1px)      │ ← 当它进入视口,触发回调
│   └─────────────────────────────┘   │
│                                     │
└─────────────────────────────────────┘
         ↓ 触发回调
    fetchNextPage()  → 加载更多数据
         ↓ 新数据渲染
    哨兵被推到新列表底部 → 等待下次进入视口

关键 :每次新数据渲染后,哨兵自然地被推到列表最底部,形成一个自动循环:滚到底 → 加载 → 哨兵下移 → 再滚到底 → 再加载...


三、规则

使用哨兵模式时,需要遵守以下规则:

规则 说明
1. 哨兵元素必须始终在列表末尾 只有在最后面,用户滚到底才能触发
2. 防止重复触发 加载中时不要重复请求,用 loading 状态锁住
3. 有数据才放哨兵 没有数据或已加载完毕时,不渲染哨兵元素
4. 及时断开观察 组件卸载或条件变化时调用 observer.disconnect() 防止内存泄漏
5. 依赖项要完整 useEffect 的依赖数组要包含所有会影响是否加载的状态
6. 哨兵尽量小 高度 1px 即可,不要影响布局和用户体验

四、用法

基础用法(React + TypeScript)

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 参数说明

typescript 复制代码
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 要求高的页面 动态加载的内容不利于搜索引擎抓取
需要"回到顶部"后保持位置 无限滚动在页面刷新后无法恢复滚动位置

六、举个生活化的例子 🌰

场景:自助火锅的传送带

想象你在吃回转寿司

  1. 传送带 = 你的页面可滚动区域
  2. 寿司盘子 = 一条条数据
  3. 你的座位前方 = 视口(你能看到的区域)
  4. 最后一个盘子后面的"加菜牌" = 🚨 哨兵元素

当传送带转啊转,"加菜牌"经过你面前时,后厨就知道:盘子快被拿完了,赶紧做新的放上来!

  • 后厨正在做(loading = true)→ 不会重复通知
  • 盘子全上完了(hasMore = false)→ 把"加菜牌"撤掉
  • 还没开始吃(list.length === 0)→ "加菜牌"也不需要放

这就是哨兵模式的全部思想!


七、对比传统方案

方案 实现方式 优点 缺点
监听 scroll 事件 addEventListener('scroll', ...) 兼容性好 频繁触发、需要节流、计算滚动位置复杂
"加载更多"按钮 用户手动点击 简单直接 用户体验差,需要主动操作
🚨 哨兵模式 (IntersectionObserver) 观察哨兵元素 性能好、代码简洁、自动触发 极老浏览器不支持(IE 不支持)

性能对比

bash 复制代码
scroll 事件:每秒可能触发 60+ 次 → 需要 throttle/debounce
哨兵模式:  只在交叉状态变化时触发 → 天然高性能 🚀

八、注意事项

  1. 浏览器兼容性IntersectionObserver 在现代浏览器中均支持(Chrome 51+、Safari 12.1+)。如需兼容老浏览器,可引入 polyfill:
bash 复制代码
npm install intersection-observer
  1. 避免闪烁:如果页面初始内容不够长(不足以滚动),哨兵会立即可见并触发加载,这其实是正确行为------它会连续加载直到内容填满屏幕或没有更多数据。

  2. 配合 useCallback :如果 fetchData 函数作为依赖传入 useEffect,建议用 useCallback 包裹,避免不必要的 observer 重建。


总结

哨兵模式 = 放一个隐形元素在底部 + 用 IntersectionObserver 监听它是否出现 + 出现就加载数据

三句话,就是全部核心。剩下的只是条件判断和状态管理。它是目前前端实现无限滚动最优雅、性能最好的方案。

相关推荐
爱分享的鱼鱼1 小时前
Element Plus 日期选择器(DatePicker)深度解析:从基础用法到高级定制
前端
evle2 小时前
从 Recoil 的兴衰看前端状态管理的技术选型
前端·react.js
恋猫de小郭2 小时前
Flutter 2026 Roadmap 发布,未来计划是什么?
android·前端·flutter
时清云2 小时前
2025-年终总结
前端
Esaka_Forever2 小时前
Promise resolve 的基础用法
前端·javascript
a1117762 小时前
卡通风格 UI 组件库html (TRIZ UI Kit [特殊字符])
前端·ui·html
鳄鱼杆3 小时前
虚拟机 | 如何通过域名访问虚拟机中的Web服务?
前端
We་ct3 小时前
LeetCode 236. 二叉树的最近公共祖先:两种解法详解(递归+迭代)
前端·数据结构·算法·leetcode·typescript
用泥种荷花3 小时前
【LangChain.js学习】 提示词模板
前端