虛擬列表,我真的會了!!!

原文鏈接: https://juejin.cn/post/7085941958228574215

作者:Running53

虛擬列表的使用場景

如果我想要在網頁中放大量的列表項,純渲染的話,對於瀏覽器性能將會是個極大的挑戰,會造成滾動卡頓,整體體驗非常不好,主要有以下問題:

1. 傳統做法

對於長列表渲染,傳統的方法是使用懶加載的方式,下拉到底部獲取新的內容加載進來,其實就相當於是在垂直方向上的分頁疊加功能,但隨着加載數據越來越多,瀏覽器的迴流和重繪的開銷將會越來越大,整個滑動也會造成卡頓,這個時候我們就可以考慮使用虛擬列表來解決問題

2. 虛擬列表

其核心思想就是在處理用戶滾動時,只改變列表在可視區域的渲染部分,具體步驟爲:

先計算可見區域起始數據的索引值startIndex和當前可見區域結束數據的索引值endIndex,假如元素的高度是固定的,那麼startIndex的算法很簡單,即startIndex = Math.floor(scrollTop/itemHeight)endIndex = startIndex + (clientHeight/itemHeight) - 1,再根據startIndex 和endIndex取相應範圍的數據,渲染到可視區域,然後再計算startOffset(上滾動空白區域)和endOffset(下滾動空白區域),這兩個偏移量的作用就是來撐開容器元素的內容,從而起到緩衝的作用,使得滾動條保持平滑滾動,並使滾動條處於一個正確的位置

上述的操作可以總結成五步:

定高虛擬列表實現步驟

掘金使用的是傳統懶加載的方式加載的哈,用的並不是虛擬列表,這裏只是想表達一下什麼是定高的列表!

實現的效果應該是:不論怎麼滾動,我們改變的只是滾動條的高度和可視區的元素內容,並沒有增加任何多餘的元素,下面來看看要怎麼實現吧!

// 虛擬列表DOM結構
<div className='container'>
  // 監聽滾動事件的盒子,該高度繼承了父元素的高度
  <div className='scroll-box' ref={containerRef} onScroll={boxScroll}>
    // 該盒子的高度一定會超過父元素,要不實現不了滾動的效果,而且還要動態的改變它的padding值用於控制滾動條的狀態
    <div style={topBlankFill.current}>
      {
      showList.map(item => <div className='item' key={item.commentId || (Math.random() + item.comments)}>{item.content}</div>)
      }
    </div>
  </div>
</div>

計算容器最大容積數量

簡單來說,就是我們必須要知道在可視區域內最多能夠容納多少個列表項,這是我們在截取內容數據渲染到頁面之前關鍵的步驟之一

 // 滾動容器高度改變後執行的函數
const changeHeight = useCallback(throttle(() ={
  // 容器高度,通過操作dom元素獲取高度是因爲它不一定是個定值
  curContainerHeight.current = containerRef.current.offsetHeight
  // 列表最大數量,考慮到列表中頂部和底部可能都會出現沒有展現完的item
  curViewNum.current = Math.ceil(curContainerHeight.current / itemHeight) + 1
}, 500)[])

useEffect(() ={
  // 組件第一次掛載需要初始化容器的高度以及最大容納值
  changeHeight()
  // 因爲我們的可視窗口和瀏覽器大小有關係,所以我們需要監聽瀏覽器大小的變化
  // 當瀏覽器大小改變之後需要重新執行changeHeight函數計算當前可視窗口對應的最大容納量是多少
  window.addEventListener('resize', changeHeight)
  return () ={
    window.removeEventListener('resize', changeHeight)
  }
}[changeHeight])

監聽滾動事件動態截取數據 && 設置上下滾動緩衝消除快速滾動白屏

這是虛擬列表的核心之處,不將所有我們請求到的元素渲染出來,而是隻渲染我們能夠看到的元素,大大減少了容器內的 dom 節點數量。

