新手也能看懂的虛擬滾動實現方法

本篇文章致力於小白也能懂的虛擬滾動實現原理,一步一步深刻比較以及優化實現方案,內容淺顯易懂,但篇幅可能較長。 若是你只想瞭解實現思路,那麼能夠直接看圖或者跳到文章最後。node

話很少說,直接開始好吧。web

爲何須要虛擬滾動

想像一下,當你有10萬數據須要展現的時候,咋辦呢?咱們來試一下將它所有加載出來。 咱們再用chrome的性能功能測試一下,獲得下圖:chrome

截屏20200604 下午5.35.48.png

渲染時長長達4.5s,DOM節點有55萬個!!!雖然在chrome中的滾動性能還能夠,應該是有作過優化。可是safari上直接打不開。數組

實事告訴咱們暴力作法是行不通的,咱們須要其餘方式,也有人說了咱們能夠採用分頁的方法。可是某些情景下(或者某些產品經理的壓迫下),好比聯繫人列表,聊天列表,咱們仍是須要採用滾動的交互方式。瀏覽器

並且就算是正常大小的列表(幾百或幾千),使用虛擬滾動對於頁面性能的提高也是能夠感知的。緩存

並且在谷歌的Lighthouse開發推薦中有寫到:性能優化

  • Have more than 1,500 nodes total.
  • Have a depth greater than 32 nodes.
  • Have a parent node with more than 60 child nodes.

這裏表示DOM節點過多會影響頁面性能,而且給出了推薦的最多節點數量。數據結構

基本思想

好了,在肯定了咱們須要優化長列表的渲染性能以後,那麼接下來就是,怎麼作呢?app

從上面咱們測試的例子來看,長列表渲染過程當中耗時最長的就是Rendering,瀏覽器渲染這一部分。並且咱們也看到了,它會生成萬級的DOM節點。這些都是致使性能變差的主要緣由。若是咱們能讓瀏覽器渲染時長降到ms級,那確定會流暢不少。而如何下降這一耗時呢?答案就是讓瀏覽器只渲染看的見得DOM節點。dom

按需渲染

咱們的數據量很龐大,可是咱們同一時間能看見的卻只有那麼十幾二十幾個,那麼幹嗎要所有一次性都渲染出來呢是吧。當咱們同一時間只渲染咱們看的見的這些DOM節點的時候,瀏覽器須要渲染的節點就會很是很是少了,這會極大的下降渲染時長!

xxxr.jpg

如上圖,咱們只渲染可視區域能見到的3,4,5,6這幾個元素,而其餘的都不會被渲染。

模擬滾動

咱們只渲染能看見的元素,這就意味着咱們沒有原生滾動的功能。咱們須要去模擬滾動行爲,在用戶滾動滑輪或者滑動屏幕時,相應的滾動列表。咱們這裏的滾動列表不是真正的滾動列表,而是根據滾動的位置從新渲染可見的列表元素。

當這個操做時間跨度足夠小時,它看起來就像是在滾動同樣。

mngd.jpg

這有點像咱們在畫幀動畫同樣,每次用戶滑動形成偏移量改變,咱們都會根據這個偏移量去渲染新的列表元素。就像是在一幀一幀的播放動畫同樣,當兩幀間隔足夠小時,動畫看起來就會很流暢,就像是在滾動同樣。

代碼實現

沒錯,上面這兩個就能基本實現長列表的按需顯示以及滾動功能,話很少說咱們直接來實現一下。 首先咱們來看看咱們有什麼:一個存放列表的父元素(視口元素),一個列表數組。

