虛擬滾動的輪子是如何造成的?

yuanfux發表於2018-04-25

相信大家都遇到過渲染一個很長的列表或者頁面帶來的痛苦,長列表與頁面可能對首屏渲染速度造成很大的影響,並且會對頁面的滾動造成一些不流暢的體驗。

我也在最近遇到了這個問題,發現除了直接使用分頁外,虛擬滾動這種解決方案很是流行,於是也重新造了一下vue中虛擬滾動的輪子。虛擬滾動簡單的說就是渲染在瀏覽器中當前可見的範圍內的內容,通過使用者滑動滾動條的位置動態地來計算顯示內容,其餘部分用空白填充來給使用者造成一個長列表的假象。

這個輪子其實並沒有大家想象中的複雜,下面就具體介紹一下這個輪子是怎麼造的,如果大家在工作中學習中遇到同樣的場景也可以適用這種解決方案。以下便是我實現的虛擬滾動的一個簡單demo,線上demo原始碼

虛擬滾動的輪子是如何造成的?

Dom結構

虛擬滾動的核心dom結構其實就是一個簡單的列表,在vue中可被描述為如下的程式碼

<div style="overflow-y: scroll; height: 300px;" @scroll="handleScroll">
    <div v-for="item in items" :key="`${item.id}`">
        <slot :data="item">
        </slot>
    </div>
</div>
複製程式碼

這裡用了vue的scoped slot來處理使用者的自定義dom內容與自定義dom內容的傳入資料,如果沒有scoped slot,我們也可以通過讓使用者在傳入資料時,在傳入的資料物件中定義一個特定的渲染函式來實現這一步驟。

有了這個可自定義dom的列表結構後,在外面套一層可滾動的定高的容器,我們就實現了一個所有列表類元件的基礎dom。那麼接下來要做的就是填充可視列表以外的滾動高度。這個做法有挺多的,比如在列表上下定義<div>,通過改動<div>高度來控制總高度;比如通過控制列表的padding-toppadding-bottom來控制;再比如直接將列表高度設定成所有元素高度總和,通過定義position啟用top來進行定位也是可以的......那麼有了這個dom結構後我們就可以來對顯示內容進行計算了。

監聽滾動事件更新列表內容(handleScroll方法)

1. 計算可視的列表範圍

首先我們可以確定的是列表的可視範圍是一段連續的陣列內容,於是這個計算就被簡化為了找到連續陣列內容的開始點與結束點。開始點與結束點依賴的兩個資訊:一是列表每一項的具體y座標,二就是當前可視範圍的開始點與結束點。列表每一項的y座標可以用一次迴圈通過累加每項的高度來得到每項的y座標,如下圖

虛擬滾動的輪子是如何造成的?

當前可視範圍的開始點s即是列表容器的scrollTop屬性,而結束點e就是s加上列表容器的高度。現在我們有了計算陣列開始點與結束點的所有資訊了,陣列的開始點計算就是在所有項的y座標中尋找到一個不大於可視範圍開始點s的項,陣列的結束點計算就是在所有項的y座標中尋找一個不小於e的項,如下圖

虛擬滾動的輪子是如何造成的?

當陣列每一項都為非固定高度的時候,我們採用二分法(具體實現可參看原始碼)來尋找陣列的上界與下界;當陣列每一項為固定高度的時候,我們可以直接用s除以每項高度向下取整(floor)來得到上界,用e除以每項高度向上取整(ceil)來得到下界。然後用slice方法獲得最終需要展示的元素陣列。

可能大家注意到了,雖然我們用了二分法O(logN)與直接計算O(1)代替了普通的遍歷來尋找上下界,但是slice方法還是會將整體的複雜度提升到O(N),所以這個優化也僅僅對在有限陣列的情況起到一定的提速作用。那麼在實際應用中我們會不會遇到一個相當龐大的陣列,大到能夠忽視O(logN)O(1)帶來的提升呢?答案是否定的,因為瀏覽器對頁面的記憶體限制我們很難在實際應用中遇上這樣一個陣列。

2. 計算上下填充高度

有了陣列的上界與下界,上下填充高度的計算其實非常直觀,我們以設定列表的padding-toppadding-bottom屬性為例,如圖

虛擬滾動的輪子是如何造成的?

頁模式

很多時候我們需要優化的不是一個長列表,而是一個長頁面,那麼對於上述的計算方法有什麼改變呢?

首先我們需要改變可視範圍開始點s與結束點e的計算方法,對於頁面而言可視範圍的開始點即是window.pageYOffset || document.documentElement.scrollTop;結束點是開始點加上可視範圍的高度,這裡的高度計算我們使用window.innerHeight || document.documentElement.clientHeight,但請注意這兩個屬性在頁面有滾動條的時候返回的值是不同的,innerHeight會包含滾動條的高度,clientHeight不包含滾動條的高度。

計算完了可視範圍,我們還需要調整陣列y座標。原先的陣列y座標都是相對於滾動容器而言的,現在我們需要將陣列的y座標調整為相對於頁面。調整方法有兩種:一是可以在計算y座標的時,加上滾動容器的offsetTop屬性;二是可以在計算可視範圍開始點s與結束點e時,減去滾動容器的offsetTop屬性。

調整完了座標,我們還需要將滾動容器的heightoverflow-y屬性去掉,讓容器自由生長,同時將滾動容器的scroll事件轉移到window物件上,這樣就實現了對頁的虛擬滾動。通過頁模式,我們就可以實現對任何通過固定高度塊佈局的長頁面進行此類的優化。

更多可以優化的地方

1. 滾動顯示優化

當滾動重新整理資料過於頻繁的時候,渲染就會就會產生閃爍,這時我們就需要通過requestAnimationFrame來呼叫更新列表的方法來實現對更新列表速率的控制,從而生成平滑的滾動動畫。

2. 列表快取

vue在這裡幫我們處理了一部分列表更新的問題,比如在滾動造成的小範圍陣列變動中,vue是會複用先前渲染的節點來進行列表更新的。如果你沒有使用類似的框架,那麼就需要自己去處理一下這部分的複用邏輯。

除此之外,我們可以對在一定範圍內的渲染內容直接進行快取,例如我們可以限定快取節點數量,在滾動時遇到快取命中時直接使用快取中的節點,如果無命中並且快取節點已滿的情況下則可用一定的快取替換策略,例如用新節點來替換最不頻繁使用(LFU)的快取節點。通過這樣的列表快取來實現對小範圍滾動的再次優化。

3. 列表回收

我們當前的做法依然是在滾動時對dom進行不停地銷燬與再建立,雖然每次建立與銷燬dom的開銷並不大,但是它們依舊會佔用瀏覽器的一部分效能。

當列表內的每一個元素都是通過統一的dom模版或渲染函式進行渲染時,我們就可以通過列表回收的方式,將超出可視範圍的dom節點回收,再將新的資料注入到回收的dom節點中,最後將更新資料後的回收節點放回列表中去,如下圖。通過列表回收的方式可以保證你的dom節點總量在一個極低的範圍內,並且省去了建立銷燬dom這一部分的開銷。

虛擬滾動的輪子是如何造成的?

最後感謝你的閱讀,如果大家有什麼意見,建議或者前端相關的問題都歡迎與我交流,這是我的github,have a nice day! :)

相關連結

考慮了x軸滾動的vue虛擬滾動元件

加入了列表快取與列表回收的vue虛擬滾動元件

相關文章