記錄--虛擬滾動探索與封裝

来源:https://www.cnblogs.com/smileZAZ/archive/2023/03/01/17169500.html
-Advertisement-
Play Games

這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 1. 介紹 什麼是虛擬滾動?虛擬滾動就是通過js控制大列表中的dom創建與銷毀,只創建可視區域dom,非可視區域的dom不創建。這樣在渲染大列表中的數據時,只創建少數的dom,提高性能。 2. 分類 在虛擬滾動技術中,虛擬滾動可以分為定高 ...


這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助

1. 介紹

什麼是虛擬滾動虛擬滾動就是通過js控制大列表中的dom創建與銷毀,只創建可視區域dom非可視區域的dom不創建。這樣在渲染大列表中的數據時,只創建少數的dom,提高性能。

2. 分類

在虛擬滾動技術中,虛擬滾動可以分為定高虛擬滾動非定高虛擬滾動。定高指的是每一個列表元素都是高度固定的,非定高指的是每一個列表元素的高度是動態變化的。定高虛擬滾動的實現比較容易,而且性能高;非定高虛擬滾動的失效稍微複雜,而且性能比定高虛擬滾動要差一些。無論是定高虛擬滾動還是非定高虛擬滾動,都是虛擬滾動技術的分類,對於大數據渲染都有很大的性能提升

下麵我們逐步分析兩種虛擬滾動技術的實現,並且封裝成常用的組件。選用技術棧是vue,改成react或angular也是十分方便的。

3. 定高虛擬滾動

3.1 封裝思路

虛擬滾動的結構如下圖:

image.png 虛擬滾動由圖三部分組成,渲染容器container渲染數據list撐開滾動條的容器clientHeightRef。渲染容器就是我們需要渲染的區域,渲染數據就是可視區域的數據,撐開滾動條的容器代表所有數據渲染出來後的高度。

由於我們只渲染可視區域的數據,那麼渲染容器的滾動條高度是不正確的,需要撐開滾動條的容器來撐開實際的高度

html結構如下:

<div class="virtual-list">
      <!-- 這裡是用於撐開高度,出現滾動條用 -->
      <div class="list-view-phantom" ref="clientHeightRef" :style="{ height: list.length*itemHeight + 'px' }"></div>
      <ul v-if="list.length > 0" class="option-warp" ref="contentRef">
        <li
          :style="{ height: itemHeight + 'px' }"
          class="option"
          v-for="(item, index) in virtualRenderData"
          :key="index"
        >
          {{item}}
        </li>
      </ul>
  </div>

每一條數據的高度是this.itemHeight=10,渲染容器的高度是this.containerHeight=300,那麼一屏需要渲染的數據是count=Math.ceil(this.containerHeight / this.itemHeight)

假設我們我們的需要的數據list如下:

const list = [
    {id:1,name:1},
    {id:2,name:3},
    ....
]

那麼撐開滾動條的容器的高度是this.list.length*this.itemHeight

我們給渲染容器加一個監聽滾動的事件,主要是獲取當前滾動的scrollTop,用來更新渲染可視區域的數據。如下,我們封裝一個更新渲染可視區域的數據函數:

const update = function(scrollTop = 0){
    this.$nextTick(() => {
        // 獲取當前可展示數量
        const count = Math.ceil(this.containerHeight / this.itemHeight)
        const start = Math.floor(scrollTop / this.itemHeight)
        // 取得可見區域的結束數據索引
        const end = start + count
        // 計算出可見區域對應的數據,讓 Vue.js 更新
        this.virtualRenderData = this.list.slice(start, end)
    })
}

 

當滾動條滾動的時候,我們需要從list中截取當前渲染容器剛剛好可以渲染的數據,達到像真的滾動的了一樣。上面的滾動函數雖然已經更新了渲染可視區域的數據,但是當我們滾動的時候會發現內容塊被滾動到了上面,再次滾動的時候直接就不見了。這是由於滾動條是由撐開滾動條的容器撐開的,渲染的內容高度只有容器的高度,所以它只會在頂部出現,滾動的時候自然就不會動,效果如下:

