瀑布流使用虛擬列表性能優化

瀑布流算是比較常見的布局了,一個般常見縱向瀑布流的交互,當我們滾動到底的時候加載下一頁的數據追加到上去。因為一次加載的數據量不是很多,頁面操作是也不會有太大的性能消耗。但是如果當你一直往下滾動加載,加載幾十頁的時候,就會開始感覺不那麼流暢的,這是因為雖然每次操作的很少,但是頁面的 DOM 越來越多,內存佔用也會增大,而且發生重排重繪時候瀏覽器計算量耗時也會增大,就導致了慢慢不能那麼流暢了。這個時候可以選擇結合虛擬列表方式使用,虛擬列表本身就是用來解決超長列表時的處理方案。

瀑布流

瀑布流的實現方式有很多種,大體分為:

  • CSS: CSS 實現的有 multi-column、grid ,CSS 實現存在一定局限性,例如無法調整順序,當元素高度差異較大時候不是很好處理各列間隔差等。
  • JavaScript:JavaScript 實現的有 JavaScript + flex、JavaScript + position,JavaScript 實現兼容性較好,可控制性高。

因為我的瀑布流是可提前計算元素寬高,列數是動態的,所以採用了 JavaScript + position 來配合 虛擬列表 進行優化。

js + flex 實現

如果你的瀑布流 列是固定,列寬不固定 的,使用 flex 是個很好選擇,當你的容器寬度變話時候,每一列寬度會自適應,大致實現方式

將你的數據分為對應列數

let data1 = [], //第一列
    data2 = [], //第二列
    data3 = [], //第三列
    i = 0;

while (i < data.length) {
    data1.push(data[i++]);
    if (i < data.length) {
        data2.push(data[i++]);
    }
    if (i < data.length) {
        data3.push(data[i++]);
    }
}

然後將你的每列數據插入進去就可以了,設置 list 為 flex 容器,並設置主軸方向為 row

<div class="list">
    <!-- 第一列 -->
    <div class="column">
        <div class="item"></div>
        <!-- more items-->
    </div>
    <!-- 第二列 -->
    <div class="column">
        <div class="item"></div>
        <!-- more items-->
    </div>
    <!-- 第三列 -->
    <div class="column">
        <div class="item"></div>
        <!-- more items-->
    </div>
</div>

js + position 實現

這種方式比較適合 列定寬,列數量不固定情況,而且最好能計算出每個元素的大小。

大致 HTML 結構如下:

<ul class="list">
    <li class="list-item"></li>
    <!-- more items-->
</ui>
<style>
    .list {
        position: relative;
    }

    .list-item {
        position: absolute;
        top: 0;
        left: 0;
    }
</style>

JavaScript 部分,首先需要獲取 list 寬度,根據 list.width/列寬 計算出列的數量,然後根據列數量去分組數據和計算位置

// 以列寬為300 間隔為20 為例

let catchColumn = (Math.max(parseInt((dom.clientWidth + 20) / (300 + 20)), 1))

const toTwoDimensionalArray = (count) => {
    let list = []
    for (let index = 0; index < count; index++) {
        list.push([])
    }
    return list;
}

const minValIndex = (arr = []) => {
    let val = Math.min(...arr);
    return arr.findIndex(i => i === val)
}

// 緩存累計高度
let sumHeight = toTwoDimensionalArray(catchColumn)

data.forEach(item => {
    // 獲取累計高度最小那列
    const minIndex = minValIndex(sumHeight)

    let width = 0 // 這裡寬高更具需求計算出來
    let height = 0

	item._top = minIndex * (300 + 20) // 緩存位置信息,後面會用到
    item.style = {
        width: width + 'px',
        height: height + 'px',
        // 計算偏移位置
        transform: `translate(${minIndex * (300 + 20)}px, ${sumHeight[minIndex]}px)`
    }

    sumHeight[minIndex] = sumHeight[minIndex] + height + 20 
})

動態列數

可以使用 ResizeObserver(現代瀏覽器兼容比較好了) 監聽容器元素大小變化,當寬度變化時重新計算列數量,當列數量發生變化時重新計算每項的位置信息。

const observer = debounce((e) => {
    const column = updateVisibleContainerInfo(visibleContainer)
    if (column !== catchColumn) {
        catchColumn = column
        // 重新計算
        this.resetLayout()
    }
}, 300)

