React虛擬列表的實(shí)現(xiàn)
1.背景
在開發(fā)過程中,總是遇到很多列表的顯示。當(dāng)上數(shù)量級(jí)別的列表渲染于瀏覽器,終會(huì)導(dǎo)致瀏覽器的性能下降。如果數(shù)據(jù)量過大,首先渲染極慢,其次頁(yè)面直接卡死。當(dāng)然,你可以選擇其他方式避免。例如分頁(yè),或者下載文件等等。我們這里討論如果使用虛擬列表來(lái)解決這個(gè)問題。
2.什么是虛擬列表
最簡(jiǎn)單的描述:列表滾動(dòng)時(shí),變更可視區(qū)域內(nèi)的渲染元素。

通過 [單條數(shù)據(jù)預(yù)估高度] 計(jì)算出 [列表總高度]和[可視化區(qū)域高度 ]。并在[可視化區(qū)域高度]內(nèi)按需渲染列表。
3.相關(guān)概念簡(jiǎn)介
下面介紹在組件中,很重要的一些參數(shù)信息,這里先進(jìn)行了解,有個(gè)印象,后續(xù)在使用的時(shí)候才比較明朗。
- [單條數(shù)據(jù)預(yù)估高度]: 列表中具體某一條列表的具體高度,它可以是 [固定高度],也可以是[動(dòng)態(tài)高度]
- [列表總高度]: 當(dāng)所有數(shù)據(jù)渲染時(shí),列表的[總高度]
- [可視化區(qū)域高度]: 掛在虛擬列表的容器。即列表可見的區(qū)域
- [預(yù)估顯示條數(shù)]: 在 [可視化區(qū)域高度] 按照 [單條數(shù)據(jù)預(yù)估高度],可見的數(shù)據(jù)條數(shù)
- [開始索引]: [可視化區(qū)域高度] 顯示的數(shù)據(jù)的第一條數(shù)據(jù)的索引
- [結(jié)束索引]: [可視化區(qū)域高度] 顯示的數(shù)據(jù)的最后一條數(shù)據(jù)的索引
- [每條Item 位置緩存]: 因?yàn)榱斜淼母叨炔灰欢?,因此?huì)對(duì)每條數(shù)據(jù)的高度位置進(jìn)行記錄,包括 index索引,top, bottom, lineHeight屬性
4.虛擬列表實(shí)現(xiàn)
虛擬列表可以簡(jiǎn)單理解為:當(dāng)列表發(fā)生滾動(dòng)時(shí),變更[可視化區(qū)域高度 ]內(nèi)的渲染元素,根據(jù)上面介紹的相關(guān)概念,我們依據(jù)這些屬性,按照以下步驟進(jìn)行:
- 傳入組件數(shù)據(jù) [數(shù)據(jù)列表(resources)] 和 [預(yù)估高度(estimatedItemSize]
- 根據(jù) [數(shù)據(jù)列表(resources)]和 [預(yù)估高度(estimatedItemSize] 計(jì)算出每條數(shù)據(jù)的初始位置(當(dāng)全部渲染時(shí)每條數(shù)據(jù)的占位)
- 計(jì)算出 [列表總高度]
- [可視化區(qū)域高度] 通過css控制
- 根據(jù) [可視化區(qū)域高度],計(jì)算出可視化區(qū)域預(yù)估顯示條數(shù)
- 初始化可視窗口的 [頭掛載元素]和[尾掛載元素],當(dāng)發(fā)生滾動(dòng)時(shí),根據(jù)滾動(dòng)差值和滾動(dòng)方向,重新計(jì)算[頭掛載元素]和[尾掛載元素]。
依據(jù)以上的簡(jiǎn)介步驟,下面開始來(lái)實(shí)現(xiàn)一個(gè)虛擬列表吧。
4.1 驅(qū)動(dòng)開發(fā):參數(shù)剖析
| 參數(shù) | 說明 | 類型 | 默認(rèn)值 |
|---|---|---|---|
| resources | 源數(shù)據(jù)數(shù)組 | Array | [] |
| estimatedItemSize | 每條數(shù)據(jù)的預(yù)估高度 | number | 32px |
| extrea | 用于自定義ItemRender,傳遞其他參數(shù) | any | none |
| ItemRender | 每一條數(shù)據(jù)渲染的組件 | React.FC | const ItemRender = ({ data }: Data) => (<React.Fragment>{String(data) }</React.Fragment>) |
| key | 作為遍歷時(shí),生成item 的唯一key。需要是resources的數(shù)據(jù)具體的某個(gè)唯一值的字段。用于提高性能。 | string | 默認(rèn)順序 自定義 -> 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>盡情地書寫你想編寫的內(nèi)容,不局限于頁(yè)面高度</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 組件初始化計(jì)算和布局
現(xiàn)在,如何使用已經(jīng)知道,那么開始實(shí)現(xiàn)我們的組件吧。根據(jù)傳入的數(shù)據(jù)源resources和預(yù)估高度estimatedItemSize,計(jì)算出每一條數(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ù)的高度一致,那么這個(gè)高度確實(shí)是不會(huì)改變的。如果每一條數(shù)據(jù)的高度不固定,那么該位置會(huì)在滾動(dòng)的過程中進(jìn)行更新。下面統(tǒng)計(jì)一些其他需要初始化的參數(shù):
| 參數(shù) | 說明 | 類型 | 默認(rèn)值 |
|---|---|---|---|
| resources | 源數(shù)據(jù)數(shù)組 | Array | [] |
| startOffset | 可視區(qū)域距離頂部的偏移量 | number | 0 |
| listHeight | 所有數(shù)據(jù)渲染時(shí),容器的高度 | any | none |
| visibleCount | 一頁(yè)可視化區(qū)域條數(shù) | number | 10 |
| startIndex | 可視化區(qū)域開始索引 | number | 0 |
| endIndex | 可視化區(qū)域結(jié)束索引 | number | 10 |
| visibleData | 可視化區(qū)域顯示的數(shù)據(jù) | Array | [] |
其實(shí)對(duì)于每一個(gè)屬性,介紹一下就清楚它的意義所在。但是 [startOffset]這個(gè)參數(shù)需要重點(diǎn)介紹一下。它就是在滾動(dòng)過程中,模擬無(wú)限滾動(dòng)的重要屬性。它的值,表示我們滾動(dòng)過程中距離頂部的位置。[startOffset]通過結(jié)合[visibleData]達(dá)到了無(wú)限滾動(dòng)的效果。
tips: 這里注意 [positions]的位置,相當(dāng)于一個(gè)組件的外部變量。記得不要掛在到組件的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, // 一頁(yè)可視區(qū)域條數(shù)
startIndex: 0, // 可視區(qū)域開始索引
endIndex: 10, // // 可視區(qū)域結(jié)束索引
};
}
// 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 滾動(dòng)觸發(fā)注冊(cè)事件與更新
將onScroll通過[componentDidMount]注冊(cè)到dom上。滾動(dòng)事件中,使用的requestAnimationFrame,該方法是利用瀏覽器的空余時(shí)間進(jìn)行執(zhí)行,可以提高代碼的性能。大家想進(jìn)行深入理解,可以去查閱該api的具體使用。
componentDidMount() {
events.on(this.getEl(), 'scroll', this.onScroll, false);
events.on(this.getEl(), 'mousewheel', NOOP, false);
// 根據(jù)渲染,計(jì)算最新的節(jié)點(diǎn)
let visibleCount = Math.ceil(this.getEl().offsetHeight / estimatedItemSize);
if (visibleCount === this.state.visibleCount || visibleCount === 0) {
return;
}
// 因?yàn)?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);
// 因?yàn)?startIndex變更, 更新endIndex, listHeight/ 偏移量
this.updateState({ visibleCount: this.state.visibleCount, startIndex});
});
};
接下來(lái)我們分析一下重點(diǎn)步驟。當(dāng)進(jìn)行滾動(dòng)時(shí),我們是可以拿到當(dāng)前[scrollRef]虛擬列表容器的 [scrollTop],通過該距離和[positions](記錄了每個(gè)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é)點(diǎn)(因?yàn)槭莃ottom, 因此startIndex應(yīng)該是下一個(gè)節(jié)點(diǎn))
if (midValue === value) {
return midIndex + 1;
}
// 中間值 小于 傳入值,則說明 value對(duì)應(yīng)的節(jié)點(diǎn) 大于 start, start往后移動(dòng)一位
else if (midValue < value) {
start = midIndex + 1;
}
// 中間值 大于 傳入值,則說明 value 在 中間值之前,end 節(jié)點(diǎn)移動(dòng)到 mid - 1
else if (midValue > value) {
// tempIndex存放最靠近值為value的所有
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex;
}
end = midIndex - 1;
}
}
return tempIndex;
};
獲取到startIndex,那么我們就依據(jù)startIndex來(lái)更新組件State中所有的屬性的值。
updateState = ({ visibleCount, startIndex }) => {
// 根據(jù)新計(jì)算的節(jié)點(diǎn),更新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高度不等更新
至此,我們對(duì)于基本的dom進(jìn)行滾動(dòng),數(shù)據(jù)更新等邏輯完成。但是在測(cè)試過程中,會(huì)發(fā)現(xiàn),如果高度不等,還沒進(jìn)行更新position等操作呢?這些放在哪里呢?
這里,我們的[componentDidUpdate]就該派上用場(chǎng)了。每一次dom完成渲染,那么此時(shí)就應(yīng)該將顯示出來(lái)的item的 位置高度信息更新到 [position]屬性中。當(dāng)前 總高度[istHeight] 和偏移量[startOffset]也得同時(shí)進(jìn)行更新。
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é)點(diǎn)以后所有的節(jié)點(diǎn)
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;
}
}
});
};
//獲取當(dāng)前的偏移量
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ù)
當(dāng)前最后一步,如果我們傳入的外部數(shù)據(jù)源等進(jìn)行了變更,那么我們就得同步數(shù)據(jù)。該操作當(dāng)然是發(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 結(jié)束語(yǔ)
好了,一個(gè)完整的vitural list組件完成,該組件因?yàn)槊織l數(shù)據(jù)ItemRender的render函數(shù)時(shí)自定義,所以只要是列表形式,你想虛擬滾動(dòng)誰(shuí),都可以。當(dāng)然,根據(jù)查閱網(wǎng)上的資料,圖片的相關(guān)的滾動(dòng),因?yàn)榫W(wǎng)絡(luò)問題,無(wú)法保證獲取列表項(xiàng)的真是高度,從而可能造成不準(zhǔn)確的情況。這里暫不做討論,有興趣的小伙伴可以再次深入。
到此這篇關(guān)于React虛擬列表的實(shí)現(xiàn)的文章就介紹到這了,更多相關(guān)React虛擬列表內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
React?中使用?Redux?的?4?種寫法小結(jié)
這篇文章主要介紹了在?React?中使用?Redux?的?4?種寫法,Redux 一般來(lái)說并不是必須的,只有在項(xiàng)目比較復(fù)雜的時(shí)候,比如多個(gè)分散在不同地方的組件使用同一個(gè)狀態(tài),本文就React使用?Redux的相關(guān)知識(shí)給大家介紹的非常詳細(xì),需要的朋友參考下吧2022-06-06
Redis數(shù)據(jù)結(jié)構(gòu)面試高頻問題解析
這篇文章主要為大家介紹了Redis數(shù)據(jù)結(jié)構(gòu)高頻面試問題解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-06-06
React?Native?中限制導(dǎo)入某些組件和模塊的方法
這篇文章主要介紹了React?Native?中限制導(dǎo)入某些組件和模塊的方法,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-08-08
React路由跳轉(zhuǎn)的實(shí)現(xiàn)示例
在React中,可以使用多種方法進(jìn)行路由跳轉(zhuǎn),本文主要介紹了React路由跳轉(zhuǎn)的實(shí)現(xiàn)示例,具有一定的參考價(jià)值,感興趣的可以了解一下2023-12-12

