React虛擬列表的實現(xiàn)
1.背景
在開發(fā)過程中,總是遇到很多列表的顯示。當上數(shù)量級別的列表渲染于瀏覽器,終會導致瀏覽器的性能下降。如果數(shù)據(jù)量過大,首先渲染極慢,其次頁面直接卡死。當然,你可以選擇其他方式避免。例如分頁,或者下載文件等等。我們這里討論如果使用虛擬列表來解決這個問題。
2.什么是虛擬列表
最簡單的描述:列表滾動時,變更可視區(qū)域內的渲染元素。
通過 [單條數(shù)據(jù)預估高度] 計算出 [列表總高度]和[可視化區(qū)域高度 ]。并在[可視化區(qū)域高度]內按需渲染列表。
3.相關概念簡介
下面介紹在組件中,很重要的一些參數(shù)信息,這里先進行了解,有個印象,后續(xù)在使用的時候才比較明朗。
- [單條數(shù)據(jù)預估高度]: 列表中具體某一條列表的具體高度,它可以是 [固定高度],也可以是[動態(tài)高度]
- [列表總高度]: 當所有數(shù)據(jù)渲染時,列表的[總高度]
- [可視化區(qū)域高度]: 掛在虛擬列表的容器。即列表可見的區(qū)域
- [預估顯示條數(shù)]: 在 [可視化區(qū)域高度] 按照 [單條數(shù)據(jù)預估高度],可見的數(shù)據(jù)條數(shù)
- [開始索引]: [可視化區(qū)域高度] 顯示的數(shù)據(jù)的第一條數(shù)據(jù)的索引
- [結束索引]: [可視化區(qū)域高度] 顯示的數(shù)據(jù)的最后一條數(shù)據(jù)的索引
- [每條Item 位置緩存]: 因為列表的高度不一定,因此會對每條數(shù)據(jù)的高度位置進行記錄,包括 index索引,top, bottom, lineHeight屬性
4.虛擬列表實現(xiàn)
虛擬列表可以簡單理解為:當列表發(fā)生滾動時,變更[可視化區(qū)域高度 ]內的渲染元素,根據(jù)上面介紹的相關概念,我們依據(jù)這些屬性,按照以下步驟進行:
- 傳入組件數(shù)據(jù) [數(shù)據(jù)列表(resources)] 和 [預估高度(estimatedItemSize]
- 根據(jù) [數(shù)據(jù)列表(resources)]和 [預估高度(estimatedItemSize] 計算出每條數(shù)據(jù)的初始位置(當全部渲染時每條數(shù)據(jù)的占位)
- 計算出 [列表總高度]
- [可視化區(qū)域高度] 通過css控制
- 根據(jù) [可視化區(qū)域高度],計算出可視化區(qū)域預估顯示條數(shù)
- 初始化可視窗口的 [頭掛載元素]和[尾掛載元素],當發(fā)生滾動時,根據(jù)滾動差值和滾動方向,重新計算[頭掛載元素]和[尾掛載元素]。
依據(jù)以上的簡介步驟,下面開始來實現(xiàn)一個虛擬列表吧。
4.1 驅動開發(fā):參數(shù)剖析
參數(shù) | 說明 | 類型 | 默認值 |
---|---|---|---|
resources | 源數(shù)據(jù)數(shù)組 | Array | [] |
estimatedItemSize | 每條數(shù)據(jù)的預估高度 | number | 32px |
extrea | 用于自定義ItemRender,傳遞其他參數(shù) | any | none |
ItemRender | 每一條數(shù)據(jù)渲染的組件 | React.FC | const ItemRender = ({ data }: Data) => (<React.Fragment>{String(data) }</React.Fragment>) |
key | 作為遍歷時,生成item 的唯一key。需要是resources的數(shù)據(jù)具體的某個唯一值的字段。用于提高性能。 | string | 默認順序 自定義 -> id -> key -> index |
4.1.1 ItemRender
import React, { useState } from 'react'; import { VirtualList } from 'biz-web-library'; // 定義每一條數(shù)據(jù)顯示的組件 const ItemRender = ({ data }) => { let dindex = parseInt(data); let lineHeight = dindex % 2 ? '40px' : '80px'; return ( <div style={{ lineHeight, background: dindex % 2 ? '#f5f5f5' : '#fff' }}> <h3>#{dindex} title name</h3> <p>盡情地書寫你想編寫的內容,不局限于頁面高度</p> </div> ); }; const ItemRenderMemo = React.memo(ItemRender);
4.1.2 數(shù)據(jù)列表初始化
// 初始化列表數(shù)據(jù) const getDatas = () => { const datas = []; for (let i = 0; i < 100000; i++) { datas.push(`${i} Item`); } return datas; };
4.1.3 如何使用
// 使用虛擬列表 export default () => { let [resources, setResources] = useState([]); const changeResources = () => { setResources(getDatas()); }; return ( <div> <button onClick={changeResources}>click me </button> <div style={{ height: '400px', overflow: 'auto', border: '1px solid #f5f5f5', padding: '0 10px', }} > <VirtualList ItemRender={ItemRenderMemo} resources={resources} estimatedItemSize={60} /> </div> </div> ); };
4.2 組件初始化計算和布局
現(xiàn)在,如何使用已經(jīng)知道,那么開始實現(xiàn)我們的組件吧。根據(jù)傳入的數(shù)據(jù)源resources和預估高度estimatedItemSize,計算出每一條數(shù)據(jù)的初始化位置。
// 循環(huán)緩存列表的總體初始化高度 export const initPositinoCache = ( estimatedItemSize: number = 32, length: number = 0, ) => { let index = 0, positions = Array(length); while (index < length) { positions[index] = { index, height: estimatedItemSize, top: index * estimatedItemSize, bottom: (index++ + 1) * estimatedItemSize, }; } return positions; };
如果列表每條數(shù)據(jù)的高度一致,那么這個高度確實是不會改變的。如果每一條數(shù)據(jù)的高度不固定,那么該位置會在滾動的過程中進行更新。下面統(tǒng)計一些其他需要初始化的參數(shù):
參數(shù) | 說明 | 類型 | 默認值 |
---|---|---|---|
resources | 源數(shù)據(jù)數(shù)組 | Array | [] |
startOffset | 可視區(qū)域距離頂部的偏移量 | number | 0 |
listHeight | 所有數(shù)據(jù)渲染時,容器的高度 | any | none |
visibleCount | 一頁可視化區(qū)域條數(shù) | number | 10 |
startIndex | 可視化區(qū)域開始索引 | number | 0 |
endIndex | 可視化區(qū)域結束索引 | number | 10 |
visibleData | 可視化區(qū)域顯示的數(shù)據(jù) | Array | [] |
其實對于每一個屬性,介紹一下就清楚它的意義所在。但是 [startOffset]這個參數(shù)需要重點介紹一下。它就是在滾動過程中,模擬無限滾動的重要屬性。它的值,表示我們滾動過程中距離頂部的位置。[startOffset]通過結合[visibleData]達到了無限滾動的效果。
tips: 這里注意 [positions]的位置,相當于一個組件的外部變量。記得不要掛在到組件的static屬性上面。
// 緩存所有item的位置 let positions: Array<PositionType>; class VirtualList extends React.PureComponent{ constructor(props) { super(props); const { resources } = this.props; // 初始化緩存 positions = initPositinoCache(props.estimatedItemSize, resources.length); this.state = { resources, startOffset: 0, listHeight: getListHeight(positions), // positions最后一條數(shù)據(jù)的bottom屬性 scrollRef: React.createRef(), // 虛擬列表容器ref items: React.createRef(), // 虛擬列表顯示區(qū)域ref visibleCount: 10, // 一頁可視區(qū)域條數(shù) startIndex: 0, // 可視區(qū)域開始索引 endIndex: 10, // // 可視區(qū)域結束索引 }; } // TODO: 隱藏一些其他功能。。。。。 // 布局 render() { const { ItemRender = ItemRenderComponent, extrea } = this.props; const { listHeight, startOffset, resources, startIndex, endIndex, items, scrollRef } = this.state; let visibleData = resources.slice(startIndex, endIndex); return ( <div ref={scrollRef} style={{ height: `${listHeight}px` }}> <ul ref={items} style={{ transform: `translate3d(0,${startOffset}px,0)`, }} > {visibleData.map((data, index) => { return ( <li key={data.id || data.key || index} data-index={`${startIndex + index}`}> <ItemRender data={data} {...extrea}/> </li> ); })} </ul> </div> ); } }
4.3 滾動觸發(fā)注冊事件與更新
將onScroll通過[componentDidMount]注冊到dom上。滾動事件中,使用的requestAnimationFrame,該方法是利用瀏覽器的空余時間進行執(zhí)行,可以提高代碼的性能。大家想進行深入理解,可以去查閱該api的具體使用。
componentDidMount() { events.on(this.getEl(), 'scroll', this.onScroll, false); events.on(this.getEl(), 'mousewheel', NOOP, false); // 根據(jù)渲染,計算最新的節(jié)點 let visibleCount = Math.ceil(this.getEl().offsetHeight / estimatedItemSize); if (visibleCount === this.state.visibleCount || visibleCount === 0) { return; } // 因為 visibleCount變更, 更新endIndex, listHeight/ 偏移量 this.updateState({ visibleCount, startIndex: this.state.startIndex }); } getEl = () => { let el = this.state.scrollRef || this.state.items; let parentEl: any = el.current?.parentElement; switch (window.getComputedStyle(parentEl)?.overflowY) { case 'auto': case 'scroll': case 'overlay': case 'visible': return parentEl; } return document.body; }; onScroll = () => { requestAnimationFrame(() => { let { scrollTop } = this.getEl(); let startIndex = binarySearch(positions, scrollTop); // 因為 startIndex變更, 更新endIndex, listHeight/ 偏移量 this.updateState({ visibleCount: this.state.visibleCount, startIndex}); }); };
接下來我們分析一下重點步驟。當進行滾動時,我們是可以拿到當前[scrollRef]虛擬列表容器的 [scrollTop],通過該距離和[positions](記錄了每個item的所有位置屬性),可以拿到該位置的startIndex。這里為提高性能,我們通過二分法查找:
// 工具函數(shù),放入工具文件 export const binarySearch = (list: Array<PositionType>, value: number = 0) => { let start: number = 0; let end: number = list.length - 1; let tempIndex = null; while (start <= end) { let midIndex = Math.floor((start + end) / 2); let midValue = list[midIndex].bottom; // 值相等,則直接返回 查找到的節(jié)點(因為是bottom, 因此startIndex應該是下一個節(jié)點) if (midValue === value) { return midIndex + 1; } // 中間值 小于 傳入值,則說明 value對應的節(jié)點 大于 start, start往后移動一位 else if (midValue < value) { start = midIndex + 1; } // 中間值 大于 傳入值,則說明 value 在 中間值之前,end 節(jié)點移動到 mid - 1 else if (midValue > value) { // tempIndex存放最靠近值為value的所有 if (tempIndex === null || tempIndex > midIndex) { tempIndex = midIndex; } end = midIndex - 1; } } return tempIndex; };
獲取到startIndex,那么我們就依據(jù)startIndex來更新組件State中所有的屬性的值。
updateState = ({ visibleCount, startIndex }) => { // 根據(jù)新計算的節(jié)點,更新data數(shù)據(jù) this.setState({ startOffset: startIndex >= 1 ? positions[startIndex - 1]?.bottom : 0, listHeight: getListHeight(positions), startIndex, visibleCount, endIndex: getEndIndex(this.state.resources, startIndex, visibleCount) }); }; // 下面是工具函數(shù),放在其他文件中的 export const getListHeight = (positions: Array<PositionType>) => { let index = positions.length - 1; return index < 0 ? 0 : positions[index].bottom; }; export const getEndIndex = ( resources: Array<Data>, startIndex: number, visibleCount: number, ) => { let resourcesLength = resources.length; let endIndex = startIndex + visibleCount; return resourcesLength > 0 ? Math.min(resourcesLength, endIndex) : endIndex; }
4.4 item高度不等更新
至此,我們對于基本的dom進行滾動,數(shù)據(jù)更新等邏輯完成。但是在測試過程中,會發(fā)現(xiàn),如果高度不等,還沒進行更新position等操作呢?這些放在哪里呢?
這里,我們的[componentDidUpdate]就該派上用場了。每一次dom完成渲染,那么此時就應該將顯示出來的item的 位置高度信息更新到 [position]屬性中。當前 總高度[istHeight] 和偏移量[startOffset]也得同時進行更新。
componentDidUpdate() { this.updateHeight(); } updateHeight = () => { let items: HTMLCollection = this.state.items.current?.children; if (!items.length) return; // 更新緩存 updateItemSize(positions, items); // 更新總高度 let listHeight = getListHeight(positions); // 更新總偏移量 let startOffset = getStartOffset(this.state.startIndex, positions); this.setState({ listHeight, startOffset, }); }; // 下面是工具函數(shù),放在其他文件中的 export const updateItemSize = ( positions: Array<PositionType>, items: HTMLCollection, ) => { Array.from(items).forEach(item => { let index = Number(item.getAttribute('data-index')); let { height } = item.getBoundingClientRect(); let oldHeight = positions[index].height; //存在差值, 更新該節(jié)點以后所有的節(jié)點 let dValue = oldHeight - height; if (dValue) { positions[index].bottom = positions[index].bottom - dValue; positions[index].height = height; for (let k = index + 1; k < positions.length; k++) { positions[k].top = positions[k - 1].bottom; positions[k].bottom = positions[k].bottom - dValue; } } }); }; //獲取當前的偏移量 export const getStartOffset = ( startIndex: number, positions: Array<PositionType> = [], ) => { return startIndex >= 1 ? positions[startIndex - 1]?.bottom : 0; }; export const getListHeight = (positions: Array<PositionType>) => { let index = positions.length - 1; return index < 0 ? 0 : positions[index].bottom; };
4.5 外部參數(shù)數(shù)據(jù)變更,更新組件數(shù)據(jù)
當前最后一步,如果我們傳入的外部數(shù)據(jù)源等進行了變更,那么我們就得同步數(shù)據(jù)。該操作當然是發(fā)放在 getDerivedStateFromProps方法完成。
static getDerivedStateFromProps( nextProps: VirtualListProps, prevState: VirtualListState, ) { const { resources, estimatedItemSize } = nextProps; if (resources !== prevState.resources) { positions = initPositinoCache(estimatedItemSize, resources.length); // 更新高度 let listHeight = getListHeight(positions); // 更新總偏移量 let startOffset = getStartOffset(prevState.startIndex, positions); let endIndex = getEndIndex(resources, prevState.startIndex, prevState.visibleCount); return { resources, listHeight, startOffset, endIndex, }; } return null; }
5 結束語
好了,一個完整的vitural list組件完成,該組件因為每條數(shù)據(jù)ItemRender的render函數(shù)時自定義,所以只要是列表形式,你想虛擬滾動誰,都可以。當然,根據(jù)查閱網(wǎng)上的資料,圖片的相關的滾動,因為網(wǎng)絡問題,無法保證獲取列表項的真是高度,從而可能造成不準確的情況。這里暫不做討論,有興趣的小伙伴可以再次深入。
到此這篇關于React虛擬列表的實現(xiàn)的文章就介紹到這了,更多相關React虛擬列表內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
React報錯map()?is?not?a?function詳析
這篇文章主要介紹了React報錯map()?is?not?a?function詳析,文章圍繞主題展開詳細的內容介紹,具有一定的參考價值,需要的小伙伴可以參考一下2022-08-08詳解React中的useMemo和useCallback的區(qū)別
React中的useMemo和useCallback是兩個重要的Hooks。常常被用于優(yōu)化組件的性能。雖然這兩個Hooks看起來很相似,但它們彼此之間還是有很大的區(qū)別的,隨著小編一起來學習吧2023-04-04