如何用React + Rxjs實現一個虛擬滾動組件?

爲什麼使用虛擬列表

在我們的業務場景中遇到這麼一個問題,有一個商戶下拉框選擇列表,我們簡單的使用 antd 的 select 組件,發現每次點擊下拉框,從點擊到彈出會存在很嚴重的卡頓,在本地測試時,數據庫只存在370條左右數據,這個量級的數據都能感到很明顯的卡頓了(開發環境約700+ms),更別提線上 2000+ 的數據了。

Antd 的 select 性能確實不敢恭維,它會簡單的將全部數據 map 出來,在點擊的時候初始化並保存在 document.body 下的一個 DOM 節點中緩存起來,這又帶來了另一個問題,我們的場景中,商戶選擇列表很多模塊都用到了,每次點擊之後都會新生成 2000+ 的 DOM 節點,如果把這些節點都存到 document 下,會造成 DOM 節點數量暴漲。

虛擬列表就是爲了解決這種問題而存在的。

虛擬列表原理

虛擬列表本質就是使用少量的 DOM 節點來模擬一個長列表。如下圖左所示,不論多長的一個列表,實際上出現在我們視野中的不過只是其中的一部分,這時對我們來說,在視野外的那些 item 就不是必要的存在了,如圖左中 item 5 這個元素)。即使去掉了 item 5 (如右圖),對於用戶來說看到的內容也完全一致。

下面我們來一步步將步驟分解,具體 DEMO 示例可以查看 Online Demo。

這裏是我通過這種思想實現的一個庫,功能會更完善些:
https://github.com/musicq/vist

創建適合容器高度的 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%',
      // 根據計算出的元素位移定位元素位置
      transform: `translateY(${data.$pos}px)`
    }}
  >
    {(this.props.children as any)(data.origin)}
  </div>
))

這樣就完成了一個虛擬列表的基本形態和功能了。

效果如下

v1版 - 完全重刷列表

但是這個版本的虛擬列表並不完美,它存在以下幾個問題

1.計算浪費
2.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$ 來渲染的,所以如果想要按需更新列表,我們就需要在這個流裏下手。

在容器滾動的過程中存在如下幾種場景:

1.用戶慢慢地向上或者向下滾動:移出視野的元素是一個接一個的;

2.用戶直接跳轉到列表的一個指定位置:這時整個列表都可能完全移出視野。

但是這兩種場景其實都可以歸納爲一種情況,都是求前一種狀態與當前狀態之間的索引差集。

實現

在 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:https://github.com/musicq/vist

上述代碼具體請看在線 DEMO:

https://stackblitz.com/edit/react-ts-virtuallist

更多內容,請關注前端之巔。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章