原文地址:developers.google.com/web/updates…
原文作者:Surma
譯者:王芃
摘要: 重用你的DOM元素以及刪除那些遠離可視範圍的元素。為延遲顯示的元素使用佔位符。這裡是一個無盡滾動的演示和程式碼。
無盡滾動在網際網路上到處都有應用。Google Music的藝術家列表是一個,Facebook的時間線是一個,Tweeter的話題列表也是一個。當你向下滾動,新的內容就神奇的“無中生有”了。這是一個得到廣泛讚揚的、非常好的使用者體驗。
在這個無盡滾動背後的技術挑戰其實比它看上去要難。當你想做正確的事時,你遇到的問題是巨大的。開始時是一些比較簡單的事情,比如在頁面尾部的連結是無法點選的,因為內容不斷的把它們“擠”走。但是問題逐漸開始變得越來越難:當使用者將手機從豎屏改為橫屏時你該如何處理 resize
事件?或者當列表過長時你如何避免手機的卡頓?
正確的事
我們認為有充分的理由來實現一個參考設計:在保證效能的基礎上,以一個可複用的方式來解決這些問題。
我們將會使用3種技術來達成目標:DOM回收、墓碑和滾動錨定。
我們的demo會是一個類似聊天的視窗,我們可以滾動這些訊息列表。首先需要的是一個無盡的訊息資料來源。從技術角度看,沒有任何一個無盡列表是真正無盡的,但當有足夠的資料量填充進去時,它們看上去感覺是無盡的。為簡化問題,我們這裡硬編碼了一套訊息資料,隨機的抽取訊息、聯絡人和圖片。為了更像網路的真實情況,我們人為加入了一些延遲。
DOM 回收
DOM回收是一個未被廣泛使用的技術,它的用途是讓DOM的節點數保持在較低的數值。概括來說,它的機制是利用那些離開檢視區域的、已經建立的DOM元素,而不是新建DOM元素。需要承認的一點是DOM節點本身並非耗能大戶,但是也不是一點都不消耗效能,每一個節點都會增加一些額外的記憶體、佈局、樣式和繪製。如果一個站點的DOM節點過多,在低端裝置上會發現明顯的變慢,如果沒有徹底卡死的話。同樣需要注意的一點是,在一個較大的DOM中每一次重新佈局或重新應用樣式(在節點上增加或刪除樣式所觸發的過程)的系統開銷都會比較昂貴。所以進行DOM回收意味著我們會保持DOM節點在一個比較低的數量上,進而加快上面提到的這些處理過程。
第一個障礙是滾動本身。由於我們在任何時刻DOM中只有全部列表專案的一個微小子集,我們需要找到一種方式可以讓瀏覽器正確的反映出理論上應該在“那裡”的全部列表專案數量。我們這裡用一個 1px * 1px
的”前哨“元素(sentinel
),並且應用一個變換使得包含“逃兵”列表專案的元素(下圖中的 runway
)保持一個理想的高度。我們會把runaway
中的每一個元素提升到它們自己的層,保持 runaway
本身是完全空的,沒有背景色,神馬都木有。如果 runaway
層不是空的話,是不利於瀏覽器優化的。因為我們將不得不在顯示卡上儲存一個由成千上萬的畫素組成的紋理。這樣做顯然在移動裝置上是不可行的。
當我們進行滾動時,我們會檢查是否viewport是否已經足夠接近 runaway
的尾部。如果是的話,我們會通過把 sentinel
和viewport中的剩餘元素移向 runaway
的底部來擴充套件 runaway
,然後用新內容渲染這些元素。
向反方向滾動時也類似,但我們無論如何也不會縮小 runaway
,原因是我們需要滾動欄的位置保持連續性。
墓碑(Tombstones)
如之前我們所說,我們會盡量讓資料來源表現的像現實世界遇到的情況:有網路延遲及其它情況。這就意味著如果我們的使用者飛快地滾動,他們會很容易就把我們渲染的有資料的專案都甩在身後。如果這種情況發生時,我們就需要放置一個墓碑條目(佔位)在對應位置,等到資料取到後墓碑條目會被實際內容替代。墓碑也會被回收,對於墓碑元素會有一個獨立的可複用DOM元素的池。這樣設計的原因是,我們希望墓碑元素在被實際資料替代時可以有一個漂亮的過渡,而不是出現那種生硬的或者讓人迷失的效果。
這裡有一個有趣的挑戰,那就是真實的條目的高度可能會超過墓碑的高度,因為不同的文字量或者圖片的大小決定了這點。為了解決這個問題,每次當取到資料後我們會調整當前的滾動位置,而且在viewport之上的一個墓碑條目也會被替換。將滾動位置錨定到某一條目而非某一具體的畫素位置,這個概念叫做滾動錨定。
滾動錨定
滾動錨定的觸發時機有兩個:一個是墓碑被替換時,另一個是視窗大小發生改變時(在裝置發生翻轉時也會發生)。我們必須要知道在viewport中的最頂部可見元素是什麼。由於這個元素可能只是部分可見的,所以我們也需要儲存從頂部元素到viewport頂部的偏移量。
這樣的話,當viewport改變大小時、runaway
改變時,我們是可以把場景恢復到一個看起來和原來幾乎一致的樣子。爽就一個字!但是改變大小的視窗意味著每個條目都可能改變了高度,那麼我們如何能知道該把錨定的內容移動多少偏移量呢?我們並不知道!為了搞清楚這點,我們可能不得不把錨定條目之上的元素佈局起來,把它們的高度累加在一起。但顯然這樣做會造成改變大小時會有明顯的停頓,我們並不想要這樣的結果。相反,我們藉助於一個假設:在viewport之上的每個元素都是和墓碑等高的。根據這個假設來調整對應的滾動位置。當元素滾動進入 runaway
時,我們調整滾動位置,這樣就有效的把佈局工作延遲到真正需要的時候了。
佈局
我剛才跳過了一個重要的細節:佈局。每次DOM元素的回收通常情況下都會引發整個 runaway
的重新佈局,這會直接影響我們的效能:無法達成每秒60幀的目標。為避免這一點,我們自己承擔了佈局的重任,使用了絕對定位的元素。這樣我們可以讓所有 runaway
中的元素感覺上還在佔用空間,但其實那裡毛都沒有。由於我們自己在操控佈局,我們便可以快取每個元素消失前的位置,在使用者往回滾動時,我們能立刻從快取中載入正確的元素。
理想情況下,條目應該只被重繪一次,那就是當它們被加到DOM時。而且應該對於 runaway
中其它條目的增加或刪除完全不受影響。這個是可能的,但是隻限於現代瀏覽器。
極致優化
最近,Chrome增加了CSS Containment的支援,這個特性允許開發者告訴瀏覽器某個元素是佈局和繪製的邊界。由於我們這裡採用的是自己來佈局,這是一個很好的可以應用 containment
的機會。當我們增加一個元素到 runaway
時,我們知道其它條目不應該被這個重新佈局影響。所以每個條目應該設定一個 contain: layout
。我們同樣也不希望影響站點的其它部分,所以 runaway
本身也需要這樣設定。
另一個優化點,我們考慮的是利用IntersectionObservers去檢測使用者是否已經滾動了足夠距離,以便於我們決定是否開始回收DOM和載入新資料。但是 IntersectionObservers
是為高延遲設計的,所以我們實際上會“感覺”用了 IntersectionObservers
反而比不用時“響應更慢”。在我們當前的實現中滾動事件的處理其實也存在這個問題。也許這個問題的可信度較高的解決方案會是 Houdini’s Compositor Worklet
仍不完美
目前的DOM回收實現方式仍不是完美的,因為我們把所有“滾過”viewport的元素都新增到DOM了,而不是僅僅關心那些在螢幕上可見的元素。這就意味著,如果你滾動的真的非常非常快的話,快到你堆積了大量的佈局和繪製工作,瀏覽器已經無法跟上的地步時,這時我們可能除了背景什麼都看不到了。這當然不是世界末日但是確實是一個可以優化的地方。
我們希望你可以看到這個過程:當你想提供一個高效能的有良好使用者體驗的功能時,一個簡單的問題是演變成複雜問題的。隨著“Progressive Web Apps ”逐漸成為移動裝置的一等公民,高效能的良好體驗會變得越來越重要,開發者也必須持續的研究使用一些模式來應對效能約束。
所有的程式碼可以到這裡檢視,我們已經盡力讓程式碼有可複用性了,但不會釋出一個npm類庫或其它單獨的專案。這個程式碼的主要目的是教學。