最近在研究頁面渲染及web動畫的性能問題,以及拜讀《CSS SECRET》(CSS揭祕)這本大作。
本文主要想談談頁面優化之滾動優化。
主要內容包括了爲何需要優化滾動事件,滾動與頁面渲染的關係,節流與防抖,pointer-events:none 優化滾動。因爲本文涉及了很多很多基礎,可以對照上面的知識點,選擇性跳到相應地方閱讀。
滾動優化的由來
滾動優化其實也不僅僅指滾動(scroll 事件),還包括了例如 resize 這類會頻繁觸發的事件。簡單的看看:
1
2
3
4
|
var i
= 0; window.addEventListener( 'scroll' , function (){ console.log(i++); }, false ); |
輸出如下:
在綁定 scroll 、resize 這類事件時,當它發生時,它被觸發的頻次非常高,間隔很近。如果事件中涉及到大量的位置計算、DOM 操作、元素重繪等工作且這些工作無法在下一個 scroll 事件觸發前完成,就會造成瀏覽器掉幀。加之用戶鼠標滾動往往是連續的,就會持續觸發 scroll 事件導致掉幀擴大、瀏覽器 CPU 使用率增加、用戶體驗受到影響。
在滾動事件中綁定回調應用場景也非常多,在圖片的懶加載、下滑自動加載數據、側邊浮動導航欄等中有着廣泛的應用。
當用戶瀏覽網頁時,擁有平滑滾動經常是被忽視但卻是用戶體驗中至關重要的部分。當滾動表現正常時,用戶就會感覺應用十分流暢,令人愉悅,反之,笨重不自然卡頓的滾動,則會給用戶帶來極大不舒爽的感覺。
滾動與頁面渲染的關係
爲什麼滾動事件需要去優化?因爲它影響了性能。那它影響了什麼性能呢?額......這個就要從頁面性能問題由什麼決定說起。
我覺得搞技術一定要追本溯源,不要看到別人一篇文章說滾動事件會導致卡頓並說了一堆解決方案優化技巧就如獲至寶奉爲圭臬,我們需要的不是拿來主義而是批判主義,多去源頭看看。
從問題出發,一步一步尋找到最後,就很容易找到問題的癥結所在,只有這樣得出的解決方法才容易記住。
說教了一堆廢話,不喜歡的直接忽略哈,回到正題,要找到優化的入口就要知道問題出在哪裏,對於頁面優化而言,那麼我們就要知道頁面的渲染原理:
瀏覽器渲染原理我在我上一篇文章裏也要詳細的講到,不過更多的是從動畫渲染的角度去講的:【Web動畫】CSS3 3D 行星運轉 && 瀏覽器渲染原理 。
想了想,還是再簡單的描述下,我發現每次 review 這些知識點都有新的收穫,這次換一張圖,以 chrome 爲例子,一個 Web 頁面的展示,簡單來說可以認爲經歷了以下下幾個步驟:
-
JavaScript:一般來說,我們會使用 JavaScript 來實現一些視覺變化的效果。比如做一個動畫或者往頁面裏添加一些 DOM 元素等。
-
Style:計算樣式,這個過程是根據 CSS 選擇器,對每個 DOM 元素匹配對應的 CSS 樣式。這一步結束之後,就確定了每個 DOM 元素上該應用什麼 CSS 樣式規則。
-
Layout:佈局,上一步確定了每個 DOM 元素的樣式規則,這一步就是具體計算每個 DOM 元素最終在屏幕上顯示的大小和位置。web 頁面中元素的佈局是相對的,因此一個元素的佈局發生變化,會聯動地引發其他元素的佈局發生變化。比如,<body> 元素的寬度的變化會影響其子元素的寬度,其子元素寬度的變化也會繼續對其孫子元素產生影響。因此對於瀏覽器來說,佈局過程是經常發生的。
-
Paint:繪製,本質上就是填充像素的過程。包括繪製文字、顏色、圖像、邊框和陰影等,也就是一個 DOM 元素所有的可視效果。一般來說,這個繪製過程是在多個層上完成的。
-
Composite:渲染層合併,由上一步可知,對頁面中 DOM 元素的繪製是在多個層上進行的。在每個層上完成繪製過程之後,瀏覽器會將所有層按照合理的順序合併成一個圖層,然後顯示在屏幕上。對於有位置重疊的元素的頁面,這個過程尤其重要,因爲一旦圖層的合併順序出錯,將會導致元素顯示異常。
這裏又涉及了層(GraphicsLayer)的概念,GraphicsLayer 層是作爲紋理(texture)上傳給 GPU 的,現在經常能看到說 GPU 硬件加速,就和所謂的層的概念密切相關。但是和本文的滾動優化相關性不大,有興趣深入瞭解的可以自行 google 更多。
簡單來說,網頁生成的時候,至少會渲染(Layout+Paint)一次。用戶訪問的過程中,還會不斷重新的重排(reflow)和重繪(repaint)。
其中,用戶 scroll 和 resize 行爲(即是滑動頁面和改變窗口大小)會導致頁面不斷的重新渲染。
當你滾動頁面時,瀏覽器可能會需要繪製這些層(有時也被稱爲合成層)裏的一些像素。通過元素分組,當某個層的內容改變時,我們只需要更新該層的結構,並僅僅重繪和柵格化渲染層結構裏變化的那一部分,而無需完全重繪。顯然,如果當你滾動時,像視差網站(戳我看看)這樣有東西在移動時,有可能在多層導致大面積的內容調整,這會導致大量的繪製工作。
防抖(Debouncing)和節流(Throttling)
scroll 事件本身會觸發頁面的重新渲染,同時 scroll 事件的 handler 又會被高頻度的觸發, 因此事件的 handler 內部不應該有複雜操作,例如 DOM 操作就不應該放在事件處理中。
針對此類高頻度觸發事件問題(例如頁面 scroll ,屏幕 resize,監聽用戶輸入等),下面介紹兩種常用的解決方法,防抖和節流。
防抖(Debouncing)
防抖技術即是可以把多個順序地調用合併成一次,也就是在一定時間內,規定事件被觸發的次數。
通俗一點來說,看看下面這個簡化的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
//
簡單的防抖動函數 function debounce(func,
wait, immediate) { //
定時器變量 var timeout; return function ()
{ //
每次觸發 scroll handler 時先清除定時器 clearTimeout(timeout); //
指定 xx ms 後觸發真正想進行的操作 handler timeout
= setTimeout(func, wait); }; }; //
實際想綁定在 scroll 事件上的 handler function realFunc(){ console.log( "Success" ); } //
採用了防抖動 window.addEventListener( 'scroll' ,debounce(realFunc,500)); //
沒采用防抖動 window.addEventListener( 'scroll' ,realFunc); |
上面簡單的防抖的例子可以拿到瀏覽器下試一下,大概功能就是如果 500ms 內沒有連續觸發兩次 scroll 事件,那麼纔會觸發我們真正想在 scroll 事件中觸發的函數。
上面的示例可以更好的封裝一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
//
防抖動函數 function debounce(func,
wait, immediate) { var timeout; return function ()
{ var context
= this ,
args = arguments; var later
= function ()
{ timeout
= null ; if (!immediate)
func.apply(context, args); }; var callNow
= immediate && !timeout; clearTimeout(timeout); timeout
= setTimeout(later, wait); if (callNow)
func.apply(context, args); }; }; var myEfficientFn
= debounce( function ()
{ //
滾動中的真正的操作 },
250); //
綁定監聽 window.addEventListener( 'resize' ,
myEfficientFn); |
節流(Throttling)
防抖函數確實不錯,但是也存在問題,譬如圖片的懶加載,我希望在下滑過程中圖片不斷的被加載出來,而不是隻有當我停止下滑時候,圖片才被加載出來。又或者下滑時候的數據的 ajax 請求加載也是同理。
這個時候,我們希望即使頁面在不斷被滾動,但是滾動 handler 也可以以一定的頻率被觸發(譬如 250ms 觸發一次),這類場景,就要用到另一種技巧,稱爲節流函數(throttling)。
節流函數,只允許一個函數在 X 毫秒內執行一次。
與防抖相比,節流函數最主要的不同在於它保證在 X 毫秒內至少執行一次我們希望觸發的事件 handler。
與防抖相比,節流函數多了一個 mustRun 屬性,代表 mustRun 毫秒內,必然會觸發一次 handler ,同樣是利用定時器,看看簡單的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
//
簡單的節流函數 function throttle(func,
wait, mustRun) { var timeout, startTime
= new Date(); return function ()
{ var context
= this , args
= arguments, curTime
= new Date(); clearTimeout(timeout); //
如果達到了規定的觸發時間間隔,觸發 handler if (curTime
- startTime >= mustRun){ func.apply(context,args); startTime
= curTime; //
沒達到觸發間隔,重新設定定時器 } else { timeout
= setTimeout(func, wait); } }; }; //
實際想綁定在 scroll 事件上的 handler function realFunc(){ console.log( "Success" ); } //
採用了節流函數 window.addEventListener( 'scroll' ,throttle(realFunc,500,1000)); |
上面簡單的節流函數的例子可以拿到瀏覽器下試一下,大概功能就是如果在一段時間內 scroll 觸發的間隔一直短於 500ms ,那麼能保證事件我們希望調用的 handler 至少在 1000ms 內會觸發一次。
使用 rAF(requestAnimationFrame)觸發滾動事件
上面介紹的抖動與節流實現的方式都是藉助了定時器 setTimeout ,但是如果頁面只需要兼容高版本瀏覽器或應用在移動端,又或者頁面需要追求高精度的效果,那麼可以使用瀏覽器的原生方法 rAF(requestAnimationFrame)。
requestAnimationFrame
window.requestAnimationFrame() 這個方法是用來在頁面重繪之前,通知瀏覽器調用一個指定的函數。這個方法接受一個函數爲參,該函數會在重繪前調用。
rAF 常用於 web 動畫的製作,用於準確控制頁面的幀刷新渲染,讓動畫效果更加流暢,當然它的作用不僅僅侷限於動畫製作,我們可以利用它的特性將它視爲一個定時器。(當然它不是定時器)
通常來說,rAF 被調用的頻率是每秒 60 次,也就是 1000/60 ,觸發頻率大概是 16.7ms 。(當執行復雜操作時,當它發現無法維持 60fps 的頻率時,它會把頻率降低到 30fps 來保持幀數的穩定。)
簡單而言,使用 requestAnimationFrame 來觸發滾動事件,相當於上面的:
1
|
throttle(func,
xx, 1000/60) //xx
代表 xx ms內不會重複觸發事件 handler |
簡單的示例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
var ticking
= false ; //
rAF 觸發鎖 function onScroll(){ if (!ticking)
{ requestAnimationFrame(realFunc); ticking
= true ; } } function realFunc(){ //
do something... console.log( "Success" ); ticking
= false ; } //
滾動事件監聽 window.addEventListener( 'scroll' ,
onScroll, false ); |
上面簡單的使用 rAF 的例子可以拿到瀏覽器下試一下,大概功能就是在滾動的過程中,保持以 16.7ms 的頻率觸發事件 handler。
使用 requestAnimationFrame 優缺點並存,首先我們不得不考慮它的兼容問題,其次因爲它只能實現以 16.7ms 的頻率來觸發,代表它的可調節性十分差。但是相比 throttle(func, xx, 16.7) ,用於更復雜的場景時,rAF 可能效果更佳,性能更好。
總結一下
-
防抖動:防抖技術即是可以把多個順序地調用合併成一次,也就是在一定時間內,規定事件被觸發的次數。
-
節流函數:只允許一個函數在 X 毫秒內執行一次,只有當上一次函數執行後過了你規定的時間間隔,才能進行下一次該函數的調用。
-
rAF:16.7ms 觸發一次 handler,降低了可控性,但是提升了性能和精確度。
簡化 scroll 內的操作
上面介紹的方法都是如何去優化 scroll 事件的觸發,避免 scroll 事件過度消耗資源的。
但是從本質上而言,我們應該儘量去精簡 scroll 事件的 handler ,將一些變量的初始化、不依賴於滾動位置變化的計算等都應當在 scroll 事件外提前就緒。
建議如下:
避免在scroll 事件中修改樣式屬性 / 將樣式操作從 scroll 事件中剝離
輸入事件處理函數,比如 scroll / touch 事件的處理,都會在 requestAnimationFrame 之前被調用執行。
因此,如果你在 scroll 事件的處理函數中做了修改樣式屬性的操作,那麼這些操作會被瀏覽器暫存起來。然後在調用 requestAnimationFrame 的時候,如果你在一開始做了讀取樣式屬性的操作,那麼這將會導致觸發瀏覽器的強制同步佈局。
滑動過程中嘗試使用 pointer-events: 禁止鼠標事件
大部分人可能都不認識這個屬性,嗯,那麼它是幹什麼用的呢?
pointer-events 是一個 CSS 屬性,可以有多個不同的值,屬性的一部分值僅僅與 SVG 有關聯,這裏我們只關注 pointer-events: 的情況,大概的意思就是禁止鼠標行爲,應用了該屬性後,譬如鼠標點擊,hover 等功能都將失效,即是元素不會成爲鼠標事件的 target。
可以就近 F12 打開開發者工具面板,給 <body> 標籤添加上 pointer-events:
那麼它有什麼用呢?
pointer-events: 可用來提高滾動時的幀頻。的確,當滾動時,鼠標懸停在某些元素上,則觸發其上的 hover 效果,然而這些影響通常不被用戶注意,並多半導致滾動出現問題。對 body 元素應用 pointer-events: ,禁用了包括 hover 在內的鼠標事件,從而提高滾動性能。
1
2
3
|
.disable-hover
{ pointer-events:
none; } |
大概的做法就是在頁面滾動的時候, 給 <body> 添加上 .disable-hover 樣式,那麼在滾動停止之前, 所有鼠標事件都將被禁止。當滾動結束之後,再移除該屬性。
可以查看這個 demo 頁面。
上面說 pointer-events: pointer-events-MDN ,還專門有文章講解過這個技術: 段話摘自
使用pointer-events:none實現60fps滾動 。
這就完了嗎?沒有,張鑫旭有一篇專門的文章,用來探討 pointer-events:
結論見仁見智,使用 pointer-events:
其他參考文獻(都是好文章,值得一讀):
- 實例解析防抖動(Debouncing)和節流閥(Throttling)
- 無線性能優化:Composite
- Javascript高性能動畫與頁面渲染
- Google Developers--渲染性能
- Web高性能動畫
到此本文結束,如果還有什麼疑問或者建議,可以多多交流。