什么是頁面卡頓?如下:
當拖動頁面或者滾動的時候頁面一卡一卡的,看起來不連貫,我們就說頁面卡了,這是一種非常不友好的體驗,怎么衡量頁面卡頓的情況呢?
1. 失幀和幀率FPS
如果你家里買了電視盒的話,在設置里面應該會有一個輸出設置:
上面選中的60Hz就是幀率(frame per second),即一秒鍾60幀,換句話說,一秒鍾的動畫是由60幅靜態圖片連在一起形成的。60fps是動畫播放比較理想、比較基礎的要求。當然如果你的顯卡要是連這個都支持不了的話那就沒辦法了。windows系統有個刷新頻率也是這個意思。
所以卡了,就是失幀了,或者掉幀了,1秒鍾沒有60個畫面,看起來不連貫了。這可能是因為在渲染某些幀所花的時間比較長,導致停留在這些幀的時間較長,所以畫面停頓了。
2. 渲染流程
60fps就要求1幀的時間為1s / 60 = 16.67ms。瀏覽器顯示頁面的時候,要處理js邏輯,還要做渲染,每個執行片段不能超過16.67ms。實際上,瀏覽器內核自身支撐體系運行也需要消耗一些時間,所以留給我們的時間差不多只有10ms。這10ms里面需要做一些什么事情?在Chrome的開發者文檔Rendering Performance里面提到這個流程:
首先你用js做了些邏輯,還觸發了樣式變化,style把應用的樣式規則計算好之后,把影響到的頁面元素進行重新布局,叫做layout,再把它畫到內存的一個畫布里面,paint成了像素,最后把這個畫布刷到屏幕上去,叫做composite,形成一幀。
這幾項的任何一項如果執行時間太長了,就會導致渲染這一幀的時間太長,平均幀率就會掉。假設這一幀花了50ms,那么此時的幀率就為1s / 50ms = 20fps.
當然上面的過程並不一定每一步都會執行,例如:
- 你的js只是做一些運算,並沒有增刪DOM或改變CSS,那么后續幾步就不會執行
- style只改了顏色等不需要重新layout的屬性就不用執行layout這一步
- style改了transform屬性,在blink和edge瀏覽器里面不需要layout和paint,如下面css trigger的說明:
發生掉幀的時候,我們可以使用的Chrome的devtools的timeline來觀察這個過程。以最開始的例子做說明。
3. 掉幀分析
打開timeline的標簽,勾上js profile和paint這兩個選項,然后點擊左邊的記錄按鈕:
在頁面拖動地圖,出現卡頓的情況后,點擊關閉記錄按鈕,就會生成這次操作的詳細過程,先看最上面的overview圖:
最上面一欄是幀率,頂點表示60fps,紅色方格表示渲染時間比較長的幀,Chrome把這種情況叫做jank。可以看到上面有3個比較大的低谷,這並不是異常的失幀,這是Chrome檢測到頁面沒有動了,idle空閑了,自動降低幀率。第二欄是CPU,黃色的為script,紫色的是CSS,藍色是html,可以看到往往script占了比較高的CPU。關於timeline更詳細的說明,可以查看chrome的文檔。
我們注意到在6s和8s中間CPU占用有一個比較大的峰值,並且失幀得比較厲害:
選中這段區域,進行放大查看:
可以看到有好幾幀都超過了16.67ms,其中有一幀甚至達到了81.8ms,所以難怪卡得那么厲害。我們重點看一下這一幀里面發生了什么。
這一幀的FPS只有1s / 81.8ms = 12fps,點擊第二個tab展開:
其中js的處理用掉了46.8ms(js里面還要更新dom),排第二的rendering花掉了22.9ms,這個rendering包括上面說的css計算和layout:
最后的Painting,時間還是比較少的,只花了2.5ms:
所以最長的開銷是js腳本,並且很可能js里面做了很多dom操作或者改了很多css,導致Rendering的時間也很長。
由於在開始記錄之前勾選了js profile的選項,所以可觀察這些js執行的具體開銷,包括調用的函數棧及每個函數的執行時間:
最上面那個函數是XHR Ready State Change觸發的,也就是說這一整段代碼都是在一個ajax的success回調函數里面執行的。再往下可以看到回調函數里面調用的最耗時的兩個函數:
其一的showMapResut就花費了22.65ms,它又調了removeOldHouses和addNewHouses,這兩個各自的時間約為11ms。
而另一個showResult的時間更多:
快40ms,它下面的doShowResut和resizeContainer最為耗時。
所以我們找到4個最為耗時的函數。那接下來怎么辦呢?
上面已經提到,每一幀留給我們的時間只有10ms。所以可以考慮把上面那4個函數拆了,分別在4個連續的幀里面執行。這樣應該會改善很多。
4. 拆分代碼段
我們把代碼拆成一個個單元,每個單元就是一個task任務,每一幀執行之前就去取一個task執行。並且控制每個task的執行時間都在10ms以內。這樣就可以解決問題。js在渲染每一幀之前會去調requestAnimationFrame
(傳一個函數的參數給它去執行)。所以用這一個api,並把task傳給它。我們建立一個任務隊列,為此封裝一個Task類:
使用的時候先new一個Task,然后調draw函數初始化。有任務的時候調addTask插到隊尾,執行任務的時候調shift取出隊頭元素。
上面的實現其實有一點問題,因為requestAnimationFrame是全局的,每次new一個Task,進行draw的時候,會把上一個傳給它的task給覆蓋掉。但是這個是可以從代碼層面上解決的,這里不展開討論。
然后再封裝一個mapTask的單例,存放map頁面的task:
需要插入一個任務的時候就調一下mapTask.add,把上面4個十分耗時的函數分別當作一個任務插進去,下面是原本的執行邏輯:
現在把它改成兩個task,並加到任務隊列里面:
同樣地,把另外兩個也這樣改一下。
然后再拖動地圖,查看效果,會發現頁面瞬間爽滑了好多:
當把頁面拖快的時候還是會有一點卡頓,但是比之前已經好很多。這里還有優化的空間,例如后面兩個函數的執行時間還是比較長,可以把這兩個函數再繼續拆分task。
看一下timeline:
可以看到4個task分別在4幀執行,並且Task3還有很大的優化空間。
除了拆分代碼段的方法外,還有其它一些地方要注意:
5. 其它的優化方法
(1)盡量減少layout
獲取scrollTop、clentWidth等維度屬性時都會觸發layout以獲取實時的值,所以在for循環里面應該把這些值緩存一下。以下代碼:
應該改成:
當循環次數很多的時候,優化版的代碼會明顯提高性能。
獲取一個元素的樣式(getComputedStyle)時,也會觸發layout
另外,能夠使用transform滿足要求的就別使用position/width/height做動畫。
(2)簡化DOM結構
當DOM結構越復雜時,需要重繪的元素也就越多。所以dom應該保持簡單,特別是那些要做動畫的,或者要監聽scroll/mousemove事件的。另外使用flex比使用float在重繪方面會有優勢,詳見:《Avoid Large, Complex Layouts and Layout Thrashing》
參考:
擴展閱讀:
- Effective前端1:能使用html/css解決的問題就不要使用JS
- Effective前端2:優化html標簽
- Effective前端3:用CSS畫一個三角形
- Effective前端4:盡可能地使用偽元素
- Effective前端5:減少前端代碼耦合