在现代Web应用中,“无限滚动”或“上拉加载更多”是提升用户体验的标配功能。当用户滚动到页面底部时,应用会自动加载并展示新的内容,避免了传统分页带来的中断感。
传统的实现方式是监听scroll事件,并实时计算元素位置,这种方法不仅逻辑复杂,还容易因频繁触发而导致性能问题。今天,我们将利用浏览器原生的IntersectionObserver API和React Hooks,从零开始构建一个高性能、可定制且极易复用的InfiniteScroll组件。
一、最终效果:一行代码实现无限滚动
在深入代码之前,我们先看看这个组件用起来有多简单。在你的父组件中,你只需要这样做:
import React, { useState } from "react"
import InfiniteScroll from "./InfiniteScroll" // 引入我们创建的组件
const App = () => {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
// 模拟从API加载数据
const loadMoreData = async () => {
setLoading(true)
// 实际场景中,这里会是一个fetch请求
const res = await fetchSomeData(params)
setHasMore(res.data.current < res.data.pages) // 判断是否还有更多数据
setItems((prev) => [...prev, ...res.data.records]) // 追加新数据列表
setLoading(false)
}
return (
<div>
{/* 渲染你的列表 */}
{items.map((item) => (
<div key={item.id} className="item">
{item.content}
</div>
))}
{/* 核心!只需要这一行 */}
<InfiniteScroll loadMore={loadMoreData} hasMore={hasMore} loading={loading} />
</div>
)
}
是不是非常优雅?接下来,让我们一步步揭开它背后的魔法。
二、核心思想:用“哨兵”取代滚动监听
我们的核心武器是IntersectionObserver。它的工作原理不是去监听整个页面的滚动,而是去“观察”一个我们指定的DOM元素。
想象一下,我们在列表的末尾放置一个看不见的“哨兵”(Sentinel)元素。我们命令IntersectionObserver去监视这个哨兵:
- 当哨兵进入屏幕可视区域时,就意味着用户已经滚动到底部了。
- 这时,我们就触发
loadMore函数去加载新数据。
这种方式完全由浏览器在后台高效执行,避免了我们手动计算的性能开销。
三、代码深度解析:一步步构建组件
下面,我们来逐一拆解InfiniteScroll组件的内部实现。
1. 组件的“骨架”与Props设计
首先,我们定义组件的接口(Props),让它足够灵活和可定制:
interface InfiniteScrollProps {
loadMore: () => Promise<void> | void; // ✅ 必须:加载数据的函数
hasMore: boolean; // ✅ 必须:是否还有更多数据
threshold?: number; // 可选:提前多少像素加载,默认50px
loader?: ReactNode; // 可选:自定义“加载中”的UI
endMessage?: ReactNode; // 可选:自定义“没有更多了”的UI
loading?: boolean; // 可选:由外部控制加载状态
}
这里的设计有两个亮点:
- 可定制UI:通过
loader和endMessage,使用者可以传入任何React节点来定制加载和结束时的提示。 - 双模加载状态:组件内部自己管理一个
internalLoading状态,但同时也接受一个可选的externalLoadingprop。这使得组件既可以“开箱即用”,也可以被外部更复杂的逻辑(如Redux)精确控制,非常灵活。
2. “哨兵”的设置 (useRef)
我们需要一个引用来指向我们的“哨兵”div,以及一个引用来保存IntersectionObserver的实例。useRef是完美的选择,因为它可以在组件的多次渲染之间保持不变。
// 用于存储 IntersectionObserver 实例的引用
const observerRef = useRef<IntersectionObserver | null>(null)
// 哨兵元素的引用,用于触发加载更多
const sentinelRef = useRef<HTMLDivElement>(null)
3. 核心:IntersectionObserver的魔法 (useEffect)
useEffect是我们设置和清理IntersectionObserver的地方。这段代码只在组件挂载时或依赖项变化时执行。
useEffect(() => {
// 创建观察器实例
const observer = new IntersectionObserver(handleObserver, {
root: null, // 相对于浏览器视口
rootMargin: `${threshold}px`, // 设置提前加载的距离
threshold: 0.1, // 元素可见10%就触发
});
// 开始观察我们的“哨兵”元素
if (sentinelRef.current) {
observer.observe(sentinelRef.current);
}
observerRef.current = observer;
// 组件卸载时,执行清理工作
return () => {
if (observerRef.current) {
observerRef.current.disconnect(); // 停止观察,防止内存泄漏
}
};
}, [handleObserver, threshold]); // 依赖项
关键点:
rootMargin:我们利用thresholdprop来设置rootMargin。例如,threshold={50}意味着当“哨兵”元素距离视口底部还有50px时,就会被视为“可见”,从而提前触发加载,优化了用户体验。- 清理函数:
useEffect返回的函数是至关重要的清理机制。当组件被销毁时,disconnect()会停止观察,释放资源,避免了内存泄漏。
4. 触发加载 (useCallback 与 handleObserver)
handleObserver是每次IntersectionObserver触发时执行的回调函数。我们用useCallback把它包裹起来,以确保在依赖项没有改变的情况下,这个函数不会被重新创建,从而提升性能。
const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => {
const [entry] = entries; // 我们只观察一个哨兵,所以取第一个即可
// 核心判断逻辑!
if (entry.isIntersecting && hasMore && !isLoading) {
// 1. 哨兵可见吗?
// 2. 还有更多数据吗?
// 3. 是不是正在加载中?(防止重复触发)
// 同时满足以上三点,才执行加载
Promise.resolve(loadMore()).finally(() => {
// 如果是内部管理的loading,加载完成后重置它
if (externalLoading === undefined) {
setInternalLoading(false);
}
});
}
}, [hasMore, isLoading, loadMore, externalLoading]);
这里的逻辑非常严谨,确保了只在正确的时机调用loadMore。使用Promise.resolve().finally()可以优雅地处理loadMore无论是同步还是异步函数的情况。
5. 渲染与用户反馈 (JSX)
最后,是组件的渲染部分:
return (
<>
{/* 1. 看不见的哨兵元素,高度1px即可 */}
<div ref={sentinelRef} style={{ height: '1px' }} />
{/* 2. 如果正在加载,显示加载UI */}
{isLoading && loader}
{/* 3. 如果没有更多数据了,并且当前不在加载中,显示结束UI */}
{!hasMore && !isLoading && endMessage}
</>
);
这里通过简单的条件渲染,向用户提供了清晰的状态反馈。
6. 全部代码如下
import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
// 定义 InfiniteScroll 组件的属性接口
interface InfiniteScrollProps {
loadMore: () => Promise<void> | void // 加载更多数据的方法
hasMore: boolean // 是否还有更多数据可加载
threshold?: number // 用于控制 IntersectionObserver 的阈值,默认为 50
loader?: ReactNode // 加载中的占位组件,默认为“正在加载中...”
endMessage?: ReactNode // 没有更多数据时的提示组件,默认为“没有更多了”
loading?: boolean // 外部控制的加载状态,可选
}
/**
* 上拉加载更多组件
* @param props InfiniteScrollProps
*/
const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
loadMore, // 加载更多数据的方法
hasMore, // 是否还有更多数据
threshold = 50, // IntersectionObserver 的阈值,默认为 50
loader = ( // 加载中的占位组件,默认样式
<div className="p-2 text-center text-gray-600 dark:text-gray-300">
正在加载中...
</div>
),
endMessage = ( // 没有更多数据时的提示组件,默认样式
<div className="p-2 text-center text-gray-600 dark:text-gray-300">
没有更多了
</div>
),
loading: externalLoading, // 外部控制的加载状态
}) => {
// 内部加载状态,用于在没有外部控制时管理加载状态
const [internalLoading, setInternalLoading] = useState(false)
// 用于存储 IntersectionObserver 实例的引用
const observerRef = useRef<IntersectionObserver | null>(null)
// 哨兵元素的引用,用于触发加载更多
const sentinelRef = useRef<HTMLDivElement>(null)
// 合并外部和内部加载状态,优先使用外部状态(如果提供)
const isLoading =
externalLoading !== undefined ? externalLoading : internalLoading
/**
* 处理 IntersectionObserver 的回调函数
* @param entries IntersectionObserverEntry 数组
*/
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries // 获取第一个条目(哨兵元素)
// 如果哨兵元素进入视野,并且还有更多数据且当前未加载
if (entry.isIntersecting && hasMore && !isLoading) {
// 如果外部没有提供 loading 状态,则使用内部状态
if (externalLoading === undefined) {
setInternalLoading(true) // 设置内部加载状态为 true
}
// 调用 loadMore 方法加载更多数据
Promise.resolve(loadMore()).finally(() => {
// 加载完成后,重置内部加载状态
if (externalLoading === undefined) {
setInternalLoading(false)
}
})
}
},
[hasMore, isLoading, loadMore, externalLoading], // 依赖项
)
/**
* 初始化 IntersectionObserver 并观察哨兵元素
*/
useEffect(() => {
// 创建 IntersectionObserver 实例
const observer = new IntersectionObserver(handleObserver, {
root: null, // 相对于视口
rootMargin: `${threshold}px`, // 阈值范围
threshold: 0.1, // 交集比例阈值
})
// 如果哨兵元素存在,则观察它
if (sentinelRef.current) {
observer.observe(sentinelRef.current)
}
// 将 observer 实例存储到引用中
observerRef.current = observer
// 清理函数:组件卸载时取消观察
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
}
}
}, [handleObserver, threshold]) // 依赖项
return (
<>
{/* 哨兵元素,用于触发加载更多 */}
<div ref={sentinelRef} style={{ height: '1px' }} />
{/* 如果正在加载,显示加载占位组件 */}
{isLoading && loader}
{/* 如果没有更多数据且未加载,显示结束提示 */}
{!hasMore && !isLoading && endMessage}
</>
)
}
export default InfiniteScroll
四、总结
通过巧妙地结合IntersectionObserver和React Hooks (useState, useRef, useEffect, useCallback),我们成功构建了一个:
- 高性能:告别了笨重的
scroll事件监听。 - 可复用:封装成一个独立的组件,可在任何项目中使用。
- 高内聚:所有无限滚动的逻辑都内聚在组件内部。
- 高可定制:提供了灵活的Props来控制行为和UI。
这个InfiniteScroll组件不仅是IntersectionObserver强大能力的绝佳展示,也是现代React开发中“组合优于继承”和“关注点分离”思想的完美体现。希望这篇文章能帮助你更深刻地理解这些现代Web技术,并在你的下一个项目中大放异彩!