1. 早期的解決方案
關於無限滾動,早期的解決方案基本都是依賴監聽scroll事件:
function fetchData() { fetch(path).then(res => doSomeThing(res.data)); } window.addEventListener('scroll', fetchData);
然后計算各種.scrollTop()
、.offset().top
等等。
手寫一個也是非常枯燥。而且:
-
scroll
事件會頻繁觸發,因此我們還需要手動節流。 -
滾動元素內有大量
DOM
,容易造成卡頓。
后來出現交叉觀察者IntersectionObserver API
,在與Vue
、React
這類數據驅動視圖的框架后,無限滾動的通用方案就出來了。
2. 交叉觀察者:IntersectionObserver
const box = document.querySelector('.box'); const intersectionObserver = new IntersectionObserver((entries) => { entries.forEach((item) => { if (item.isIntersecting) { console.log('進入可視區域'); } }) });
intersectionObserver.observe(box);
敲重點:
IntersectionObserver API是異步的,不隨着目標元素的滾動同步觸發,性能消耗極低。
2.1 IntersectionObserverEntry對象
這里我就粗略的介紹下需要用到的:
IntersectionObserverEntry
對象
callback
函數被調用時,會傳給它一個數組,這個數組里的每個對象就是當前進入可視區域或者離開可視區域的對象(IntersectionObserverEntry
對象)
這個對象有很多屬性,其中最常用的屬性是:
-
target
: 被觀察的目標元素,是一個 DOM 節點對象 -
isIntersecting
: 是否進入可視區域 -
intersectionRatio
: 相交區域和目標元素的比例值,進入可視區域,值大於0,否則等於0
2.3 options
調用IntersectionObserver
時,除了傳一個回調函數,還可以傳入一個option
對象,配置如下屬性:
-
threshold
: 決定了什么時候觸發回調函數。它是一個數組,每個成員都是一個門檻值,默認為[0],即交叉比例(intersectionRatio)達到0時觸發回調函數。用戶可以自定義這個數組。比如,[0, 0.25, 0.5, 0.75, 1]就表示當目標元素 0%、25%、50%、75%、100% 可見時,會觸發回調函數。 -
root
: 用於觀察的根元素,默認是瀏覽器的視口,也可以指定具體元素,指定元素的時候用於觀察的元素必須是指定元素的子元素 -
rootMargin
: 用來擴大或者縮小視窗的的大小,使用css的定義方法,10px 10px 30px 20px表示top、right、bottom 和 left的值
const io = new IntersectionObserver((entries) => { console.log(entries); }, { threshold: [0, 0.5], root: document.querySelector('.container'), rootMargin: "10px 10px 30px 20px", });
2.4 observer
observer.observer(nodeone); //僅觀察nodeOne observer.observer(nodeTwo); //觀察nodeOne和nodeTwo observer.unobserve(nodeOne); //停止觀察nodeOne observer.disconnect(); //沒有觀察任何節點
3. 如何在React Hook中使用IntersectionObserver
在看Hooks
版之前,來看正常組件版的:
class SlidingWindowScroll extends React.Component { $bottomElement = React.createRef(); componentDidMount() { this.intiateScrollObserver(); } intiateScrollObserver = () => { const options = { root: null, rootMargin: '0px', threshold: 0.1 }; this.observer = new IntersectionObserver(this.callback, options); this.observer.observe(this.$bottomElement.current); } render() { return ( <li className='img' ref={this.$bottomElement} /> ) } }
眾所周知,React 16.x
后推出了useRef
來替代原有的createRef
,用於追蹤DOM節點。那讓我們開始吧:
4. 原理
實現一個組件,可以顯示具有15個元素的固定窗口大小的n個項目的列表:
即在任何時候,無限滾動n元素上也僅存在15個DOM
節點。
-
采用
relative/absolute
定位來確定滾動位置 -
追蹤兩個
ref
:top/bottom
來決定向上/向下滾動的渲染與否 -
切割數據列表,保留最多15個DOM元素。
5. useState 聲明狀態變量
我們開始編寫組件SlidingWindowScrollHook
:
const THRESHOLD = 15; const SlidingWindowScrollHook = (props) => { const [start, setStart] = useState(0); const [end, setEnd] = useState(THRESHOLD); const [observer, setObserver] = useState(null); // 其它代碼... }
(1). useState 的簡單理解:
const [屬性, 操作屬性的方法] = useState(默認值);
(2). 變量解析
-
start
:當前渲染的列表第一個數據,默認為0 -
end
: 當前渲染的列表最后一個數據,默認為15 -
observer
: 當前觀察的視圖ref
元素
6. useRef 定義追蹤的DOM 元素
const $bottomElement = useRef(); const $topElement = useRef();
正常的無限向下滾動只需關注一個dom元素,但由於我們是固定15個dom
元素渲染,需要判斷向上或向下滾動。
7. 內部操作方法和和對應useEffect
請配合注釋使用:
useEffect(() => { // 定義觀察 intiateScrollObserver(); return () => { // 放棄觀察 resetObservation() } },[end]) //因為[end] 是同步刷新,這里用一個就行了。 // 定義觀察 const intiateScrollObserver = () => { const options = { root: null, rootMargin: '0px', threshold: 0.1 }; const Observer = new IntersectionObserver(callback, options) // 分別觀察開頭和結尾的元素 if ($topElement.current) { Observer.observe($topElement.current); } if ($bottomElement.current) { Observer.observe($bottomElement.current); } // 設初始值 setObserver(Observer) } // 交叉觀察的具體回調,觀察每個節點,並對實時頭尾元素索引處理 const callback = (entries, observer) => { entries.forEach((entry, index) => { const listLength = props.list.length; // 向下滾動,刷新數據 if (entry.isIntersecting && entry.target.id === "bottom") { const maxStartIndex = listLength - 1 - THRESHOLD; // 當前頭部的索引 const maxEndIndex = listLength - 1; // 當前尾部的索引 const newEnd = (end + 10) <= maxEndIndex ? end + 10 : maxEndIndex; // 下一輪增加尾部 const newStart = (end - 5) <= maxStartIndex ? end - 5 : maxStartIndex; // 在上一輪的基礎上計算頭部 setStart(newStart) setEnd(newEnd) } // 向上滾動,刷新數據 if (entry.isIntersecting && entry.target.id === "top") { const newEnd = end === THRESHOLD ? THRESHOLD : (end - 10 > THRESHOLD ? end - 10 : THRESHOLD); // 向上滾動尾部元素索引不得小於15 let newStart = start === 0 ? 0 : (start - 10 > 0 ? start - 10 : 0); // 頭部元素索引最小值為0 setStart(newStart) setEnd(newEnd) } }); } // 停止滾動時放棄觀察 const resetObservation = () => { observer && observer.unobserve($bottomElement.current); observer && observer.unobserve($topElement.current); } // 渲染時,頭尾ref處理 const getReference = (index, isLastIndex) => { if (index === 0) return $topElement; if (isLastIndex) return $bottomElement; return null; }
8. 渲染界面
const {list, height} = props; // 數據,節點高度 const updatedList = list.slice(start, end); // 數據切割 const lastIndex = updatedList.length - 1; return ( <ul style={{position: 'relative'}}> {updatedList.map((item, index) => { const top = (height * (index + start)) + 'px'; // 基於相對 & 絕對定位 計算 const refVal = getReference(index, index === lastIndex); // map循環中賦予頭尾ref const id = index === 0 ? 'top' : (index === lastIndex ? 'bottom' : ''); // 綁ID return (<li className="li-card" key={item.key} style={{top}} ref={refVal} id={id}>{item.value}</li>); })} </ul> );
9. 如何使用
App.js
:
import React from 'react'; import './App.css'; import { SlidingWindowScrollHook } from "./SlidingWindowScrollHook"; import MY_ENDLESS_LIST from './Constants'; function App() { return ( <div className="App"> <h1>15個元素實現無限滾動</h1> <SlidingWindowScrollHook list={MY_ENDLESS_LIST} height={195}/> </div> ); } export default App;
const MY_ENDLESS_LIST = [ { key: 1, value: 'A' }, { key: 2, value: 'B' }, { key: 3, value: 'C' }, // 中間就不貼了... { key: 45, value: 'AS' } ]
SlidingWindowScrollHook.js
:
import React, { useState, useEffect, useRef } from "react"; const THRESHOLD = 15; const SlidingWindowScrollHook = (props) => { const [start, setStart] = useState(0); const [end, setEnd] = useState(THRESHOLD); const [observer, setObserver] = useState(null); const $bottomElement = useRef(); const $topElement = useRef(); useEffect(() => { intiateScrollObserver(); return () => { resetObservation() } // eslint-disable-next-line react-hooks/exhaustive-deps },[start, end]) const intiateScrollObserver = () => { const options = { root: null, rootMargin: '0px', threshold: 0.1 }; const Observer = new IntersectionObserver(callback, options) if ($topElement.current) { Observer.observe($topElement.current); } if ($bottomElement.current) { Observer.observe($bottomElement.current); } setObserver(Observer) } const callback = (entries, observer) => { entries.forEach((entry, index) => { const listLength = props.list.length; // Scroll Down if (entry.isIntersecting && entry.target.id === "bottom") { const maxStartIndex = listLength - 1 - THRESHOLD; // Maximum index value `start` can take const maxEndIndex = listLength - 1; // Maximum index value `end` can take const newEnd = (end + 10) <= maxEndIndex ? end + 10 : maxEndIndex; const newStart = (end - 5) <= maxStartIndex ? end - 5 : maxStartIndex; setStart(newStart) setEnd(newEnd) } // Scroll up if (entry.isIntersecting && entry.target.id === "top") { const newEnd = end === THRESHOLD ? THRESHOLD : (end - 10 > THRESHOLD ? end - 10 : THRESHOLD); let newStart = start === 0 ? 0 : (start - 10 > 0 ? start - 10 : 0); setStart(newStart) setEnd(newEnd) } }); } const resetObservation = () => { observer && observer.unobserve($bottomElement.current); observer && observer.unobserve($topElement.current); } const getReference = (index, isLastIndex) => { if (index === 0) return $topElement; if (isLastIndex) return $bottomElement; return null; } const {list, height} = props; const updatedList = list.slice(start, end); const lastIndex = updatedList.length - 1; return ( <ul style={{position: 'relative'}}> {updatedList.map((item, index) => { const top = (height * (index + start)) + 'px'; const refVal = getReference(index, index === lastIndex); const id = index === 0 ? 'top' : (index === lastIndex ? 'bottom' : ''); return (<li className="li-card" key={item.key} style={{top}} ref={refVal} id={id}>{item.value}</li>); })} </ul> ); } export { SlidingWindowScrollHook };
以及少許樣式:
.li-card { display: flex; justify-content: center; list-style: none; box-shadow: 2px 2px 9px 0px #bbb; padding: 70px 0; margin-bottom: 20px; border-radius: 10px; position: absolute; width: 80%; }
然后你就可以慢慢耍了。。。