在我們進(jìn)行業(yè)務(wù)開發(fā)的過程中,常常會(huì)碰到下拉加載列表數(shù)據(jù)的需求。本文將介紹如何利用Intersection API實(shí)現(xiàn)一個(gè)簡單的下拉加載數(shù)據(jù)的demo。
傳統(tǒng)的下拉加載方案
傳統(tǒng)的下拉加載方案大多數(shù)都是通過監(jiān)聽scroll事件,然后獲取目標(biāo)元素坐標(biāo)以及相關(guān)數(shù)據(jù),再進(jìn)行對(duì)應(yīng)的實(shí)現(xiàn)。例如下面就是一個(gè)依賴數(shù)據(jù)列表容器的scrollHeight、scrollTop和height實(shí)現(xiàn)的下拉加載的demo。
代碼實(shí)現(xiàn)
function App() {
// 用于記錄當(dāng)前是否正在請(qǐng)求中
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) {
// 當(dāng)容器已經(jīng)拉到最底部時(shí),發(fā)起請(qǐng)求
fetchData();
}
};
containerRef.current.addEventListener('scroll', onScroll);
return () => {
containerRef.current.removeEventListener('scroll', onScroll);
};
}, []);
const fetchData = () => {
// 模擬數(shù)據(jù)請(qǐng)求
// 如果當(dāng)前正在請(qǐng)求中,直接返回
if (loadingRef.current) return;
// 標(biāo)記當(dāng)前正在請(qǐng)求中
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>
);
}
實(shí)現(xiàn)效果

存在問題
1.性能較差
我們知道,scroll事件的發(fā)生是十分密集的,在監(jiān)聽scroll事件的回調(diào)函數(shù)中,我們都要重新獲取列表容器的scrollTop,這會(huì)導(dǎo)致“重排”的發(fā)生。此時(shí)需要我們額外去做一些防抖或是節(jié)流的工具,防止造成性能問題。
// 節(jié)流
throttle(onScroll, 500);
2.scrollTop的小數(shù)問題
眼尖的同學(xué)可能已經(jīng)看到的,我們在判斷容器是否已經(jīng)滾動(dòng)到底部是,還做了一個(gè)-1的操作。
if (scrollHeight - containerRef.current.scrollTop - 1 <= height) {
// 當(dāng)容器已經(jīng)拉到最底部時(shí),發(fā)起請(qǐng)求
fetchData();
}
這是因?yàn)樵谑褂蔑@示比例縮放的系統(tǒng)上,scrollTop可能會(huì)提供一個(gè)小數(shù)。如下圖所示,在容器滾動(dòng)到底部時(shí),scrollHeight(1542) - scrollTop(1141.5999755859375) 與容器的高度height(400)并不相等。