image.png 所以當我們滾動滾動的時候,還需要將渲染內容往對應的方向偏移。比如偏移的y方向距離就是scrollTop的距離,

this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${scrollTop * this.itemHeight}px, 0)`

這樣就能達到滾動的時候,渲染內容始終保持在渲染容器的頂部,好像真的隨著滾動條滾動而滾動。

但是實際上這樣的效果是不好的,因為我們更新的顆粒度是按每一條數據來分的,而不是按scrollTop來進行的,所以渲染內容的偏移量也需要按照每一條數據的顆粒度來進行更新,代碼如下:

const update = function(scrollTop = 0){
    this.$nextTick(() => {
        // 獲取當前可展示數量
        const count = Math.ceil(this.containerHeight / this.itemHeight)
        const start = Math.floor(scrollTop / this.itemHeight)
        // 取得可見區域的結束數據索引
        const end = start + count
        // 計算出可見區域對應的數據,讓 Vue.js 更新
        this.virtualRenderData = this.list.slice(start, end)
        + this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${start * this.itemHeight}px, 0)`
    })
}

上面的代碼基本可以滿足基本的使用,但是當我們滾動比較快的時候,渲染區域底部會出現瞬間留白,是因為dom沒有及時的渲染,原因是我們只渲染剛剛好一屏的數據。

為了減少留白的出現,我們應該預渲染幾條數據bufferCount,增加渲染緩存區間

const update = function(scrollTop = 0){
    this.$nextTick(() => {
        // 獲取當前可展示數量
        const count = Math.ceil(this.containerHeight / this.itemHeight)
        const start = Math.floor(scrollTop / this.itemHeight)
        // 取得可見區域的結束數據索引
        + const end = start + count + bufferCount
        // 計算出可見區域對應的數據,讓 Vue.js 更新
        this.virtualRenderData = this.list.slice(start, end)
        this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${start * this.itemHeight}px, 0)`
    })
}

3.2 完整代碼和演示地址

定高虛擬滾動演示地址:atdow.github.io/learning-co…

定高虛擬滾動代碼地址:github.com/atdow/learn…

4. 非定高虛擬滾動

4.1 封裝思路

看了上面的定高虛擬滾動,我們對虛擬滾動技術已經有了基本的瞭解。對於非定高虛擬滾動,需要解決的最大問題就是每一條需要渲染的數據的高度是不確定,這樣我們就很難確定一屏需要渲染多少條數據。

為了確定一屏需要渲染多少條數據,我們需要假設每條需要渲染數據的高度為一個假設值estimatedItemHeight=40,定義一個用於存儲每一條渲染數據高度的數組itemHeightCache=[],定義一個用於存儲每一條渲染數據距離頂部距離的數組itemTopCache=[](用於提升性能用,後面會做解釋),以及定義撐開滾動條滾動容器高度的變數scrollBarHeight

假設我們我們的需要的數據list如下:

const list = [
    {id:1,name:1},
    {id:2,name:3},
    ....
]

我們先初始化itemHeightCache、itemTopCache和scrollBarHeight

const estimatedTotalHeight = this.list.reduce((pre, current, index) => {
        // 給每一項一個虛擬高度
        this.itemHeightCache[index] = { isEstimated: true, height: this.estimatedItemHeight }
        // 給每一項距頂部的虛擬高度
        this.itemTopCache[index] = index === 0 ? 0 : this.itemTopCache[index - 1] + this.estimatedItemHeight
        return pre + this.estimatedItemHeight
      }, 0)
// 列表總高
this.scrollBarHeight = estimatedTotalHeight

有了上面的初始化數據,我們就可以進行第一次假設渲染了:

// 更新數據函數
const update = function() {
      const startIndex = this.getStartIndex()
      // 如果是奇數開始,就取其前一位偶數
      if (startIndex % 2 !== 0) {
        this.startIndex = startIndex - 1
      } else {
        this.startIndex = startIndex
      }
      this.endIndex = this.getEndIndex()
      this.visibleList = this.list.slice(this.startIndex, this.endIndex)
      // 移動渲染區域
      if (this.$refs.contentRef) {
        this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${this.itemTopCache[this.startIndex]}px, 0)`
      }
}