不過有個隱藏的問題我們需要考慮到,當用戶滑動過快的時候,很多用戶的設備性能並不是很好,很容易出現屏幕已經滾動過去了,但是列表項還沒有及時加載出來的情況,這個時候用戶就會看到短暫的白屏,對用戶的體驗非常不好。所以我們需要設置一段緩衝區域,讓用戶過快的滾動之後還能看到我們提前渲染好的數據,等到緩衝數據滾動完了,我們新的數據也渲染到頁面中去了!

const scrollHandle = () ={
  // 注意這個對應的是可視區第一個元素的索引值,而不是第多少個元素
  let startIndex = Math.floor(containerRef.current.scrollTop / itemHeight) // itemHeight是列表每一項的高度
  // 優化:如果是用戶滾動觸發的,而且兩次startIndex的值都一樣,那麼就沒有必要執行下面的邏輯
  if (!isNeedLoad && lastStartIndex.current === startIndex) return
  isNeedLoad.current = false
  lastStartIndex.current = startIndex
  const containerMaxSize = curViewNum.current
  /**
   * 解決滑動過快出現的白屏問題:注意endIndex要在startIndex人爲改變之前就計算好
   * 因爲我們實際上需要三板的數據用於兼容低性能的設備,用做上下滾動的緩衝區域,避免滑動的時候出現白屏
   * 現在的startIndex是可視區的第一個元素索引,再加上2倍可視區元素量,剛好在下方就會多出一板來當做緩衝區
   */
  // 此處的endIndex是爲了在可視區域的下方多出一板數據
  let endIndex = startIndex + 2 * containerMaxSize - 1
  // 接近滾動到屏幕底部的時候,就可以請求發送數據了,這個時候觸底的並不是可視區的最後一個元素,而是多出那一版的最後一個元素觸底了
  const currLen = dataListRef.current.length
  if (endIndex > currLen - 1) {
    // 更新請求參數,發送請求獲取新的數據(但是要保證當前不在請求過程中,否則就會重複請求相同的數據)
    !isRequestRef.current && setOptions(state =({ offset: state.offset + 1 }))
    // 如果已經滾動到了底部,那麼就設置endIndex爲最後一個元素索引即可
    endIndex = currLen - 1
  }
  // 此處的endIndex是爲了在可視區域的上方多出一板數據
  // 這裏人爲的調整startIndex的值,目的就是爲了能夠在可視區域上方多出一板來當做緩衝區
  if (startIndex <= containerMaxSize) { // containerMaxSize是我們之前計算出來的容器容納量
    startIndex = 0
  } else {
    startIndex = startIndex - containerMaxSize
  }
  // 使用slice方法截取數據,但是要記住第二個參數對應的索引元素不會被刪除,最多隻能刪除到它的前一個,所以我們這裏的endIndex需要加一
  setShowList(dataListRef.current.slice(startIndex, endIndex + 1))
}

動態設置上下空白佔位

這是虛擬列表的靈魂所在,本質上我們數據量是很少的,一般來說只有幾條到十幾條數據,如果不對列表做一些附加的操作,連生成滾動條都有點困難,更別說讓用戶自由操控滾動條滾動了。

我們必須要用某種方法將內容區域撐起來,這樣纔會出現比較合適的滾動條。我這裏採取的方法就是設置paddingToppaddingBottom的值來動態的撐開內容區域。

爲什麼要動態的改變呢?舉個例子,我們向下滑動的時候會更換頁面中要展示的數據列表,如果不改變原先的空白填充區域,那麼隨着滾動條的滾動,原先展示在可視區的第一條數據就會向上移動,雖然我們更新的數據是正確的,但並沒有將它們展示在合適的位置。完美的方案是是不僅要展示正確的數據,而且還要改變空白填充區域高度,使得數據能夠正確的展示在瀏覽器視口當中。

// 以下代碼要放在更新列表數據之前,也是在滾動事件boxScroll當中
// 改變空白填充區域的樣式,否則就會出現可視區域的元素與滾動條不匹配的情況,實現不了平滑滾動的效果
topBlankFill.current = {
  // 起始索引就是緩衝區第一個元素的索引,索引爲多少就代表前面有多少個元素
  paddingTop: `${startIndex * itemHeight}px`,
  // endIndex是緩衝區的最後一個元素,可能不在可視區內;用dataListRef數組最後一個元素的索引與endIndex相減就可以得到還沒有渲染元素的數目
  paddingBottom: `${(dataListRef.current.length - 1 - endIndex) * itemHeight}px`
}