const resizeObserver = new ResizeObserver(e => observer(e));

// 開始監聽
resizeObserver.observe(dom);

過渡動畫

當列數量發生變化時候,元素項的位置很多都會發生變化,如下圖,第 4 項的位置從第 3 列變到了第 4 項,如果不做處理會顯得比較僵硬。

1 2 3 4 4

好在我們使用了 transform(也是為什麼使用 top、left 原因,transform 動畫性能更高) 進行位置偏移,可以直接使用 transition 過渡。

.list-item {
    position: absolute;
    top: 0;
    left: 0;
    transition: transform .5s ease-in-out;
}

使用虛擬列表

瀑布流存在的問題

很多虛擬列表的都是使用的單列定高使用方式,但是瀑布流使用虛擬列表方式有點不同,瀑布流存在多列且時是錯位的。所以常規 length*height 為列表總高度,根據 scrollTop/height 來確定下標方式就行不通了,這個時候高度需要根據瀑布流高度動態決定了,可顯示元素也不能通過 starindex-endindex 去截取顯示了。

如下圖:藍色框的元素是不應該顯示的,只有與可視區域存在交叉的元素才應該顯示

1 2 3 4 5 6 8 9 10 7 12 11 13 15 14

可視元素判定

先來看下面圖,當元素完全不在可視區域時候就視為當前元素不需要顯示,只有與可視區域存在交叉或被包含時候視為需要顯示。

1 2 3 4 5 6 8 9 10 7 12 11 13 14

因為上面瀑布流的實現採用的是 position 定位的,所以我們完全能知道所有元素距離頂部的距離,很容易計算出與可視區域交叉位置。

元素偏移位置 < 滾動高度+可視區域高度 && 元素偏移位置 + 元素高度 > 滾動高度

如果只渲染可視區域範圍,滾動時候會存在白屏再出現,可視適當的擴大渲染區域,例如把上一屏和下一屏都算進來,進行預先渲染。

const top = scrollTop - clientHeight
const bottom = scrollTop + clientHeight * 2
const visibleList = data.filter(item => item._top + item.height > top && item._top < bottom)

然後通過監聽滾動事件,根據滾動位置去處理篩選數。這裡會存在一個隱藏性能問題,當滾動加載數據比較多的時候,滾動事件觸發也是比較快的,每一次都進行一次遍歷,也是比較消耗性能的。可以適當控制一下事件觸發頻率,當然這也只是治標不治本,歸根倒是查詢顯示元素方法問題。

標記下標
應為列表數據的 _top 值是從小到大正序的,所以我們可以標記在可視區元素的下標,當發生滾動的時候,我們直接從標記下標開始查找,根據滾動分幾種情況來判斷。
1> 如果滾動後,標記下標元素還在可視範圍內,可以直接從標記下標二分查找,往上往下找直到不符合條件就停止。
2> 如果滾動後,標記下標元素不在可視範圍內,根據滾動方向往上或者往下去查找,然後更新下標值。這個時候存在一種情況,就是當用戶拖動滾動條滾動幅度特別大的時候,可以將下標往上或者往下偏移,偏移量根據 滾動高度/預估平均高度*列數 去估算一個,然後在跟新這個預估下標進行查找。

抖動問題

我們 absolute 定位會撐開容器高度,但是滾動時候還是會存在抖動問題,我們可以自定義一個元素高度去撐開,這個元素高度也就是我們之前計算的每一列累計高度 sumHeight 中最大的那個了。

過渡動畫問題

當列寬發生變化時,元素位置發生了變化,在可視區域的元素也發生了變化,有些元素可能之前並沒有渲染,所以使用上面 CSS 會存在新出現元素不會產生過渡動畫。好在我們能夠很清楚的知道元素原位置信息和新的位置信息,我們可以利用 FLIP 來處理這動畫,很容易控制元素過渡變化,如果有些元素之前不存在,就沒有原位置信息,我們可以在可視範圍內給他隨機生成一個位置進行過渡,保證每一個元素都有個過渡效果避免僵硬。

總結

上面情況僅僅是針對動態列數量,又能計算出高度情況下優化,可能業務中也是可能存在每項高度是動態的,這個時候可以採用預估元素高度在渲染後緩存大小位置等信息,或者離屏渲染等方案解決做出進一步的優化處理。