// 獲取開始索引
cont getStartIndex = function() {
      const scrollTop = this.scrollTop
      // 每一項距頂部的距離
      const arr = this.itemTopCache
      let index = -1
      let left = 0,
        right = arr.length - 1,
        mid = Math.floor((left + right) / 2)
      // 判斷 有可迴圈項時進入
      while (right - left > 1) {
        /*
        二分法:拿每一次獲得到的 距頂部距離 scrollTop 同 獲得到的模擬每個列表據頂部的距離作比較。
        arr[mid] 為虛擬列高度的中間項
        不斷while 迴圈,利用二分之一將數組分割,減小搜索範圍
        直到最終定位到 目標index 值
      */
        // 目標數在左側
        if (scrollTop < arr[mid]) {
          right = mid
          mid = Math.floor((left + right) / 2)
        } else if (scrollTop > arr[mid]) {
          // 目標數在右側
          left = mid
          mid = Math.floor((left + right) / 2)
        } else {
          index = mid
          return index
        }
      }
      index = left
      return index
}

// 獲取結束索引
const getEndIndex = function() {
      const clientHeight = this.$refs.scrollbarRef?.clientHeight //渲染容器高度
      let itemHeightTotal = 0
      let endIndex = 0
      for (let i = this.startIndex; i < this.dataList.length; i++) {
        if (itemHeightTotal < clientHeight) {
          itemHeightTotal += this.itemHeightCache[i].height
          endIndex = i
        } else {
          break
        }
      }
      endIndex = endIndex
      return endIndex
}

update函數是用來更新需要渲染的數據的,核心邏輯就是獲取截取數據的開始索引getStartIndex和結束索引getEndIndex以及移動被渲染數據容器

當滾動條滾動的時候,我們就將scrollTop存起來,這個時候從itemTopCache中獲取距離scrollTop最近的索引,就是我們需要截取數據的開始索引。因為itemTopCache存儲的就是每一條數據距離頂部的距離,所以直接取就行了,這也是為什麼我們要先存儲itemTopCache。因為滾動的時候,我們都要從itemTopCache中使用二分法查找,不然就得從itemHeightCache中從頭到尾一個一個遍歷去對比查找,在數據量大的時候容易造成卡頓。

getEndIndex核心就是從itemHeightCache(存儲每一條渲染數據高度的數組)中一條一條拿數據,從startIndex開始拿,一直拿到剛好填滿渲染容器高度即可,就可以得到我們的截取數據的最後索引 (實際上這樣是不夠完美的,後面繼續講解)。

移動被渲染數據容器的技巧和上面定高虛擬滾動類似,這裡不做太多解釋。

在初始化完itemHeightCache、itemTopCache和scrollBarHeight後,我們就可以手動調一次update函數進行第一次渲染了(this.update()),使用的都是預設的假定值。

在說更新之前,我們需要先定義一下子組件,也就是每一條被渲染數據的容器。這樣當數據被更新渲染之後(需要通知暴露indexheight參數),就可以得到真實的dom的高度,通知我們去更新itemHeightCache、itemTopCache和scrollBarHeight,更新邏輯如下:

const updateItemHeight = function({ index, height }) {
      // 每次創建的時候都會拋出事件,因為沒有處理非同步的情況,所以必須每次高度變化都需要更新
      // dom元素載入後得到實際高度 重新賦值回去
      this.itemHeightCache[index] = { isEstimated: false, height: height }
      // 重新確定列表的實際總高度
      this.scrollBarHeight = this.itemHeightCache.reduce((pre, current) => {
        return pre + current.height
      }, 0)
      // 更新itemTopCache
      const newItemTopCache = [0]
      for (let i = 1, l = this.itemHeightCache.length; i < l; i++) {
        // 虛擬每項距頂部高度 + 實際每項高度
        newItemTopCache[i] = this.itemTopCache[i - 1] + this.itemHeightCache[i - 1].height
      }
      // 獲得每一項距頂部的實際高度
      this.itemTopCache = newItemTopCache
}