下拉置地自動請求和加載數據

在真實的開發場景中,我們不會一次性請求 1w、10w 條數據過來,這樣請求時間那麼長,用戶早就把頁面關掉了,還優化個屁啊哈哈!

所以真實開發中,我們還是要結合原來的懶加載方式,等到下拉觸底的時候去加載新的數據進來,放置到緩存數據當中,然後我們再根據滾動事件決定具體渲染哪一部分的數據到頁面上去。

// 組件剛掛載以及下拉觸底的時候請求更多數據
useEffect(() ={
  (async () ={
    try {
      // 表明當前正處於請求過程中
      isRequestRef.current = true
      const { offset } = options
      let limit = 20
      if (offset === 1) limit = 40
      const { data: { comments, more } } = await axios({
        url: `http://localhost:3000/comment/music?id=${186015 - offset}&limit=${limit}&offset=1`
      })
      isNeedLoad.current = more
      // 將新請求到的數據添加到存儲列表數據的變量當中
      dataListRef.current = [...dataListRef.current, ...comments]
      // 必選要在boxScroll之前將isRequestRef設爲false,因爲boxScroll函數內部會用到這個變量
      isRequestRef.current = false
      // 請求完最新數據的時候需要重新觸發一下boxScroll函數,因爲容器內的數據、空白填充區域可能需要變化
      boxScroll()
    } catch (err) {
      isRequestRef.current = false
      console.log(err);
    }
  })()
  // 在boxScroll函數里面,一旦發生了觸底操作就會去改變optiosn的值
}[options])

滾動事件請求動畫幀進行節流優化

虛擬列表很依賴於滾動事件,考慮到用戶可能會滑動很快,我們在用節流優化的時候事件必須要設置的夠短,否則還是會出現白屏現象。

這裏我沒有用傳統的節流函數,而是用到了請求動畫幀幫助我們進行節流,這裏我就不做具體介紹了,想了解的可以看我另一篇文章 juejin.cn/post/708236…[1]juejin.cn/post/684490…[2]

// 利用請求動畫幀做了一個節流優化
let then = useRef(0)
const boxScroll = () ={
  const now = Date.now()
  /**
   * 這裏的等待時間不宜設置過長,不然會出現滑動到空白佔位區域的情況
   * 因爲間隔時間過長的話,太久沒有觸發滾動更新事件,下滑就會到padding-bottom的空白區域
   * 電腦屏幕的刷新頻率一般是60HZ,渲染的間隔時間爲16.6ms,我們的時間間隔最好小於兩次渲染間隔16.6*2=33.2ms,一般情況下30ms左右,
   */
  if (now - then.current > 30) {
    then.current = now
    // 重複調用scrollHandle函數,讓瀏覽器在下一次重繪之前執行函數,可以確保不會出現丟幀現象
    window.requestAnimationFrame(scrollHandle)
  }
}

當然,填充空白區域、模擬滾動條還有其它的辦法,比如根據總數據量讓一個盒子撐開父盒子用於生成滾動條,根據startIndex計算出可視區域距離頂部的距離並調節內容區域元素的transform屬性,即startOffset = scrollTop - (scrollTop % this.itemSize),讓內容區域一直暴露在可視區域內

目前爲止,我們已經實現了固定高度的列表項用虛擬列表來展示的功能!接下里我們將會介紹關於不定高(其高度由內容進行撐開)的列表項如何用虛擬列表進行優化

不定高虛擬列表實現步驟

微博是一個很典型的不定高虛擬列表,大家感興趣的話可以去看一下哦!

在之前的實現中,列表項的高度是固定的,因爲高度固定,所以可以很輕易的就能獲取列表項的整體高度以及滾動時的顯示數據與對應的偏移量。而實際應用的時候,當列表中包含文本、圖片之類的可變內容,會導致列表項的高度並不相同。