而後咱們來實現第一個思路:按需渲染。咱們只渲染視口能看見的元素,這裏有幾個問題:

  1. 視口能渲染幾個列表元素? 視口的高度咱們已經知道了(父元素的高度),假設偏移量爲0,咱們從第一個元素開始渲染,那麼它能裝幾個列表元素呢?這裏就須要咱們給每個列表元素設置一個高度。經過累加高度計算找到第一個加完它的高度後總高度超出視口高度的列表元素。

    未命名做品 3.jpg
    由上圖咱們能夠看見,假如每一個元素都是30px,視口高度爲100px,那麼經過累加計算,咱們能夠知道視口最多能看到第四個元素。

  2. 怎麼知道該渲染哪幾個元素? 當用戶沒有滾動時,偏移量爲0,咱們知道從第一個元素開始渲染。那麼假如當用戶累計滾動了x像素後,又該從哪一個元素開始渲染呢?

    未命名做品 2.jpg
    咱們要作的第一件事是記錄用戶操做的列表的滾動總距離virtualOffset,而後咱們經過從第一個元素累加高度獲得heightSum,當heightSumvirtualOffset大時,最後一個累加高度的元素,就是視口須要渲染的第一個元素!圖中咱們看到第一個元素是3。 而且!你能夠從圖中看到,3並非完整可見的,他向上偏移了一段距離,咱們稱其爲renderOffset。其計算公式爲:renderOffset = virtualOffset - (heightSum - 元素3的高度)。從這裏看出咱們須要一個元素包裹住列表元素,以便總體偏移。 再根據第1個問題,咱們知道咱們須要渲染的是3,4,5,6,這裏須要注意的是計算的時候要減去renderOffset

  3. 列表元素咋渲染成我想要的? 對於每個列表元素,咱們調用一個itemElementGenerator函數來建立DOM,它接受對應的列表項做爲參數,返回一個DOM元素。該DOM元素會被做爲列表元素加載到視口元素中。

OK,讓咱們直接敲代碼吧!

1. 構造函數,咱們先肯定咱們須要的參數。

class VirtualScroll {
  constructor({ el, list, itemElementGenerator, itemHeight }) {
    this.$list = el // 視口元素
    this.list = list // 須要展現的列表數據
    this.itemHeight = itemHeight // 每一個列表元素的高度
    this.itemElementGenerator = itemElementGenerator // 列表元素的DOM生成器
  }
}
複製代碼

爲了方便,這裏咱們假設每一個元素的高度都是同樣的。固然也能夠每一個都不同。 接下來咱們須要作一些初始化的操做。

2. 初始化操做

class VirtualScroll {
  constructor({ el, list, itemElementGenerator, itemHeight }) {
    // ...
    this.mapList()
    this.initContainer()
  }
  initContainer() {
    this.containerHeight = this.$list.clientHeight
    this.$list.style.overflow = "hidden"
  }
  mapList() {
    this._list = this.list.map((item, i) => ({
      height: this.itemHeight,
      index: i,
      item: item,
    }))
  }
}
複製代碼

咱們記錄視口元素的高度,而後將傳入的列表數據轉化爲方便咱們計算的數據結構。

3. 監聽事件

class VirtualScroll {
  constructor(/* ... */) {
    // ...
    this.bindEvents()
  }
  bindEvents() {
    let y = 0
    const updateOffset = (e) => {
      e.preventDefault()
      y += e.deltaY
    }
    this.$list.addEventListener("wheel", updateOffset)
  }
}
複製代碼

咱們監聽視口的滾輪事件,該事件對象有一個屬性叫作deltaY,記錄的是滾輪滾動的方向以及滾動量。向下爲正,向上爲負。

4. 渲染列表

class VirtualScroll {
  render(virtualOffset) {
    const headIndex = findIndexOverHeight(this._list, virtualOffset)
    const tailIndex = findIndexOverHeight(this._list, virtualOffset + this.containerHeight)

    this.renderOffset = offset - sumHeight(this._list, 0, headIndex)

    this.renderList = this._list.slice(headIndex, tailIndex + 1)

    const $listWp = document.createElement("div")
    this.renderList.forEach((item) => {
      const $el = this.itemElementGenerator(item)
      $listWp.appendChild($el)
    })
    $listWp.style.transform = `translateY(-${this.renderOffset}px)`
    this.$list.innerHTML = ''
    this.$list.appendChild($listWp)
  }
}
複製代碼
// 找到第一個累加高度大於指定高度的序號
export function findIndexOverHeight(list, offset) {
  let currentHeight = 0
  for (let i = 0; i < list.length; i++) {
    const { height } = list[i]
    currentHeight += height

    if (currentHeight > offset) {
      return i
    }
  }

  return list.length - 1
}

// 獲取列表中某一段的累加高度
export function sumHeight(list, start = 0, end = list.length) {
  let height = 0
  for (let i = start; i < end; i++) {
    height += list[i].height
  }

  return height
}
複製代碼