dom更新完之後,初始化預定值計算出來需要的渲染數據就真的被渲染了,我們這個時候就可以再次調用update函數再次更新數據,自動更新彌補到渲染真實一屏需要渲染的數據了。

const updateItemHeight = function({ index, height }) {
      // 每次創建的時候都會拋出事件,因為沒有處理非同步的情況,所以必須每次高度變化都需要更新
      // dom元素載入後得到實際高度 重新賦值回去
      this.itemHeightCache[index] = { isEstimated: false, height: height }
      // 重新確定列表的實際總高度
      this.scrollBarHeight = this.itemHeightCache.reduce((pre, current) => {
        return pre + current.height
      }, 0)
      // 更新itemTopCache
      const newItemTopCache = [0]
      for (let i = 1, l = this.itemHeightCache.length; i < l; i++) {
        // 虛擬每項距頂部高度 + 實際每項高度
        newItemTopCache[i] = this.itemTopCache[i - 1] + this.itemHeightCache[i - 1].height
      }
      // 獲得每一項距頂部的實際高度
      this.itemTopCache = newItemTopCache
      + this.update() // 自動更新
}

當滾動的時候,存儲scrollTop,手動調用update函數,將會自動更新,整個過程如下:

image.png

html結構如下:

 <div class="virtual-list-dynamic-height" ref="scrollbarRef" @scroll="onScroll">
    <div class="list-view-phantom" :style="{ height: scrollBarHeight + 'px' }"></div>
    <!-- 列表總高 -->
    <ul ref="contentRef">
      <Item
        v-for="item in visibleList"
        :data="item.data"
        :index="item.index"
        :key="item.index"
        @update-height="updateItemHeight"
      >
        {{item}}
      </Item>
    </ul>
</div>

跟定高虛擬滾動不同點就是,需要定義子組件,同時傳遞給子組件index索引。visibleList需要定義為[{index:xxx,data:xxx}]的數據格式,將index給儲存起來,這樣在子組件更新的時候才能獲取到index

4.2 調優

在上面的代碼中,基本可以實現基礎的非定高虛擬滾動了,但是還是無法應對複雜的情況。

我們舉一個極端的例子:當一條數據的真實高度是200,其他數據的真實高度高度是10,渲染容器的高度是300。在第一次假設渲染並且更新後我們的itemHeightCache、itemTopCache和scrollBarHeight後,我們將會得到這樣的結果。渲染容器中渲染的是數據是第一條數據和剩下的9條數據,剛剛好渲染一屏數據,這樣是沒有任何問題的。

當滾動條滾動的時候,我們滾動了20px的距離,獲取到的startIndex應該是0,因為距離頂部最近的數據是第一條數據,這個就會造成下部空白20px的區域。當滾動了80px的時候,獲取到的startIndex也是0,原理同上,下部造成了空白區域將會是恐怖的80px

image.png

為瞭解決空白局域,靠緩衝渲染bufferCount是不夠的,就算bufferCount給了4,多四條數據也無法填充滿空白區域。調大bufferCount容易造成性能問題,也不能確定bufferCount到底給多少才能合適。所以需要調整getEndIndex的邏輯,不再是從startIndex獲取到剛好填充滿渲染區域,而是從startIndex獲取到剛好填充滿渲染區域+statIndex的高度。這樣無論startIndex的高度是多少,我們都能填充滿整個渲染容器,因為空白區域最大高度就是startIndex的高度。同時我們在endIndex上加上bufferCount,就可以達到完美的效果。

// 獲取結束索引
const getEndIndex = function() {
      + const whiteHeight = this.scrollTop - this.itemTopCache[this.startIndex] // 出現留白的高度
      const clientHeight = this.$refs.scrollbarRef?.clientHeight //渲染容器高度
      let itemHeightTotal = 0
      let endIndex = 0
      for (let i = this.startIndex; i < this.dataList.length; i++) {
        + if (itemHeightTotal < clientHeight+whiteHeight) {
          itemHeightTotal += this.itemHeightCache[i].height
          endIndex = i
        } else {
          break
        }
      }
      + endIndex = endIndex + bufferCount
      return endIndex
}

3.3 完整代碼和演示地址