所以我們需要做出相應(yīng)的兼容處理。
Intersection版本下拉加載
Intersection
IntersectionObserver 提供了一種異步觀察目標(biāo)元素在其祖先元素或頂級(jí)文檔視窗(viewport)中是否可視的方法。
IntersectionObserver的用法十分簡單,我們只需要定義好DOM元素的可視狀態(tài)發(fā)生變化后需要做些什么,以及需要觀察哪些元素的可視狀態(tài)就好了。
接下來我們詳細(xì)的看看intersectionObserver這個(gè)API。
const intersectionObserver = new IntersectionObserver(callback, options?) ;
IntersectionObserver構(gòu)造函數(shù)會(huì)接收兩個(gè)參數(shù)。
callback
callback為被觀察元素的可視狀態(tài)發(fā)生變更后的回調(diào)函數(shù),此回調(diào)函數(shù)接受兩個(gè)參數(shù):
function callback(entries, observer?) => {
//...
}
entries:一個(gè)IntersectionObserverEntry對(duì)象的數(shù)組。IntersectionObserverEntry對(duì)象用于描述被觀察對(duì)象的可視狀態(tài)的變化,擁有以下的屬性:
- entry.boundingClientRect:被觀察元素的邊界信息,相當(dāng)于被觀察元素調(diào)用getBoundingClientRect()的結(jié)果。
- entry.intersectionRatio:被觀察元素與容器元素相交矩形面積與被觀察元素總面積的比例。
- entry.intersectionRect:相交矩形的邊界信息。
- entry.isIntersecting:一個(gè)布爾值,表示被觀察元素是否可視,如果是true,則表示元可視,反之則表示不可視。
- entry.rootBounds:容器元素的邊界信息,相當(dāng)于容器元素調(diào)用getBoundingClientRect()的結(jié)果。
- entry.target:被觀察的元素的引用。
- entry.time:當(dāng)前時(shí)間戳。
observer:當(dāng)前IntersectionObserver實(shí)例的引用。
options
options為一個(gè)可選參數(shù),可傳入以下屬性:
- root:指定容器元素,默認(rèn)為瀏覽器窗體元素。容器元素必須是目標(biāo)元素的祖先節(jié)點(diǎn)。
- rootMargin:用于擴(kuò)展或縮小rootBounds的大小,用法與CSS中margin一致,默認(rèn)值為默認(rèn)值是"0px 0px 0px 0px"。
- threshold:number或number數(shù)組,用于指定callback回調(diào)函數(shù)執(zhí)行的閾值,如傳入
[0, 0.2, 0.6, 0.8, 1]時(shí),intersectionRatio每增加或減少0.2時(shí)都會(huì)觸發(fā)回調(diào)函數(shù)的執(zhí)行。默認(rèn)值為0。需要注意的時(shí),由于回調(diào)函數(shù)時(shí)異步觸發(fā)的,在回調(diào)函數(shù)執(zhí)行時(shí)intersectionRatio可能已經(jīng)和指定的閾值不一致了。
IntersectionObserver實(shí)例
IntersectionObserver構(gòu)造函數(shù)會(huì)把options中的屬性掛載到IntersectionObserver實(shí)例上,并賦予IntersectionObserver實(shí)例四個(gè)方法:
- IntersectionObserver.disconnect():停止監(jiān)聽工作。
- IntersectionObserver.observe(targetElem):開始監(jiān)聽某個(gè)元素可視狀態(tài)的變化。
- IntersectionObserver.takeRecords():返回所有觀察目標(biāo)的IntersectionObserverEntry對(duì)象數(shù)組。
- IntersectionObserver.unobserve(targetElem):停止監(jiān)聽某個(gè)目標(biāo)元素。
Intersection的優(yōu)勢
intersectionObserver構(gòu)造函數(shù)中傳入的回調(diào)函數(shù)只會(huì)在觀察的元素的可視狀態(tài)發(fā)生變化后才會(huì)執(zhí)行,很好的解決傳統(tǒng)判斷可視的方案的性能瓶頸。
實(shí)現(xiàn)思路
我們在實(shí)現(xiàn)下拉加載功能時(shí),當(dāng)數(shù)據(jù)列表還沒有加載完時(shí),我們往往會(huì)在數(shù)據(jù)列表的最后放置一個(gè)loading組件,表示當(dāng)數(shù)據(jù)列表還有更加數(shù)據(jù),并且正在加載中。我們可以利用這個(gè)loading組件的可視狀態(tài)以及Intersection API實(shí)現(xiàn)Intersection版本的下拉加載。
代碼實(shí)現(xiàn)
function App() {
// 用于記錄當(dāng)前是否正在請(qǐng)求中
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,代表監(jiān)聽的元素由不可見變成可見,進(jìn)行數(shù)據(jù)請(qǐng)求
fetchData();
}
});
// 監(jiān)聽Loading div的可見性
intersectionObserver.observe(loadingDivRef.current);
return () => {
intersectionObserver.unobserve(loadingDivRef.current);
intersectionObserver.disconnect();
intersectionObserver = null;
};
}, []);
const fetchData = () => {
// 模擬數(shù)據(jù)請(qǐng)求
// 如果當(dāng)前正在請(qǐng)求中,直接返回
if (loadingRef.current) return;
// 標(biāo)記當(dāng)前正在請(qǐng)求中
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>
);
}
實(shí)現(xiàn)效果
