效能優化小冊 - 渲染十萬條資料:基於 IntersectionObserver 的虛擬列表

破曉L發表於2020-06-17
技術不侷限於框架,相同的原理只是實現方式略有不同。

前置

1. 什麼是虛擬列表?

首先,虛擬列表只是一個概念,本人對虛擬列表這個表述不置可否。

虛擬列表是對於列表形態資料展示的一種按需渲染,是對長列表渲染的一種優化。

虛擬列表不會一次性完整地渲染長列表,而是按需顯示的一種方案,以提高無限滾動的效能。

2. 虛擬列表的實現原理?

根據容器元素的高度 clientHeight 以及列表項元素的高度 offsetHeight 來顯示長列表資料中的某一個部分,而不是去完整地渲染整個長列表。

實現一個虛擬列表需要:

  • 得知容器元素的高度 clientHeight
  • 得知列表項元素的高度 offsetHeight
  • 計算可視區域應該渲染的列表項的個數 count = clientHeight / offsetHeight
  • 計算可視區域資料渲染的起始位置 start
  • 計算可視區域資料渲染的結束位置 end
  • 對完整長列表資料進行截斷 sliceList = dataList.slice(start, end)
  • 渲染截斷後的列表資料,進而實現無限載入

3. 虛擬列表與懶載入有何不同?

懶載入與虛擬列表其實都是延時載入的一種實現,原理相同但場景略有不同。

  • 懶載入的應用場景偏向於網路資源請求,解決網路資源請求過多時,造成的網站響應時間過長的問題。
  • 虛擬列表是對長列表渲染的一種優化,解決大量資料渲染時,造成的渲染效能瓶頸的問題。

4. IntersectionObserver 介紹

IntersectionObserver 提供了一種非同步觀察目標元素與視口的交叉狀態,簡單地說就是能監聽到某個元素是否會被我們看到,當我們看到這個元素時,可以執行一些回撥函式來處理某些事務。

let io = new IntersectionObserver(callback, option);

callback 會觸發兩次。一次是目標元素剛剛進入視口(開始可見),另一次是完全離開視口(開始不可見)。

更多介紹 Intersection Observer

實現

1. 模擬十萬條資料:

function getDataList() {
  let data = []
  for(let i = 0; i < 100000; i++) {
    data.push({id: "item" + i, value: Math.random() * i})
  }
  return data;
}

2. Dom 建立及列表渲染:

不依賴框架的情況下,需要命令性的去建立 DOM 以及操作 DOM

<ul class="container">
  <span class="sentinels">....</span>
</ul>
function $(selector) {
  return document.querySelector(selector)
}

function loadData(start, end) {
  // 擷取資料
  let sliceData = getDataList().slice(start, end)
  // 現代瀏覽器下,createDocumentFragment 和 createElement 的區別其實沒有那麼大
  let fragment = document.createDocumentFragment(); 
  for(let i = 0; i < sliceData.length; i++) {
    let li = document.createElement('li');
    li.innerText = JSON.stringify(sliceData[i])
    fragment.appendChild(li);
  }
  $('.container').insertBefore(fragment, $('.sentinels'));
}

如果是基於 Virtual DOM 的框架,直接運算元據即可(虛擬碼):

// 父元件
<virtual-list :listData="listData"></virtual-list>

// 子元件
<ul class='container'>
  <li
    v-for="item in sliceData" 
    :key="item.id"
  >{{ item }}</li>
</ul>
...

// js
this.sliceData = this.data.slice(start, index)

3. 使用 IntersectionObserver API 建立監聽器:

let count = Math.ceil(document.body.clientHeight / 120);
let startIndex = 0;
let endIndex = 0;
...
let io = new IntersectionObserver(function(entries) {
    loadData(startIndex, count)
    // 標誌位元素進入視口
    if(entries[0].isIntersecting) {
      // 更新列表資料起始和結束位置
      startIndex = startIndex += count;
      endIndex = startIndex + count;
      if(endIndex >= getDataList().length) {
        // 資料載入完取消觀察
        io.unobserve(entries[0].target)
      }
      // requestAnimationFrame 由系統決定回撥函式的執行時機
      requestAnimationFrame(() => {
        loadData(startIndex, endIndex)
        let num = Number(getDataList().length - startIndex)
        let info = ['還有', num , '條資料']
        $('.top').innerText = info.join(' ')
        if(num - count <= 0) {
           $('.top').classList.add('out')
         }
      })
    }
  });
  // 開始觀察“標誌位”元素
  io.observe($('.sentinels'));
})

由於 IntersectionObserver 無法監聽動態建立的 dom,所以我們設定一個「標誌位」元素 span.sentinels 作為監聽的目標物件。

<ul class="container">
  <span class="sentinels">....</span>
</ul>

如果目標元素正處於交叉狀態 entries[0].isIntersecting == true,則代表 .sentinels 進入了可視區域,從而載入新的列表資料。

if(entries[0].isIntersecting) {
  ...
  requestAnimationFrame(() => {
    loadData(startIndex, endIndex)
  })
  ...
}

最後將新的列表 insertBefore 到其前面,進而實現無限載入。

$('.container').insertBefore(fragment, $('.sentinels'));

同系列文章:

相關文章