我們在列表渲染之前,確實沒有辦法知道每一項的高度,但是又必須要渲染出來,那怎麼辦呢?

這裏有一個解決方法,就是先給沒有渲染出來的列表項設置一個預估高度,等到這些數據渲染成真實dom元素了之後,再獲取到他們的真實高度去更新原來設置的預估高度,下面我們來看看跟定高列表有什麼不同,具體要怎麼實現吧!

請求到新數據對數據進行初始化(設置預估高度)

預估高度的設置其實是有技巧的,列表項預估高度設置的越大,展現出來的數據就會越少,所以當預估高度比實際高度大很多的時候,很容易出現可視區域數據量太少而引起的可視區域出現部分空白。爲了避免這種情況,我們的預估高度應該設置爲列表項產生的最小值,這樣儘管可能會多渲染出幾條數據,但能保證首次呈現給用戶的畫面中沒有空白

// 請求更多的數據
useEffect(() ={
  (async () ={
    // 只有當前不在請求狀態的時候纔可以發送新的請求
    if (!isRequestRef.current) {
      console.log('發送請求了');
      try {
        isRequestRef.current = true
        const { offset } = options
        let limit = 20
        if (offset === 1) limit = 40
        const { data: { comments, more } } = await axios({
          url: `http://localhost:3000/comment/music?id=${186015 - offset}&limit=${limit}&offset=1`
        })
        isNeedLoad.current = more
        // 獲取緩存中最後一個數據的索引值,如果沒有,則返回-1
        const lastIndex = dataListRef.current.length ? dataListRef.current[dataListRef.current.length - 1].index : -1
        // 先將請求到的數據添加到緩存數組中去
        dataListRef.current = [...dataListRef.current, ...comments]
        const dataList = dataListRef.current
        // 將剛剛請求到的新數據做一下處理,爲他們添加上對應的索引值、預估高度、以及元素首尾距離容器頂部的距離
        for (let i = lastIndex + 1, len = dataListRef.current.length; i < len; i++) {
          dataList[i].index = i
          // 預估高度是列表項對應的最小高度
          dataList[i].height = 63
          // 每一個列表項頭部距離容器頂部的距離等於上一個元素尾部距離容器頂部的距離
          dataList[i].top = dataList[i - 1]?.bottom || 0
          // 每一個列表項尾部距離容器頂部的距離等於上一個元素頭部距離容器頂部的距離加上自身列表項的高度
          dataList[i].bottom = dataList[i].top + dataList[i].height
        }
        isRequestRef.current = false
        boxScroll()
      } catch (err) {
        console.log(err);
      } finally {
        isRequestRef.current = false
      }
    }
  })()
  // eslint-disable-next-line
}[options])

每次列表更新之後將列表項真實高度更新緩存中的預估高度

React函數式組件中,useEffect只要不傳第二個參數,就可以實現類組件componentDidUpdate生命週期函數的作用,只要我們重新渲染一次列表組件,就會重新計算一下當前列表每一項中的真實高度並更新到緩存中去,當下次我們再用到緩存中的這些數據時,使用的就是真實高度了

// 每次組件重新渲染即用戶滾動更改了數據之後需要將列表中我們還不知道的列表項高度更新到我們的緩存數據中去,以便下一次更新的時候能夠正常渲染
useEffect(() ={ 
  const doms = containerRef.current.children[0].children
  const len = doms.length
  // 因爲一開始我們沒有請求數據,所以即使組件渲染完了,但是沒有列表項,此時不需要執行後續操作
  if (len) {
    // 遍歷所有的列表結點,根據結點的真實高度去更改緩存中的高度
    for (let i = 0; i < len; i++) {
      const realHeight = doms[i].offsetHeight
      const originHeight = showList[i].height
      const dValue = realHeight - originHeight
      // 如果列表項的真實高度就是緩存中的高度,則不需要進行更新
      if (dValue) {
        const index = showList[i].index
        const allData = dataListRef.current
        /**
           * 如果列表項的真實高度不是緩存中的高度,那麼不僅要更新緩存中這一項的bottom和height屬性
           * 在該列表項後續的所有列表項的top、bottom都會受到它的影響,所以我們又需要一層for循環進行更改緩存中後續的值
           */
        allData[index].bottom += dValue
        allData[index].height = realHeight
        /**
           * 注意:這裏更改的一定要是緩存數組中對應位置後續的所有值,如果只改變的是showList值的話
           * 會造成dataList間斷性的bottom和下一個top不連續,因爲startIndex、endIndex以及空白填充區域都是依據top和bottom值來進行計算的
           * 所以會導致最後計算的結果出錯,滑動得來的startIndex變化幅度大且滾動條不穩定,出現明顯抖動問題
           */
        for (let j = index + 1, len = allData.length; j < len; j++) {
          allData[j].top = allData[j - 1].bottom
          allData[j].bottom += dValue
        }
      }
    }
  }
  // eslint-disable-next-line
})

