用 IntersectionObserver 做无限滚动列表(不卡、不漏、可中断)

无限滚动早年是监听 scroll 事件 + 算 scrollTop,性能差且容易漏触发。
IntersectionObserver 是浏览器原生的 "元素进入视口" 通知机制,
节能 + 精准。

1. 最小实现

<ul id="list"></ul>
<div id="sentinel" style="height: 1px"></div>
const list = document.getElementById('list')
const sentinel = document.getElementById('sentinel')
let page = 0
let loading = false
let done = false

async function fetchPage(n) {
  const res = await fetch(`/api/items?page=${n}`)
  return res.json()  // { items: [...], hasMore: true }
}

async function loadMore() {
  if (loading || done) return
  loading = true
  const data = await fetchPage(++page)
  data.items.forEach(item => {
    const li = document.createElement('li')
    li.textContent = item.title
    list.appendChild(li)
  })
  if (!data.hasMore) done = true
  loading = false
}

const observer = new IntersectionObserver(
  entries => {
    if (entries[0].isIntersecting) loadMore()
  },
  { rootMargin: '300px' }   // 距视口 300px 时就触发,提前预加载
)

observer.observe(sentinel)
loadMore()   // 启动

关键点:

  • sentinel 元素:放在列表末尾的一个空 div,作为"快到底"的探测点
  • rootMargin:把视口边界往外扩 300px,提前触发,用户感受不到等待
  • loading guard:防止滚动太快连续触发同一次加载

2. React 版

import { useEffect, useRef, useState, useCallback } from 'react'

export function useInfinite<T>(fetchFn: (page: number) => Promise<{items: T[], hasMore: boolean}>) {
  const [items, setItems] = useState<T[]>([])
  const [page, setPage] = useState(0)
  const [loading, setLoading] = useState(false)
  const [done, setDone] = useState(false)
  const sentinelRef = useRef<HTMLDivElement | null>(null)

  const loadMore = useCallback(async () => {
    if (loading || done) return
    setLoading(true)
    const next = page + 1
    const data = await fetchFn(next)
    setItems(prev => [...prev, ...data.items])
    setPage(next)
    if (!data.hasMore) setDone(true)
    setLoading(false)
  }, [loading, done, page, fetchFn])

  useEffect(() => {
    const el = sentinelRef.current
    if (!el) return
    const obs = new IntersectionObserver(
      entries => { if (entries[0].isIntersecting) loadMore() },
      { rootMargin: '300px' }
    )
    obs.observe(el)
    return () => obs.disconnect()
  }, [loadMore])

  return { items, loading, done, sentinelRef }
}

使用:

function FeedPage() {
  const { items, loading, done, sentinelRef } = useInfinite(p =>
    fetch(`/api/feed?page=${p}`).then(r => r.json())
  )
  return (
    <>
      {items.map(i => <Item key={i.id} {...i} />)}
      <div ref={sentinelRef} />
      {loading && <Spinner />}
      {done && <div>到底了</div>}
    </>
  )
}

3. 处理"立即可见的 sentinel"

如果列表初始就短,sentinel 一开始就在视口里,IntersectionObserver
立即触发 → 立即触发下一页 → 加载完 sentinel 仍在视口 → 又触发……
循环到 done 才停。

通常没问题(连续加载到首屏满),但如果数据源慢,会发出一堆并发请求。
解决:保留 loading guard + 让 loadMore 在 fetch 完成前不允许并发,
就够了。

更稳的做法:用 useCallback + 检查 loading 状态。React 18 严格模式
下会双调用,loading 必须用 ref 而不是 state:

const loadingRef = useRef(false)
const loadMore = useCallback(async () => {
  if (loadingRef.current || done) return
  loadingRef.current = true
  try { /* ... */ } finally { loadingRef.current = false }
}, [done])

4. 中断 / 卸载安全

组件卸载时正在 fetch:要么 abort,要么忽略结果。

useEffect(() => {
  const ctrl = new AbortController()
  fetch('/api/...', { signal: ctrl.signal })
    .then(r => r.json())
    .then(data => { /* setState */ })
    .catch(e => { if (e.name !== 'AbortError') console.error(e) })
  return () => ctrl.abort()
}, [page])

5. virtualized 列表的配合

万条以上的列表,光无限滚动还不够,DOM 节点太多会卡。配 react-virtuoso
或 react-window 做虚拟滚动,只渲染视口内的几十行。Virtuoso 自带
endReached 回调,IntersectionObserver 都省了。

6. 浏览器兼容

IntersectionObserver 在 Safari 12.1+ 全支持,2024 年的所有目标浏览器都行。
不再需要 polyfill。

踩过的坑

  • 忘了 observer.disconnect() → 内存泄漏。React 用 cleanup 函数;
    vanilla JS 用一个全局 observer 在路由切换时手动 disconnect。
  • sentinel 是 display: none → 永远不触发。要给它真实尺寸(至少 1px)。
  • 滚动容器不是 window 时(例如内嵌可滚动的 div),要给 IntersectionObserver
    root: scrollContainer,否则 viewport 默认是 window。
  • 移动端慢网下重复触发:用户慢慢滑,每次 sentinel 进入视口都触发;
    服务端在还没响应时收到多个相同 page 请求。后端用幂等返回 OK。
精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。