哨兵模式-无限滚动

2 阅读5分钟

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

六、举个生活化的例子 🌰

场景:自助火锅的传送带

想象你在吃回转寿司

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

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

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

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


七、对比传统方案

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

性能对比

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

八、注意事项

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

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


总结

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

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