得到可視區域的起始和結束元素索引 && 設置上下滾動緩衝區域消除快速滾動白屏

列表項的bottom屬性代表的就是該元素尾部到容器頂部的距離,不難發現,可視區的第一個元素的bottom是第一個大於滾動高度的;可視區最後一個元素的bottom是第一個大於(滾動高度 + 可視高度)的。我們可以利用這條規則遍歷緩存數組找到對應的startIndexendIndex

由於我們的緩存數據,本身就是有順序的,所以獲取開始索引的方法可以考慮通過二分查找的方式來降低檢索次數,減少時間複雜度

// 得到要渲染數據的起始索引和結束索引
const getIndex = () ={
  // 設置緩衝區域的數據量
  const aboveCount = 5
  const belowCount = 5
  // 結果數組,裏面包含了起始索引和結束索引
  const resObj = {
    startIndex: 0,
    endIndex: 0,
  }
  const scrollTop = containerRef.current.scrollTop
  const dataList = dataListRef.current
  const len = dataList.length
  // 設置上層緩衝區,如果索引值大於緩衝區域,那麼就需要減小startIndex的值用於設置頂層緩衝區
  const startIndex = binarySearch(scrollTop)
  if (startIndex <= aboveCount) {
    resObj.startIndex = 0
  } else {
    resObj.startIndex = startIndex - aboveCount
  }
  /**
     * 緩衝數據中第一個bottom大於滾動高度加上可視區域高度的元素就是可視區域最後一個元素
     * 如果沒有找到的話就說明當前滾動的幅度過大,緩存中沒有數據的bottom大於我們的目標值,所以搜索不到對應的索引,我們只能拿緩存數據中的最後一個元素補充上
     */
  const endIndex = binarySearch(scrollTop + curContainerHeight.current) || len - 1
  // 增大endIndex的索引值用於爲滾動區域下方設置一段緩衝區,避免快速滾動所導致的白屏問題
  resObj.endIndex = endIndex + belowCount
  return resObj
}

// 由於我們的緩存數據,本身就是有順序的,所以獲取開始索引的方法可以考慮通過二分查找的方式來降低檢索次數:
const binarySearch = (value) ={
  const list = dataListRef.current
  let start = 0;
  let end = list.length - 1;
  let tempIndex = null;
  while (start <= end) {
    let midIndex = parseInt((start + end) / 2);
    let midValue = list[midIndex].bottom;
    if (midValue === value) {
      // 說明當前滾動區域加上可視區域剛好是一個結點的邊界,那麼我們可以以其下一個結點作爲末尾元素
      return midIndex + 1;
    } else if (midValue < value) {
      // 由於當前值與目標值還有一定的差距,所以我們需要增加start值以讓下次中點的落點更靠後
      start = midIndex + 1;
    } else if (midValue > value) {
      // 因爲我們的目的並不是找到第一個滿足條件的值,而是要找到滿足條件的最小索引值
      if (tempIndex === null || tempIndex > midIndex) {
        tempIndex = midIndex;
      }
      // 由於我們要繼續找更小的索引,所以需要讓end-1以縮小範圍,讓下次中點的落點更靠前
      end--
    }
  }
  return tempIndex;
}

