React實(shí)現(xiàn)虛擬滾動(dòng)的三種思路詳解
1 前言
在??web??開發(fā)的過程中,或多或少都會(huì)遇到大列表渲染的場(chǎng)景,例如全國(guó)城市列表、通訊錄列表、聊天記錄列表等等。當(dāng)列表數(shù)據(jù)量為幾百條時(shí),依靠瀏覽器本身的性能基本可以支撐,一般不會(huì)出現(xiàn)卡頓的情況。但當(dāng)列表數(shù)量級(jí)達(dá)到上千,頁(yè)面渲染或操作就可能會(huì)出現(xiàn)卡頓,而當(dāng)列表數(shù)量突破上萬甚至十幾萬時(shí),網(wǎng)頁(yè)可能會(huì)出現(xiàn)嚴(yán)重卡頓甚至直接崩潰。為了解決大列表造成的渲染壓力,便出現(xiàn)了虛擬滾動(dòng)技術(shù)。本文主要介紹虛擬滾動(dòng)的基本原理,以及子項(xiàng)定高的虛擬滾動(dòng)列表的簡(jiǎn)單實(shí)現(xiàn)。
2 基本原理
首先來看一下直接渲染的大列表的實(shí)際表現(xiàn)。以有10萬條子項(xiàng)的簡(jiǎn)單大列表為例,頁(yè)面初始化時(shí),??FP??時(shí)間大概在4000ms左右,大量的時(shí)間被用于執(zhí)行腳本和渲染。而當(dāng)快速滾動(dòng)列表時(shí),網(wǎng)頁(yè)的??FPS??維持在35左右,可以明顯的感覺到頁(yè)面的卡頓。借助谷歌??Lighthouse??工具,最終網(wǎng)頁(yè)的性能得分僅為49。通過實(shí)際訪問體驗(yàn)和性能相關(guān)數(shù)據(jù)可以看出,直接渲染的大列表在加載操作方面體驗(yàn)是十分糟糕的。點(diǎn)擊? ?鏈接??,體驗(yàn)實(shí)際效果。
通過以上的測(cè)試數(shù)據(jù)可以看到,在頁(yè)面初始化時(shí)腳本的執(zhí)行和??DOM??渲染占據(jù)的大部分的時(shí)間。而隨著列表子項(xiàng)的減少,頁(yè)面初始化時(shí)間會(huì)變短并且滾動(dòng)時(shí)??FPS??可以保持在60。由此可以得出結(jié)論大量節(jié)點(diǎn)的渲染是頁(yè)面初始化慢和操作卡頓的主要原因。
雖然大列表的數(shù)據(jù)量很大,但是設(shè)備的顯示區(qū)域是有限的,也就是說在同一時(shí)間,用戶看到的內(nèi)容是有限的。利用這一特點(diǎn),可以將大列表按需渲染。也就是只渲染某一時(shí)刻用戶看的到的內(nèi)容,當(dāng)用戶滾動(dòng)頁(yè)面時(shí),再通過??JS??的計(jì)算重現(xiàn)調(diào)整視窗內(nèi)的內(nèi)容,這樣可以把列表子項(xiàng)的數(shù)量級(jí)別從幾萬降到幾十。
借助按需渲染的思想來優(yōu)化大列表在實(shí)現(xiàn)層面可以分成三步,一是確定當(dāng)前視窗在哪,二是確定當(dāng)前要真實(shí)渲染哪些節(jié)點(diǎn),三是把渲染的節(jié)點(diǎn)移動(dòng)到視窗內(nèi)。對(duì)于問題一,視窗的位置對(duì)于長(zhǎng)列表來說,其開始位置為列表滾動(dòng)區(qū)域的??scrollTop??。對(duì)于問題二,按照視窗外內(nèi)容不渲染的思路,則應(yīng)該渲染數(shù)組索引從??Math.floor(scrollTop/itemHeight)??開始共??Math.ceil(viewHeight/itemHeight)??個(gè)元素。對(duì)于問題三,有多種實(shí)現(xiàn)思路,以下將介紹幾種常見虛擬滾動(dòng)的實(shí)現(xiàn)方式。
解釋:
- scrollTop:列表滾動(dòng)區(qū)域的scrollTop
- itemHeight:子節(jié)點(diǎn)的高度
- viewHeight:視窗的高度
3 實(shí)現(xiàn)
3.1 Transform
該方案主要是通過監(jiān)聽滾動(dòng)區(qū)域的滾動(dòng)事件,動(dòng)態(tài)計(jì)算視窗內(nèi)渲染節(jié)點(diǎn)的開始索引以及偏移量,然后重新觸發(fā)渲染節(jié)點(diǎn)的渲染并將內(nèi)容通過??transform??屬性將該部分內(nèi)容移動(dòng)到視窗內(nèi)。
簡(jiǎn)單代碼實(shí)現(xiàn)如下,? ?線上效果預(yù)覽??。
function VirtualList(props) { const { list, itemHeight } = props; const [start, setStart] = useState(0); const [count, setCount] = useState(0); const scrollRef = useRef(null); const contentRef = useRef(null); const totalHeight = useMemo(() => itemHeight * list.length, [list.length]); useEffect(() => { setCount(Math.ceil(scrollRef.current.clientHeight / itemHeight)); }, []); const scrollHandle = () => { const { scrollTop } = scrollRef.current; const newStart = Math.floor(scrollTop / itemHeight); setStart(newStart); contentRef.current.style.transform = `translate3d(0, ${ newStart * itemHeight }px, 0)`; }; const subList = list.slice(start, start + count); return ( <div className="virtual-list" onScroll={scrollHandle} ref={scrollRef}> <div style={{ height: totalHeight + "px" }}> <div className="content" ref={contentRef}> {subList.map(({ idx }) => ( <div key={idx} className="item" style={{ height: itemHeight + "px" }} > {idx} </div> ))} </div> </div> </div> ); }
類似思想實(shí)現(xiàn)的開源項(xiàng)目:? ?react-list??
3.2 Absolute
該方案與??transform??方案類似,都是通過監(jiān)聽滾動(dòng)區(qū)域的滾動(dòng)事件,動(dòng)態(tài)的計(jì)算要顯示的內(nèi)容。但??transform??方案顯示內(nèi)容的偏量是動(dòng)態(tài)計(jì)算并賦值的,而該方案則是利用??absolute??屬性直接將待渲染的節(jié)點(diǎn)定位到其該出現(xiàn)的位置。例如,索引為0的元素,其必定在??top = 0 * itemHeight??的位置,索引為??start??的元素必定在??top = start * itemHeight??的位置,這與視窗位置無關(guān)。視窗只決定了要渲染那些子節(jié)點(diǎn),不影響子節(jié)點(diǎn)的相對(duì)位置。
簡(jiǎn)單代碼實(shí)現(xiàn)如下,? ?線上效果預(yù)覽??。
function VirtualList(props) { const { list, itemHeight } = props; const [start, setStart] = useState(0); const [count, setCount] = useState(0); const scrollRef = useRef(null); const totalHeight = useMemo(() => itemHeight * list.length, [list.length]); useEffect(() => { setCount(Math.ceil(scrollRef.current.clientHeight / itemHeight)); }, []); const scrollHandle = () => { const { scrollTop } = scrollRef.current; const newStart = Math.floor(scrollTop / itemHeight); setStart(newStart); }; const subList = list.slice(start, start + count); return ( <div className="virtual-list" onScroll={scrollHandle} ref={scrollRef}> <div style={{ height: `${totalHeight}px` }}> {subList.map(({ idx }) => ( <div key={idx} className="item" style={{ position: "absolute", width: "100%", height: itemHeight + "px", top: `${(idx - 1) * itemHeight}px`, }} > {idx} </div> ))} </div> </div> ); }
類似思想實(shí)現(xiàn)的開源項(xiàng)目:? ?react-virtualized??
3.3 Padding
該方案與以上兩種方案有較大的差別,主要體現(xiàn)在以下兩點(diǎn):一是列表高度撐起的方式不同,以上兩種方案的高度是通過設(shè)置??height = list.length * itemHeight???的方式撐起來的,而該方案則是通過??paddingTop + paddingBottom + renderHeight???的方式來?yè)纹饋淼?。二是列表的重新渲染時(shí)機(jī)不同,以上兩種方案會(huì)在??Math.floor(scrollTop / itemHeight)??值變化時(shí)重新渲染,而該方案則是在渲染節(jié)點(diǎn)"不夠"在視窗內(nèi)顯示時(shí)觸發(fā)。
舉個(gè)例子,假定視窗一次可以顯示10個(gè),同時(shí)配置虛擬滾動(dòng)組件一次渲染50節(jié)點(diǎn),那么當(dāng)屏幕滾動(dòng)到第11個(gè)時(shí)并不需要渲染,因?yàn)榇藭r(shí)顯示的是11-20個(gè)節(jié)點(diǎn),而將要顯示的21-50已經(jīng)渲染好了。只有當(dāng)滾動(dòng)到第41個(gè)的時(shí)候才需要重新渲染,因?yàn)槠聊煌庖呀?jīng)沒有渲染好的節(jié)點(diǎn)了,再滾動(dòng)就要顯示白屏了。根據(jù)以上例子進(jìn)一步的分析臨界條件,當(dāng)前渲染位置為??[itemHeight * start, itemHeight * (start + count)]???,視窗顯示的位置為??[scrollTop, scrollTop + clientHeight]??。
當(dāng)??scrollTop + clientHeight >= itemHeight * (start + count)??時(shí),說明視窗顯示位置超過了渲染的最大位置,重新觸發(fā)渲染調(diào)整渲染位置,避免底部白屏。
當(dāng)??scrollTop <= itemHeight * start??時(shí),說明視窗顯示位置不足渲染的最小位置,重新觸發(fā)渲染調(diào)整渲染位置,避免頂部白屏。
簡(jiǎn)單代碼實(shí)現(xiàn)如下,? ?線上效果預(yù)覽??。
function VirtualList(props) { // 注意該count是外部傳入的 const { list, itemHeight, count } = props; const totalHeight = useMemo(() => itemHeight * list.length, [list.length]); const currentHeight = useMemo(() => itemHeight * count, [itemHeight, count]); const [start, setStart] = useState(0); const scrollRef = useRef(null); const paddingTop = useMemo(() => itemHeight * start, [start]); const paddingBottom = useMemo( () => totalHeight - itemHeight * start - currentHeight, [start] ); const scrollHandle = () => { const { scrollTop, clientHeight } = scrollRef.current; if ( scrollTop + clientHeight >= itemHeight * (start + count) || scrollTop <= itemHeight * start ) { const newStart = Math.floor(scrollTop / itemHeight); setStart(Math.min(list.length - count, newStart)); } }; const subList = list.slice(start, start + count); return ( <div className="virtual-list" onScroll={scrollHandle} ref={scrollRef}> <div style={{ paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px`, }} > {subList.map(({ idx }) => ( <div key={idx} className="item" style={{ height: itemHeight + "px" }}> {idx} </div> ))} </div> </div> ); }
類似思想實(shí)現(xiàn)的開源項(xiàng)目:? ?vue-virtual-scroll-list??
4 性能
使用以上三種方案分別測(cè)試頁(yè)面加載速度和滾動(dòng)時(shí)的??FPS??發(fā)現(xiàn),三者之間的性能數(shù)據(jù)無明顯差別。頁(yè)面初始化時(shí),??FP??時(shí)間提前到450ms左右,快速滾動(dòng)時(shí)的??FPS??基本穩(wěn)定在60左右,網(wǎng)站的谷歌??Lighthouse??性能跑分提高到95左右。實(shí)際訪問體驗(yàn)和性能相關(guān)數(shù)據(jù)都得到了較大的提升。
5 總結(jié)
本文主要是介紹了虛擬滾動(dòng)的基本原理,并根據(jù)常見虛擬滾動(dòng)開源庫(kù)的實(shí)現(xiàn)思路使用??react??進(jìn)行了簡(jiǎn)單的實(shí)現(xiàn)。通過簡(jiǎn)單的實(shí)現(xiàn)可以幫助我們更好的理解虛擬滾動(dòng)原理,不過在實(shí)際開發(fā)過程中,還是建議大家使用成熟的開源庫(kù)。
到此這篇關(guān)于React實(shí)現(xiàn)虛擬滾動(dòng)的三種思路詳解的文章就介紹到這了,更多相關(guān)React虛擬滾動(dòng)內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
詳解React中錯(cuò)誤邊界的原理實(shí)現(xiàn)與應(yīng)用
在React中,錯(cuò)誤邊界是一種特殊的組件,用于捕獲其子組件樹中發(fā)生的JavaScript錯(cuò)誤,并防止這些錯(cuò)誤冒泡至更高層,導(dǎo)致整個(gè)應(yīng)用崩潰,下面我們就來看看它的具體應(yīng)用吧2024-03-03React 項(xiàng)目遷移 Webpack Babel7的實(shí)現(xiàn)
這篇文章主要介紹了React 項(xiàng)目遷移 Webpack Babel7的實(shí)現(xiàn),小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-09-09React State狀態(tài)與生命周期的實(shí)現(xiàn)方法
這篇文章主要介紹了React State狀態(tài)與生命周期的實(shí)現(xiàn)方法,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-03-03react-three/postprocessing庫(kù)的參數(shù)中文含義使用解析
這篇文章主要介紹了react-three/postprocessing庫(kù)的參數(shù)中文含義使用總結(jié),本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-05-05react實(shí)現(xiàn)同頁(yè)面三級(jí)跳轉(zhuǎn)路由布局
這篇文章主要為大家詳細(xì)介紹了react實(shí)現(xiàn)同頁(yè)面三級(jí)跳轉(zhuǎn)路由布局,一個(gè)路由小案例,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-09-09Electron打包React生成桌面應(yīng)用方法詳解
這篇文章主要介紹了React+Electron快速創(chuàng)建并打包成桌面應(yīng)用,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)吧2022-12-12React 如何使用時(shí)間戳計(jì)算得到開始和結(jié)束時(shí)間戳
這篇文章主要介紹了React 如何拿時(shí)間戳計(jì)算得到開始和結(jié)束時(shí)間戳,本文通過示例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2023-09-09