原文同樣釋出在知乎專欄
為什麼使用虛擬列表
在我們的業務場景中遇到這麼一個問題,有一個商戶下拉框選擇列表,我們簡單的使用 antd 的 select 元件,發現每次點選下拉框,從點選到彈出會存在很嚴重的卡頓,在本地測試時,資料庫只存在370條左右資料,這個量級的資料都能感到很明顯的卡頓了(開發環境約700+ms),更別提線上 2000+ 的資料了。Antd 的 select 效能確實不敢恭維,它會簡單的將全部資料 map 出來,在點選的時候初始化並儲存在 document.body 下的一個 DOM 節點中快取起來,這又帶來了另一個問題,我們的場景中,商戶選擇列表很多模組都用到了,每次點選之後都會新生成 2000+ 的 DOM 節點,如果把這些節點都存到 document 下,會造成 DOM 節點數量暴漲。
虛擬列表就是為了解決這種問題而存在的。
虛擬列表原理
虛擬列表本質就是使用少量的 DOM 節點來模擬一個長列表。如下圖左所示,不論多長的一個列表,實際上出現在我們視野中的不過只是其中的一部分,這時對我們來說,在視野外的那些 item 就不是必要的存在了,如圖左中 item 5 這個元素)。即使去掉了 item 5 (如右圖),對於使用者來說看到的內容也完全一致。
下面我們來一步步將步驟分解,具體程式碼可以檢視 Online Demo。
這裡是我通過這種思想實現的一個庫,功能會更完善些。
建立適合容器高度的 DOM 元素
以上圖為例,想象一個擁有 1000 元素的列表,如果使用上圖左的方式的話,就需要建立 1000 個 DOM 節點新增在 document 中,而其實每次出現在視野中的元素,只有4個,那麼剩餘的 996 個元素就是浪費。而如果就只建立 4 個 DOM 節點的話,這樣就能節省 996 個DOM 節點的開銷。
解題思路
真實 DOM 數量 = Math.ceil(容器高度 / 條目高度)
定義元件有如下介面
interface IVirtualListOptions {
height: number
}
interface IVirtualListProps {
data$: Observable<string[]>
options$: Observable<IVirtualListOptions>
}
複製程式碼
首先需要有一個容器高度的流來裝載容器高度
private containerHeight$ = new BehaviorSubject<number>(0)
複製程式碼
需要在元件 mount 之後,才能測量容器的真實高度。可以通過一個 ref 來繫結容器元素,在 componentDidMount
之後,獲取容器高度,並通知 containerHeight$
。
this.containerHeight$.next(virtualListContainerElm.clientHeight)
複製程式碼
獲取了容器高度之後,根據上面的公式來計算視窗內應該顯示的 DOM 數量
const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(
map(([ch, { height }]) => Math.ceil(ch / height))
)
複製程式碼
通過組合 actualRows$
和 data$
兩個流,來獲取到應當出現在視窗內的資料切片
const dataInViewSlice$ = combineLatest(this.props.data$, actualRows$).pipe(
map(([data, actualRows]) => data.slice(0, actualRows))
)
複製程式碼
這樣,一個當前時刻的資料來源就獲取到了,訂閱它來將列表渲染出來
dataInViewSlice$.subscribe(data => this.setState({ data }))
複製程式碼
效果
給定的資料有 1000 條,只渲染了前 7 條資料出來,這符合預期。
現在存在另一個問題,容器的滾動條明顯不符合 1000 條資料該有的高度,因為我們只有 7 條真實 DOM,沒有辦法將容器撐開。
撐開容器
在原生的列表實現中,我們不需要處理任何事情,只需要把 DOM 新增到 document 中就可以了,瀏覽器會計算容器的真實高度,以及滾動到什麼位置會出現什麼元素。但是虛擬列表不會,這就需要我們自行解決容器的高度問題。
為了能讓容器看起來和真的擁有1000條資料一樣,就需要將容器的高度撐開到 1000 條元素該有的高度。這一步很容易,參考下面公式
解題思路
真實容器高度 = 資料總數 * 每條 item 的高度
將上述公式換成程式碼
const scrollHeight$ = combineLatest(this.props.data$, this.props.options$).pipe(
map(([data, { height }]) => data.length * height)
)
複製程式碼
效果
這樣看起來就比較像有 1000 個元素的列表了。
但是滾動之後發現,下面全是空白的,由於列表只存在7個元素,空白是正常的。而我們期望隨著滾動,元素能正確的出現在視野中。
滾動列表
這裡有三種實現方式,而前兩種基本一樣,只有細微的差別,我們先從最初的方案說起。
完全重刷列表
這種方案是最簡單的實現,我們只需要在列表滾動到某一位置的時候,去計算出當前的視窗中列表的索引,有了索引就能得到當前時刻的資料切片,從而將資料渲染到檢視中。
為了讓列表效果更好,我們將渲染的真實 DOM 數量多增加 3 個
const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(
map(([ch, { height }]) => Math.ceil(ch / height) + 3)
)
複製程式碼
首先定義一個視窗滾動事件流
const scrollWin$ = fromEvent(virtualListElm, 'scroll').pipe(
startWith({ target: { scrollTop: 0 } })
)
複製程式碼
在每次滾動的時候去計算當前狀態的索引
const shouldUpdate$ = combineLatest(
scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
this.props.options$,
actualRows$
).pipe(
// 計算當前列表中最頂部的索引
map(([st, { height }, actualRows]) => {
const firstIndex = Math.floor(st / height)
const lastIndex = firstIndex + actualRows - 1
return [firstIndex, lastIndex]
})
)
複製程式碼
這樣就能在每一次滾動的時候得到視窗內資料的起止索引了,接下來只需要根據索引算出 data 切片就好了。
const dataInViewSlice$ = combineLatest(this.props.data$, shouldUpdate$).pipe(
map(([data, [firstIndex, lastIndex]]) => data.slice(firstIndex, lastIndex + 1))
);
複製程式碼
拿到了正確的資料,還沒完,想象一下,雖然我們隨著滾動的發生計算出了正確的資料切片,但是正確的資料卻沒有出現在正確的位置,因為他們的位置是固定不變的。
因此還需要對元素的位置做位移(逮蝦戶)的操作,首先修改一下傳給檢視的資料結構
const dataInViewSlice$ = combineLatest(
this.props.data$,
this.props.options$,
shouldUpdate$
).pipe(
map(([data, { height }, [firstIndex, lastIndex]]) => {
return data.slice(firstIndex, lastIndex + 1).map(item => ({
origin: item,
// 用來定位元素的位置
$pos: firstIndex * height,
$index: firstIndex++
}))
})
);
複製程式碼
接下把 HTML 結構也做一下修改,將每一個元素的位移新增進去
this.state.data.map(data => (
<div
key={data.$index}
style={{
position: 'absolute',
width: '100%',
// 定位每一個 item
transform: `translateY(${data.$pos}px)`
}}
>
{(this.props.children as any)(data.origin)}
</div>
))
複製程式碼
這樣就完成了一個虛擬列表的基本形態和功能了。
效果如下
但是這個版本的虛擬列表並不完美,它存在以下幾個問題
- 計算浪費
- DOM 節點的建立和移除
計算浪費
每次滾動都會使得 data 發生計算,雖然藉助 virtual DOM 會將不必要的 DOM 修改攔截掉,但是還是會存在計算浪費的問題。
實際上我們確實應該觸發更新的時機是在當前列表的索引發生了變化的時候,即開始我的列表索引為 [0, 1, 2]
,滾動之後,索引變為了 [1, 2, 3]
,這個時機是我們需要更新檢視的時機。藉助於 rxjs 的操作符,可以很輕鬆的搞定這個事情,只需要把 shouldUpdate$
流做一次過濾操作即可。
const shouldUpdate$ = combineLatest(
scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
this.props.options$,
actualRows$
).pipe(
// 計算當前列表中最頂部的索引
map(([st, { height }, actualRows]) => [Math.floor(st / height), actualRows]),
// 如果索引有改變,才觸發重新 render
filter(([curIndex]) => curIndex !== this.lastFirstIndex),
// update the index
tap(([curIndex]) => this.lastFirstIndex = curIndex),
map(([firstIndex, actualRows]) => {
const lastIndex = firstIndex + actualRows - 1
return [firstIndex, lastIndex]
})
)
複製程式碼
效果
DOM 節點的建立和移除
如果仔細對比會發現,每次列表發生更新之後,是會發生 DOM 的建立和刪除的,如下圖所示,在滾動了之後,原先位於列表中的第一個節點被移除了。
而我期望的理想的狀態是,能夠重用 DOM,不去刪除和建立它們,這就是第二個版本的實現。
複用 DOM 重刷列表
為了達到節點的複用,我們需要將列表的 key 設定為陣列索引,而非一個唯一的 id,如下
this.state.data.map((data, i) => <div key={i}>{data}</div>)
複製程式碼
只需要這一點改動,再看看效果
可以看到資料變了,但是 DOM 並沒有被移除,而是被複用了,這是我想要的效果。
觀察一下這個版本的實現與上一版本有何區別
是的,這個版本,每一次 render 都會使得整個列表樣式發生變化,而且還有一個問題,就是列表滾動到最後的時候,會發生 DOM 減少的情況,雖然並不影響顯示,但是還是有 DOM 的建立和移除的問題存在。
複用 DOM + 按需更新列表
為了能讓列表只按照需要進行更新,而不是全部重刷,我們就需要明確知道有哪些 DOM 節點被移出了視野範圍,操作這些視野範圍外的節點來補充列表,從而完成列表的按需更新,如下圖
假設使用者在向下滾動列表的時候,item 1 的 DOM 節點被移出了視野,這時我們就可以把它移動到 item 5 的位置,從而完成一次滾動的連續,這裡我們只改變了元素的位置,並沒有建立和刪除 DOM。
dataInViewSlice$
流依賴props.data$
、props.options$
、shouldUpdate$
三個流來計算出當前時刻的 data 切片,而檢視的資料完全是根據 dataInViewSlice$
來渲染的,所以如果想要按需更新列表,我們就需要在這個流裡下手。
在容器滾動的過程中存在如下幾種場景
- 使用者慢慢地向上或者向下滾動:移出視野的元素是一個接一個的
- 使用者直接跳轉到列表的一個指定位置:這時整個列表都可能完全移出視野
但是這兩種場景其實都可以歸納為一種情況,都是求前一種狀態與當前狀態之間的索引差集。
實現
在 dataInViewSlice$
流中需要做兩步操作。第一,在初始載入,還沒有陣列的時候,填充一個陣列出來;第二,根據滾動到當前時刻時的起止索引,計算出二者的索引差集,更新陣列,這一步便是按需更新的核心所在。
先來實現第一步,只需要稍微改動一下原先的 dataInViewSlice$
流的 map 實現即可完成初始資料的填充
const dataSlice = this.stateDataSnapshot;
if (!dataSlice.length) {
return this.stateDataSnapshow = data.slice(firstIndex, lastIndex + 1).map(item => ({
origin: item,
$pos: firstIndex * height,
$index: firstIndex++
}))
}
複製程式碼
接下來完成按需更新陣列的部分,首先需要知道滾動前後兩種狀態之間的索引差異,比如滾動前的索引為 [0,1,2]
,滾動後的索引為 [1,2,3]
,那麼他們的差集就是 [0]
,說明老陣列中的第一個元素被移出了視野,那麼就需要用這第一個元素來補充到列表最後,成為最後一個元素。
首先將陣列差集求出來
// 獲取滾動前後索引差集
const diffSliceIndexes = this.getDifferenceIndexes(dataSlice, firstIndex, lastIndex);
複製程式碼
有了差集就可以計算新的陣列組成了。還以此圖為例,使用者向下滾動,當元素被移除視野的時候,第一個元素(索引為0)就變成最後一個元素(索引為4),也就是,oldSlice [0,1,2,3]
-> newSlice [1,2,3,4]
。
在變換的過程中,[1,2,3]
三個元素始終是不需要動的,因此我們只需要擷取不變的 [1,2,3]
再加上新的索引 4 就能變成 [1,2,3,4]
了。
// 計算視窗的起始索引
let newIndex = lastIndex - diffSliceIndexes.length + 1;
diffSliceIndexes.forEach(index => {
const item = dataSlice[index];
item.origin = data[newIndex];
item.$pos = newIndex * height;
item.$index = newIndex++;
});
return this.stateDataSnapshot = dataSlice;
複製程式碼
這樣就完成了一個向下滾動的陣列拼接,如下圖所示,DOM 確實是只更新超出視野的元素,而沒有重刷整個列表。
但是這只是針對向下滾動的,如果往上滾動,這段程式碼就會出問題。原因也很明顯,陣列在向下滾動的時候,是往下補充元素,而向上滾動的時候,應該是向上補充元素。如 [1,2,3,4]
-> [0,1,2,3]
,對它的操作是 [1,2,3]
保持不變,而 4號元素變成了 0號元素,所以我們需要根據不同的滾動方向來補充陣列。
先建立一個獲取滾動方向的流 scrollDirection$
// scroll direction Down/Up
const scrollDirection$ = scrollWin$.pipe(
map(() => virtualListElm.scrollTop),
pairwise(),
map(([p, n]) => n - p > 0 ? 1 : -1),
startWith(1)
);
複製程式碼
將 scrollDirection$
流加入到 dataInViewSlice$
的依賴中
const dataInViewSlice$ = combineLatest(this.props.data$, this.options$, shouldUpdate$).pipe(
withLatestFrom(scrollDirection$)
)
複製程式碼
有了滾動方向,我們只需要修改 newIndex 就好了
// 向下滾動時 [0,1,2,3] -> [1,2,3,4] = 3
// 向上滾動時 [1,2,3,4] -> [0,1,2,3] = 0
let newIndex = dir > 0 ? lastIndex - diffSliceIndexes.length + 1 : firstIndex;
複製程式碼
至此,一個功能完善的按需更新的虛擬列表就基本完成了,效果如下
是不是還差了什麼?
沒錯,我們還沒有解決列表滾動到最後時會建立、刪除 DOM 的問題了。
分析一下問題原因,應該能想到是 shouldUpdate$
這裡在最後一屏的時候,計算出來的索引與最後一個索引的差小於了 actualRows$
中計算出來的數,所以導致了列表數量的變化,知道了原因就好解決問題了。
我們只需要計算出陣列在維持真實 DOM 數量不變的情況下,最後一屏的起始索引應為多少,再和計算出來的視窗中第一個元素的索引進行對比,取二者最小為下一時刻的起始索引。
計算最後一屏的索引時需要得知 data 的長度,所以先將 data 依賴拉進來
const shouldUpdate$ = combineLatest(
scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
this.props.data$,
this.props.options$,
actualRows$
)
複製程式碼
然後來計算索引
// 計算當前列表中最頂部的索引
map(([st, data, { height }, actualRows]) => {
const firstIndex = Math.floor(st / height)
// 在維持 DOM 數量不變的情況下計算出的索引
const maxIndex = data.length - actualRows < 0 ? 0 : data.length - actualRows;
// 取二者最小作為起始索引
return [Math.min(maxIndex, firstIndex), actualRows];
})
複製程式碼
這樣就真正完成了完全複用 DOM + 按需更新 DOM 的虛擬列表元件。
Github
上述程式碼具體請看線上 DEMO