這裏咱們的渲染方法主要依賴於用戶的總滾動量virtualOffset,每個virtualOffset都對應着一個固定的渲染幀。 咱們先計算出可視的子列表,再計算出偏移量。最後根據該子列表生成DOM,替換掉視口元素中的DOM。

5. 視圖更新

滾動記錄以及渲染方法都已經實現,那麼最後一步就很簡單了,就是在滾動記錄變動時執行渲染方法。

class VirtualScroll {
  constructor(/* ... */) {
    // ...
    this._virtualOffset = 0
    this.virtualOffset = this._virtualOffset
  }
  set virtualOffset(val) {
    this._virtualOffset = val
    this.render(val)
  }
  get virtualOffset() {
    return this._virtualOffset
  }
  initContainer($list) {
    // ...
+   this.contentHeight = sumHeight(this._list)
  }
  bindEvents() {
    let y = 0
+   const scrollSpace = this.contentHeight - this.containerHeight
    const updateOffset = (e) => {
      e.preventDefault()
      y += e.deltaY
+     y = Math.max(y, 0)
+     y = Math.min(y, scrollSpace)
+     this.virtualOffset = y
    }
    this.$list.addEventListener("wheel", updateOffset)
  }
}
複製代碼

OK,到這裏,咱們的虛擬滾動就已經實現了基礎功能。謝謝你們觀看,下一篇再見👋!

性能測試

首先咱們來看看基礎功能究竟有沒有解決大數據量加載問題。

截屏20200608 下午4.36.59.png

咱們再次使用Chrome性能頁面測試了一下。就倆字兒絲滑!從圖中咱們能夠看出渲染耗時從原來的4.5s降到了5ms!!! 接着咱們用Safari打開試試,成功!對比原來,10萬的數據量,Safari但是打不開的啊。

固然了,這只是初始渲染,考慮到咱們的渲染幀作法,在滾動的時候必定有性能問題。

截屏20200608 下午5.25.45.png

咱們持續滾動10秒後測試其滾動性能,發現其腳本執行時間過長,達到了40%。渲染/繪圖耗時也顯著增長。可是有個好處就是,在消耗這麼多資源的狀況下,頁面FPS確實還不錯,在基本50-70之間,使得畫面沒有卡頓現象,十分的流暢。

性能優化

在瞭解了滾動時性能後,我想你也知道問題所在。咱們在每次觸發wheel事件時都會從新渲染整個列表。而且wheel在觸摸板上觸發的頻率是至關的高!

因此咱們來看看怎麼來優化一下這些問題。

  1. 首先事件觸發頻率,咱們須要作一下節流。
  2. 每次滾動都要從新渲染,咱們須要控制一下這個從新渲染的頻率,消耗過高了。

事件節流

簡單點來講就是下降事件觸發致使的函數調用頻率。固然,這裏咱們只對消耗高的函數作節流。

class VirtualScroll {
   bindEvents() {
    let y = 0
    const scrollSpace = this.contentHeight - this.containerHeight
    const recordOffset = (e) => {
      e.preventDefault()
      y += e.deltaY
      y = Math.max(y, 0)
      y = Math.min(y, scrollSpace)
    }
    const updateOffset = () => {
      this.virtualOffset = y
    }
    const _updateOffset = throttle(updateOffset, 16)

    this.$list.addEventListener("wheel", recordOffset)
    this.$list.addEventListener("wheel", _updateOffset)
  }
}
複製代碼

能夠看到咱們將更新virtualOffset的操做剝離了出來,由於它會涉及到render操做。可是記錄偏移量咱們能夠一直觸發。 因此咱們把更新virtualOffset的操做頻率經過節流函數throttle下降了。

當咱們將間隔設爲16ms的時候,再一次進行測試,獲得瞭如下結果:

截屏20200608 下午6.19.27.png

能夠發現腳本執行耗時減小了一半,渲染/重繪時長也相應的減小了。能夠看到效果十分明顯,可是,頁面的FPS降到了30左右,頁面的滾動流暢度就沒有那麼絲滑了。可是也是沒有明顯卡頓的。

列表緩存

