无限滚动早年是监听 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。
登录后参与评论。