近期在某平臺開發迭代的過程當中遇到了超長List嵌套在antd Modal里加載慢,卡頓的狀況。因而心血來潮決定從零本身實現一個虛擬滾動列表來優化一下總體的體驗。node
咱們能夠看出來在改造以前,打開編輯窗口Modal的時候會出現短暫的卡頓,而且在點擊Cancel關閉後也並非當即響應而是稍做遲疑以後才關閉的typescript
改造完成後咱們能夠觀察到整個Modal的打開比以前變得流暢了很多,能夠作到當即響應用戶的點擊事件喚起/關閉Modal數組
因此什麼是虛擬滾動/列表呢?緩存
一個虛擬列表是指當咱們有成千上萬條數據須要進行展現可是用戶的「視窗」(一次性可見內容)又不大時咱們能夠經過巧妙的方法只渲染用戶最大可見條數+「BufferSize」個元素並在用戶進行滾動時動態更新每一個元素中的內容從而達到一個和長list滾動同樣的效果但花費很是少的資源。markdown
(從上圖中咱們能夠發現實際用戶每次能看到的元素/內容只有item-4 ~ item-13 也就是9個元素)antd
首先咱們須要定義幾個變量/名稱。函數
startIndex
endIndex
startOffset(scrollTop)
由於咱們只對可視區域的內容作了渲染,因此爲了保持整個容器的行爲和一個長列表類似(滾動)咱們必須保持原列表的高度,因此咱們將HTML結構設計成以下oop
<!--ver 1.0 -->
<div className="vListContainer">
<div className="phantomContent">
...
<!-- item-1 -->
<!-- item-2 -->
<!-- item-3 -->
....
</div>
</div>
複製代碼
vListContainer
爲可視區域的容器,具備 overflow-y: auto
屬性。phantom
中的每條數據都應該具備 position: absolute
屬性phantomContent
則是咱們的「幻影」部分,其主要目的是爲了還原真實List的內容高度從而模擬正常長列表滾動的行爲。接着咱們對 vListContainer
綁定一個onScroll
的響應函數,並在函數中根據原生滾動事件的scrollTop 屬性來計算咱們的 startIndex
和 endIndex
性能
rowHeight
total
height
phantomHeight = total * rowHeight
limit = Math.ceil(height/rowHeight)
(注意此處咱們用的是向上取整)優化
onScroll(evt: any) {
// 判斷是不是咱們須要響應的滾動事件
if (evt.target === this.scrollingContainer.current) {
const { scrollTop } = evt.target;
const { startIndex, total, rowHeight, limit } = this;
// 計算當前startIndex
const currentStartIndex = Math.floor(scrollTop / rowHeight);
// 若是currentStartIndex 和 startIndex 不一樣(咱們須要更新數據了)
if (currentStartIndex !== startIndex ) {
this.startIndex = currentStartIndex;
this.endIndex = Math.min(currentStartIndedx + limit, total - 1);
this.setState({ scrollTop });
}
}
}
複製代碼
renderDisplayContent = () => {
const { rowHeight, startIndex, endIndex } = this;
const content = [];
// 注意這塊咱們用了 <= 是爲了渲染x+1個元素用來在讓滾動變得連續(永遠渲染在判斷&渲染x+2)
for (let i = startIndex; i <= endIndex; ++i) {
// rowRenderer 是用戶定義的列表元素渲染方法,須要接收一個 index i 和
// 當前位置對應的style
content.push(
rowRenderer({
index: i,
style: {
width: '100%',
height: rowHeight + 'px',
position: "absolute",
left: 0,
right: 0,
top: i * rowHeight,
borderBottom: "1px solid #000",
}
})
);
}
return content;
};
複製代碼
線上Demo:codesandbox.io/s/a-naive-v…
onScroll(evt: any) {
........
// 計算當前startIndex
const currentStartIndex = Math.floor(scrollTop / rowHeight);
// 若是currentStartIndex 和 startIndex 不一樣(咱們須要更新數據了)
if (currentStartIndex !== originStartIdx) {
// 注意,此處咱們引入了一個新的變量叫originStartIdx,起到了和以前startIndex
// 相同的效果,記錄當前的 真實 開始下標。
this.originStartIdx = currentStartIndex;
// 對 startIndex 進行 頭部 緩衝區 計算
this.startIndex = Math.max(this.originStartIdx - bufferSize, 0);
// 對 endIndex 進行 尾部 緩衝區 計算
this.endIndex = Math.min(
this.originStartIdx + this.limit + bufferSize,
total - 1
);
this.setState({ scrollTop: scrollTop });
}
}
複製代碼
線上Demo:codesandbox.io/s/A-better-…
如今咱們已經實現了「定高」元素的虛擬列表的實現,那麼若是說碰到了高度不固定的超長列表的業務場景呢?
對輸入數據進行更改,傳入每個元素對應的高度 dynamicHeight[i] = x x 爲元素i 的行高
須要實現知道每個元素的高度(不切實際)
將當前元素先在屏外進行繪製並對齊高度進行測量後再將其渲染到用戶可視區域內
這種方法至關於雙倍渲染消耗(不切實際)
傳入一個estimateHeight 屬性先對行高進行估計並渲染,而後渲染完成後得到真實行高並進行更新和緩存
會引入多餘的transform(能夠接受),會在後邊講爲何須要多餘的transform...
<!--ver 1.0 -->
<div className="vListContainer"> <div className="phantomContent"> ... <!-- item-1 --> <!-- item-2 --> <!-- item-3 --> .... </div> </div>
<!--ver 1.1 -->
<div className="vListContainer"> <div className="phantomContent" /> <div className="actualContent"> ... <!-- item-1 --> <!-- item-2 --> <!-- item-3 --> .... </div> </div>
複製代碼
在咱們實現 「定高」 虛擬列表時,咱們是採用了把元素渲染在phantomContent
容器裏,而且經過設置每個item的position
爲 absolute
加上定義top
屬性等於 i * rowHeight
來實現不管怎麼滾動,渲染內容始終是在用戶的可視範圍內的。在列表高度不能肯定的狀況下,咱們就沒法準確的經過estimateHeight
來計算出當前元素所處的y位置,因此咱們須要一個容器來幫咱們作這個絕對定位。
actualContent 則是咱們新引入的列表內容渲染容器,經過在此容器上設置position: absolute
屬性來避免在每一個item上設置。
有一點不一樣的是,由於咱們改用actualContent 容器。當咱們進行滑動時須要動態的對容器的位置進行一個 y-transform 從而實現容器永遠處於用戶的視窗之中:
getTransform() {
const { scrollTop } = this.state;
const { rowHeight, bufferSize, originStartIdx } = this;
// 當前滑動offset - 當前被截斷的(沒有徹底消失的元素)距離 - 頭部緩衝區距離
return `translate3d(0,${ scrollTop - (scrollTop % rowHeight) - Math.min(originStartIdx, bufferSize) * rowHeight }px,0)`;
}
複製代碼
線上Demo:codesandbox.io/s/a-v-list-…
(注:當沒有高度自適應要求時且沒有實現cell複用時,把元素經過absolute渲染在phantom裏會比經過transform的性能要好一些。由於每次渲染content時都會進行重排,可是若是使用transform時就至關於進行了( 重排 + transform) > 重排)
回到列表元素高度自適應這個問題上來,如今咱們有了一個能夠在內部進行正常block排布的元素渲染容器(actualContent ),咱們如今就能夠直接在不給定高度的狀況下先把內容都渲染進去。對於以前咱們須要用rowHeight 作高度計算的地方,咱們統一替換成estimateHeight 進行計算。
limit = Math.ceil(height / estimateHeight)
phantomHeight = total * estimateHeight
同時爲了不重複計算每個元素渲染後的高度(getBoundingClientReact().height) 咱們須要一個數組來存儲這些高度
interface CachedPosition {
index: number; // 當前pos對應的元素的下標
top: number; // 頂部位置
bottom: number; // 底部位置
height: number; // 元素高度
dValue: number; // 高度是否和以前(estimate)存在不一樣
}
cachedPositions: CachedPosition[] = [];
// 初始化cachedPositions
initCachedPositions = () => {
const { estimatedRowHeight } = this;
this.cachedPositions = [];
for (let i = 0; i < this.total; ++i) {
this.cachedPositions[i] = {
index: i,
height: estimatedRowHeight, // 先使用estimateHeight估計
top: i * estimatedRowHeight, // 同上
bottom: (i + 1) * estimatedRowHeight, // same above
dValue: 0,
};
}
};
複製代碼
this.phantomHeight = this.cachedPositions[cachedPositionsLen - 1].bottom;
複製代碼
componentDidUpdate() {
......
// actualContentRef必須存在current (已經渲染出來) + total 必須 > 0
if (this.actualContentRef.current && this.total > 0) {
this.updateCachedPositions();
}
}
updateCachedPositions = () => {
// update cached item height
const nodes: NodeListOf<any> = this.actualContentRef.current.childNodes;
const start = nodes[0];
// calculate height diff for each visible node...
nodes.forEach((node: HTMLDivElement) => {
if (!node) {
// scroll too fast?...
return;
}
const rect = node.getBoundingClientRect();
const { height } = rect;
const index = Number(node.id.split('-')[1]);
const oldHeight = this.cachedPositions[index].height;
const dValue = oldHeight - height;
if (dValue) {
this.cachedPositions[index].bottom -= dValue;
this.cachedPositions[index].height = height;
this.cachedPositions[index].dValue = dValue;
}
});
// perform one time height update...
let startIdx = 0;
if (start) {
startIdx = Number(start.id.split('-')[1]);
}
const cachedPositionsLen = this.cachedPositions.length;
let cumulativeDiffHeight = this.cachedPositions[startIdx].dValue;
this.cachedPositions[startIdx].dValue = 0;
for (let i = startIdx + 1; i < cachedPositionsLen; ++i) {
const item = this.cachedPositions[i];
// update height
this.cachedPositions[i].top = this.cachedPositions[i - 1].bottom;
this.cachedPositions[i].bottom = this.cachedPositions[i].bottom - cumulativeDiffHeight;
if (item.dValue !== 0) {
cumulativeDiffHeight += item.dValue;
item.dValue = 0;
}
}
// update our phantom div height
const height = this.cachedPositions[cachedPositionsLen - 1].bottom;
this.phantomHeight = height;
this.phantomContentRef.current.style.height = `${height}px`;
};
複製代碼
當咱們如今有了全部元素的準確高度和位置值時,咱們獲取當前scrollTop (Offset)所對應的開始元素的方法修改成經過 cachedPositions 獲取:
由於咱們的cachedPositions 是一個有序數組,因此咱們在搜索時能夠利用二分查找來下降時間複雜度
getStartIndex = (scrollTop = 0) => {
let idx = binarySearch<CachedPosition, number>(this.cachedPositions, scrollTop,
(currentValue: CachedPosition, targetValue: number) => {
const currentCompareValue = currentValue.bottom;
if (currentCompareValue === targetValue) {
return CompareResult.eq;
}
if (currentCompareValue < targetValue) {
return CompareResult.lt;
}
return CompareResult.gt;
}
);
const targetItem = this.cachedPositions[idx];
// Incase of binarySearch give us a not visible data(an idx of current visible - 1)...
if (targetItem.bottom < scrollTop) {
idx += 1;
}
return idx;
};
onScroll = (evt: any) => {
if (evt.target === this.scrollingContainer.current) {
....
const currentStartIndex = this.getStartIndex(scrollTop);
....
}
};
複製代碼
export enum CompareResult {
eq = 1,
lt,
gt,
}
export function binarySearch<T, VT>(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult) {
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while (start <= end) {
tempIndex = Math.floor((start + end) / 2);
const midValue = list[tempIndex];
const compareRes: CompareResult = compareFunc(midValue, value);
if (compareRes === CompareResult.eq) {
return tempIndex;
}
if (compareRes === CompareResult.lt) {
start = tempIndex + 1;
} else if (compareRes === CompareResult.gt) {
end = tempIndex - 1;
}
}
return tempIndex;
}
複製代碼
getTransform = () =>
`translate3d(0,${this.startIndex >= 1 ? this.cachedPositions[this.startIndex - 1].bottom : 0}px,0)`;
複製代碼
線上Demo:codesandbox.io/s/a-v-list-…