非定高虛擬滾動演示地址:atdow.github.io/learning-co…

非定高虛擬滾動代碼地址:github.com/atdow/learn…

4 總結

有了非定高虛擬滾動組件,不就是可以應對各種情況了,為什麼還需要做定高虛擬滾動組件?

在上面的封裝思路中,我們能清晰知道非定高虛擬滾動組件是用假定值進行渲染的,在真實渲染過後才會彌補更新,而定高虛擬滾動所有東西都是確定的。所以定高虛擬滾動的優勢就是比非定高虛擬滾動性能高,缺點就是只能應對每一條渲染數據是固定的情況。

定高虛擬滾動:

  • 優點:性能比非定高虛擬滾動高
  • 缺點:只能應用於每一條渲染數據高度是固定的場景

非定高虛擬滾動:

  • 優點:性能比定高虛擬滾動低
  • 缺點:能應用於每一條渲染數據高度是動態的場景

本文轉載於:

https://juejin.cn/post/7204450037031092283

如果對您有所幫助,歡迎您點個關註,我會定時更新技術文檔,大家一起討論學習,一起進步。

 


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 多表查詢(上) 一. 多表關係 在實際應用中,根據需求,設計的表結構之間存在聯繫,聯繫一般分為以下三種 一對多(多對一) 多對多 一對一 1. 一對多(多對一) 案例:部門與員工的關係,一個部門對應多個員工,一個員工對應一個部門。 實現:在多的一方建立外鍵,指向一的一方的主鍵 2. 多對多 案例:學 ...
  • 為了客觀、準確、有效地評估 TDengine 3.0 的性能指標,我們決定使用 TSBS(Time Series Benchmark Suite)作為基準性能測試平臺,針對 DevOps 場景的數據集對 TDengine 3.0 展開整體(包括寫入、查詢、存儲、資源消耗等)性能評估。 ...
  • MySQL中明確查詢語句的執行順序極其重要,瞭解執行順序才不至於犯一些簡單錯誤,例如having 後面是否可以使用 select 中重命名的列名等問題。另外SQL中實際使用最頻繁的就是查詢(Queing),要想寫出高質量、高性能的查詢語句,必須深入地瞭解SQL的邏輯查詢處理順序和機制。 ...
  • 親愛的社區小伙伴們,歷時數月,我們很高興地宣佈,ChunJun 即將迎來 1.16 Release 版本的正式發佈。在新版本中,ChunJun 新增了一批常用功能,進行了多項功能優化和問題修複,併在用戶使用體驗上進行了極大地改善。有17位Contributor 為 ChunJun 提交了多項優化和修 ...
  • 衝突解決 假設你想在應用中使用 some_package 和 other_package,並且它們依賴於不同版本的 url_launcher。於是我們便有了潛在的衝突。避免這種情況的最好方法是 package 的作者在指定依賴項時使用 版本範圍 而非特定版本。 dependencies: url_l ...
  • VE-Plus 自研輕量級 vue3.js 桌面pc端UI組件庫 經過一個多月的籌劃及開發,今天給大家帶來一款全新的Vue3桌面端UI組件庫VEPlus。新增了35+常用的組件,採用vue3 setup語法糖開發,在使用上和element-ui比較類似,極易快速上手。 ve-plus 致力數據驅動視 ...
  • css的全局關鍵字有下麵幾個: inherit : 繼承父級屬性 initial:將元素初始化成css的初始值 unset:繼承和初始化元素 revert:還原到瀏覽器內置樣式 all:代指所有css屬性 下麵分別說說這幾個關鍵字的實際作用 inherit 繼承父級屬性 inherit可以繼承父級屬 ...
  • 客戶端和伺服器 客戶端與伺服器的通信 互聯網:錯綜複雜的網路,比如每個快遞車走的路. TCP/IP:定義數據如何傳輸的通信協議,比如快遞的包裝,發貨規則. DNS:功能變數名稱系統伺服器,輸入一個網址時,找到網頁的伺服器,才能發送 HTTP 請求到正確的地方。(功能變數名稱->IP),比如發貨地址. HTTP:超文 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...