就算咱們將事件的觸發頻率減小了,可是保證滾動流暢的狀況下這個渲染間隔仍是太太太過短了。那麼怎麼把渲染間隔變長呢?也就是說在兩次從新渲染之間,不用從新渲染也能知足用戶的滾動需求。

解決方法就是咱們在可視元素列表先後預先多渲染幾個列表元素。這樣咱們在少許滾動時能夠偏移這些已渲染的元素而不是從新渲染,當滾動量超過緩存元素時,再進行從新渲染。

比起從新渲染,修改列表的樣式屬性消耗就小多了。

virtualscroll1.jpg

粉色區域就是咱們的緩存區,在這個區域滾動時咱們只須要改動列表的translateY就行了。注意這裏咱們不用ymargin-top兩個屬性,由於transform擁有更好的動畫體驗。

class VirtualScroll {
  constructor(/* ... */) {
    // ...
    this.cacheCount = 10
    this.renderListWithCache = []
  }
  render(virtualOffset) {
    const headIndex = findIndexOverHeight(this._list, virtualOffset)
    const tailIndex = findIndexOverHeight(this._list, virtualOffset + this.containerHeight)

    let renderOffset
    
    // 當前滾動距離仍在緩存內
    if (withinCache(headIndex, tailIndex, this.renderListWithCache)) {
      // 只改變translateY
      const headIndexWithCache = this.renderListWithCache[0].index
      renderOffset = virtualOffset - sumHeight(this._list, 0, headIndexWithCache)
      this.$listInner.style.transform = `translateY(-${renderOffset}px)`
      return
    }

    // 下面的就和以前作法基本同樣,可是列表增長了先後緩存元素
    const headIndexWithCache = Math.max(headIndex - this.cacheCount, 0)
    const tailIndexWithCache = Math.min(tailIndex + this.cacheCount, this._list.length)

    this.renderListWithCache = this._list.slice(headIndexWithCache, tailIndexWithCache)

    renderOffset = virtualOffset - sumHeight(this._list, 0, headIndex)

    renderDOMList.call(this, renderOffset)

    function renderDOMList(renderOffset) {
      this.$listInner = document.createElement("div")
      this.renderListWithCache.forEach((item) => {
        const $el = this.itemElementGenerator(item)
        this.$listInner.appendChild($el)
      })
      this.$listInner.style.transform = `translateY(-${renderOffset}px)`
      this.$list.innerHTML = ""
      this.$list.appendChild(this.$listInner)
    }

    function withinCache(currentHead, currentTail, renderListWithCache) {
      if (!renderListWithCache.length) return false

      const head = renderListWithCache[0]
      const tail = renderListWithCache[renderListWithCache.length - 1]
      const withinRange = (num, min, max) => num >= min && num <= max

      return withinRange(currentHead, head.index, tail.index) && withinRange(currentTail, head.index, tail.index)
    }
  }
}
複製代碼

咱們設置緩存量大約爲可視元素的兩倍,經測試獲得下圖:

截屏20200608 下午9.22.05.png

腳本執行時長在以前的基礎上又少了近一半,渲染時長也是有相應的下降。

優化結果

咱們從最開始的40%的腳本執行耗時降到了如今的13%。效果仍是蠻顯著的,固然還有更多的優化空間,好比咱們如今採用的是所有列表從新替換掉,其實這中間有不少同樣或者類似的DOM,咱們能夠複用部分DOM,從而減小建立DOM的時間。

進度條

進度條的話,其實就很簡單了。這裏講幾個須要注意的點。

  1. 因爲進度條按照比例來算太小,咱們須要給一個最小高度。
  2. 當拖動進度條時,只須要按照比例更新virtualOffset便可。
  3. 固然,拖動進度條也須要進行事件節流。

思路整理

  1. 監聽滾輪事件/觸摸事件,記錄列表的總偏移量。
  2. 根據總偏移量計算列表的可視元素起始索引。
  3. 從起始索引渲染元素至視口底部。
  4. 當總偏移量更新時,從新渲染可視元素列表。
  5. 爲可視元素列表先後加入緩衝元素。
  6. 在滾動量比較小時,直接修改可視元素列表的偏移量。
  7. 在滾動量比較大時(好比拖動滾動條),會從新渲染整個列表。
  8. 事件節流。

原文 -- 個人小破站(未適配PC端)

相關文章
相關標籤/搜索