使用 React + Rxjs 實現一個虛擬滾動組件

原文一樣發佈在知乎專欄
https://zhuanlan.zhihu.com/p/...

爲何使用虛擬列表

在咱們的業務場景中遇到這麼一個問題,有一個商戶下拉框選擇列表,咱們簡單的使用 antd 的 select 組件,發現每次點擊下拉框,從點擊到彈出會存在很嚴重的卡頓,在本地測試時,數據庫只存在370條左右數據,這個量級的數據都能感到很明顯的卡頓了(開發環境約700+ms),更別提線上 2000+ 的數據了。Antd 的 select 性能確實不敢恭維,它會簡單的將所有數據 map 出來,在點擊的時候初始化並保存在 document.body 下的一個 DOM 節點中緩存起來,這又帶來了另外一個問題,咱們的場景中,商戶選擇列表不少模塊都用到了,每次點擊以後都會新生成 2000+ 的 DOM 節點,若是把這些節點都存到 document 下,會形成 DOM 節點數量暴漲。react

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

虛擬列表原理

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

圖片描述

下面咱們來一步步將步驟分解,具體代碼能夠查看 Online Demotypescript

這裏是我經過這種思想實現的一個庫,功能會更完善些。數據庫

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>
}

首先須要有一個容器高度的流來裝載容器高度antd

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>
))

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

效果以下

圖片描述

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

  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

Online Demo

相關文章
相關標籤/搜索