在我们进行业务开发的过程中,常常会碰到下拉加载列表数据的需求。本文将介绍如何利用Intersection API实现一个简单的下拉加载数据的demo。
传统的下拉加载方案
传统的下拉加载方案大多数都是通过监听scroll
事件,然后获取目标元素坐标以及相关数据,再进行对应的实现。例如下面就是一个依赖数据列表容器的scrollHeight
、scrollTop
和height
实现的下拉加载的demo。
代码实现
function App() {
// 用于记录当前是否正在请求中
const loadingRef = useRef<boolean>(false);
// 列表容器
const containerRef = useRef<HTMLDivElement>(null);
const [dataList, setDataList] = useState([]);
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
const { height } = containerRef.current.getBoundingClientRect();
const scrollHeight = containerRef.current.scrollHeight;
const onScroll = () => {
console.log('scrollHeight:', scrollHeight, 'scrollTop:', containerRef.current.scrollTop, 'height:', height);
if (scrollHeight - containerRef.current.scrollTop - 1 <= height) {
// 当容器已经拉到最底部时,发起请求
fetchData();
}
};
containerRef.current.addEventListener('scroll', onScroll);
return () => {
containerRef.current.removeEventListener('scroll', onScroll);
};
}, []);
const fetchData = () => {
// 模拟数据请求
// 如果当前正在请求中,直接返回
if (loadingRef.current) return;
// 标记当前正在请求中
loadingRef.current = true;
setTimeout(() => {
setDataList(_dataList => {
const dataList = [..._dataList];
for (let i = 0; i < 20; i++) {
dataList.push(Math.random());
}
return dataList;
});
loadingRef.current = false;
}, 500);
};
return (
<div ref={containerRef} className="list-container">
{dataList.map(item => (
<p className="list-item" key={item}>
{item}
</p>
))}
<div className="loading">loading...</div>
</div>
);
}
实现效果
存在问题
1.性能较差
我们知道,scroll
事件的发生是十分密集的,在监听scroll
事件的回调函数中,我们都要重新获取列表容器的scrollTop
,这会导致“重排”的发生。此时需要我们额外去做一些防抖或是节流的工具,防止造成性能问题。
// 节流
throttle(onScroll, 500);
2.scrollTop
的小数问题
眼尖的同学可能已经看到的,我们在判断容器是否已经滚动到底部是,还做了一个-1的操作。
if (scrollHeight - containerRef.current.scrollTop - 1 <= height) {
// 当容器已经拉到最底部时,发起请求
fetchData();
}
这是因为在使用显示比例缩放的系统上,scrollTop可能会提供一个小数。如下图所示,在容器滚动到底部时,scrollHeight(1542) - scrollTop(1141.5999755859375)
与容器的高度height(400)
并不相等。
所以我们需要做出相应的兼容处理。
Intersection版本下拉加载
Intersection
IntersectionObserver
提供了一种异步观察目标元素在其祖先元素或顶级文档视窗(viewport)中是否可视的方法。
IntersectionObserver的用法十分简单,我们只需要定义好DOM元素的可视状态发生变化后需要做些什么,以及需要观察哪些元素的可视状态就好了。
接下来我们详细的看看intersectionObserver这个API。
const intersectionObserver = new IntersectionObserver(callback, options?) ;
IntersectionObserver构造函数会接收两个参数。
callback
callback为被观察元素的可视状态发生变更后的回调函数,此回调函数接受两个参数:
function callback(entries, observer?) => {
//...
}
entries
:一个IntersectionObserverEntry
对象的数组。IntersectionObserverEntry对象用于描述被观察对象的可视状态的变化,拥有以下的属性:
- entry.boundingClientRect:被观察元素的边界信息,相当于被观察元素调用getBoundingClientRect()的结果。
- entry.intersectionRatio:被观察元素与容器元素相交矩形面积与被观察元素总面积的比例。
- entry.intersectionRect:相交矩形的边界信息。
- entry.isIntersecting:一个布尔值,表示被观察元素是否可视,如果是true,则表示元可视,反之则表示不可视。
- entry.rootBounds:容器元素的边界信息,相当于容器元素调用getBoundingClientRect()的结果。
- entry.target:被观察的元素的引用。
- entry.time:当前时间戳。
observer
:当前IntersectionObserver实例的引用。
options
options为一个可选参数,可传入以下属性:
- root:指定容器元素,默认为浏览器窗体元素。容器元素必须是目标元素的祖先节点。
- rootMargin:用于扩展或缩小rootBounds的大小,用法与CSS中margin一致,默认值为默认值是"0px 0px 0px 0px"。
- threshold:number或number数组,用于指定callback回调函数执行的阈值,如传入
[0, 0.2, 0.6, 0.8, 1]
时,intersectionRatio每增加或减少0.2时都会触发回调函数的执行。默认值为0
。需要注意的时,由于回调函数时异步触发的,在回调函数执行时intersectionRatio可能已经和指定的阈值不一致了。
IntersectionObserver实例
IntersectionObserver构造函数会把options中的属性挂载到IntersectionObserver实例上,并赋予IntersectionObserver实例四个方法:
- IntersectionObserver.disconnect():停止监听工作。
- IntersectionObserver.observe(targetElem):开始监听某个元素可视状态的变化。
- IntersectionObserver.takeRecords():返回所有观察目标的IntersectionObserverEntry对象数组。
- IntersectionObserver.unobserve(targetElem):停止监听某个目标元素。
Intersection的优势
intersectionObserver构造函数中传入的回调函数只会在观察的元素的可视状态发生变化后才会执行,很好的解决传统判断可视的方案的性能瓶颈。
实现思路
我们在实现下拉加载功能时,当数据列表还没有加载完时,我们往往会在数据列表的最后放置一个loading
组件,表示当数据列表还有更加数据,并且正在加载中。我们可以利用这个loading
组件的可视状态以及Intersection
API实现Intersection版本的下拉加载。
代码实现
function App() {
// 用于记录当前是否正在请求中
const loadingRef = useRef<boolean>(false);
// loading div
const loadingDivRef = useRef<HTMLDivElement>(null);
const [dataList, setDataList] = useState([]);
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
let intersectionObserver = new IntersectionObserver(function (entries) {
if (entries[0].intersectionRatio > 0) {
// intersectionRatio大于0,代表监听的元素由不可见变成可见,进行数据请求
fetchData();
}
});
// 监听Loading div的可见性
intersectionObserver.observe(loadingDivRef.current);
return () => {
intersectionObserver.unobserve(loadingDivRef.current);
intersectionObserver.disconnect();
intersectionObserver = null;
};
}, []);
const fetchData = () => {
// 模拟数据请求
// 如果当前正在请求中,直接返回
if (loadingRef.current) return;
// 标记当前正在请求中
loadingRef.current = true;
setTimeout(() => {
setDataList(_dataList => {
const dataList = [..._dataList];
for (let i = 0; i < 20; i++) {
dataList.push(Math.random());
}
return dataList;
});
loadingRef.current = false;
}, 500);
};
return (
<div className="list-container">
{dataList.map(item => (
<p className="list-item" key={item}>
{item}
</p>
))}
<div ref={loadingDivRef} className="loading">
loading...
</div>
</div>
);
}