監聽滾動事件動態截取數據 && 動態設置上下空白佔位

動態截取數據的操作和定高的虛擬列表幾乎一樣,區別比較大的地方就在padding值的計算方式上。在定高的列表中,我們可以根據起始索引值和結尾索引值直接計算出空白填充區域的高度。

其實在不定高的列表中,計算方式更加簡單,因爲startIndex對應元素的top值就是我們需要填充的上空白區域,下空白區域也可以根據整個列表的高度(最後一個元素的 bottom 值)和endIndex對應元素的bottom值之差得出

const scrollHandle = () ={
  // 獲取當前要渲染元素的起始索引和結束索引值
  let { startIndex, endIndex } = getIndex()
  /**
     * 如果是用戶滾動觸發的,而且兩次startIndex的值都一樣,那麼就沒有必要執行下面的邏輯,
     * 除非是用戶重新請求了之後需要默認執行一次該函數,這是一種特殊情況,就是startIndex沒變,但需要執行後續的操作
     */
  if (!isNeedLoad && lastStartIndex.current === startIndex) return
  // 渲染完一次之後就需要初始化isNeedLoad
  isNeedLoad.current = false
  // 用於實時監控lastStartIndex的值
  lastStartIndex.current = startIndex
  // 下層緩衝區域最後的元素接觸到屏幕底部的時候,就可以請求發送數據了
  const currLen = dataListRef.current.length
  if (endIndex >= currLen - 1) {
    // 當前不在請求狀態下時纔可以改變請求參數發送獲取更多數據的請求
    !isRequestRef.current && setOptions(state =({ offset: state.offset + 1 }))
    // 注意endIndex不可以大於緩存中最後一個元素的索引值
    endIndex = currLen - 1
  }
  // 空白填充區域的樣式
  topBlankFill.current = {
    // 改變空白填充區域的樣式,起始元素的top值就代表起始元素距頂部的距離,可以用來充當paddingTop值
    paddingTop: `${dataListRef.current[startIndex].top}px`,
    // 緩存中最後一個元素的bottom值與endIndex對應元素的bottom值的差值可以用來充當paddingBottom的值
    paddingBottom: `${dataListRef.current[dataListRef.current.length - 1].bottom - dataListRef.current[endIndex].bottom}px`
  }
  setShowList(dataListRef.current.slice(startIndex, endIndex + 1))
}

問題思考

我們雖然實現了根據列表項動態高度下的虛擬列表,但如果列表項中包含圖片,並且列表高度由圖片撐開。在這種場景下,由於圖片會發送網絡請求,列表項可能已經渲染到頁面中了,但是圖片還沒有加載出來,此時無法保證我們在獲取列表項真實高度時圖片是否已經加載完成,獲取到的高度有無包含圖片高度,從而造成計算不準確的情況。

但是這種任意由圖片來撐開盒子大小的場景很少見,因爲這樣會顯得整個列表很不規則。大多數展示圖片的列表場景,其實都是提前確定要展示圖片的尺寸的,比如微博,1 張圖片的尺寸是多少,2x2,3x3 的尺寸是多少都是提前設計好的,只要我們給 img 標籤加了固定高度,這樣就算圖片還沒有加載出來,但是我們也能夠準確的知道列表項的高度是多少。

如果你真的遇到了這種列表項會由圖片任意撐開的場景,可以給圖片綁定onload事件,等到它加載完之後再重新計算一下列表的高度,然後把它更新到緩存數據中,這是一種方法。其次,還可以使用 ResizeObserver[3] 來監聽列表項內容區域的高度改變,從而實時獲取每一列表項的高度,只不過 MDN 有說道這只是在實驗中的一個功能,所以暫時可能沒有辦法兼容所有的瀏覽器!

參考資料

[1]

https://juejin.cn/post/7082366494348148744: https://juejin.cn/post/7082366494348148744

[2]

https://juejin.cn/post/6844903982742110216#heading-3: https://juejin.cn/post/6844903982742110216#heading-3

[3]

https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FResizeObserver: https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WaB-oNaqXnOC3M5Z5iOvNA