在前端性能優化策略中,耳熟能詳的手段有,雅虎 35 條軍規,使用 cache,減小請求數量,使用cookie-free domain,critical asset,使用 CDN,Lazy load,PreLoad 等,這些手段其實主要都是圍繞怎麼樣更快的拿到所需關鍵資源。當咱們把這一步作到很好,沒有可優化空間了,其實還能夠從另一個方向去作優化,那就是瀏覽器渲染方面。瀏覽器渲染優化是一個很大的主題,今天,咱們只談它一個小角度,動畫幀。從動畫幀,咱們能夠怎麼樣來作一些優化工做呢?本文篇幅較長,圖較多,耐心看完,必定會有不少收穫的。javascript
先仍是來熟悉基礎概念。幀,能夠理解爲瀏覽器每一次繪製的快照。1s 內繪製的次數,叫作幀率,也就是咱們常說的 fps(frame per second)。幀率越大,瀏覽器在 1s 內繪製的次數就越多,動畫就越流暢。人們視覺系統對幀率的最低要求通常是 24fps,當幀率低於 24 時,就會感受到明顯的卡頓了。不一樣的移動設備,有不一樣的幀率,通常默認是 60fps。css
咱們要追求的理想幀率是 60fps,那麼一幀繪製的時間最可能是 1 / 60 = 16.7ms,一旦超過這個數,就達不到 60fps 了。爲了使得一幀花費的時間控制在 16.7ms 內,咱們必須先搞清楚瀏覽器在一幀會作些什麼事情呢?html
html,js,css 是瀏覽器處理最爲常見的三種資源,也是咱們前端工程師天天都會打交道的文件。但是咱們真正知道瀏覽器是怎麼繪製的嗎?稍微思考一下,而後會說,html 解析爲 dom tree,css 解析爲 cssom tree,而後 dom tree 加上 cssom tree 就合併爲 render tree,最後瀏覽器根據 render tree 繪製到屏幕上。這是咱們最爲熟知的步驟,可是它只是描述了一個總體大體的過程,並無具體到一幀的繪製過程。下面看看一幀具體繪製的過程圖。前端
上圖就是瀏覽器在繪製一幀時,會通過的處理步驟。java
要控制一幀的執行時間在 16.7ms 內,就只須要把上述 5 個步驟處理時間總和控制在 16.7ms 內。接下來,咱們一個步驟一個步驟來看,有哪些優化建議。node
JavaScript 是前端工程師的利器,有了它,咱們能夠實現很是複雜的系統,或者很是流暢的遊戲等。JavaScript 一般會處理用戶輸入或者點擊等事件,而後作一系列的視覺效果的改變。當涉及 dom 元素改變時,老是把操做放在 requestAnimationFrame
中。css3
requestAnimationFrame
會有什麼神奇之處呢?web
在不支持 raf 的瀏覽器中,一般使用setTimeout(fn, 16.7)
來實現。可是setTimeout
有一些很差的地方,chrome
setTimeout(fn, 16.7)
將會致使執行不少無心義的 fn咱們先使用setTimeout
來實現,每 16.7ms 就更新一次小球的位置,這樣儘可能保證了 1s 內更新 60 次。typescript
// 使用setTimeout來實現小球下落的動畫
function animtionWithSetTimeout() {
setTimeout(() => {
updateBoll()
animtionWithSetTimeout()
}, 16.7)
}
複製代碼
而後,咱們經過 Chrome 的 Performance 來分析當前幀率狀況以下,
幀率上會有一些鋸齒,表示有部分狀況是低於 60fps 的。而且在上圖中,某一幀中執行了兩次updateBoll
,第二次的updateBoll
是在幀快結束時觸發的,因此拉長了當前幀執行的時間。
而後,咱們使用requestAnimationFrame
來實現它。
// 使用requestAnimationFrame來實現小球下落
function animationWithRaf() {
updateBoll()
requestAnimationFrame(animationWithRaf)
}
複製代碼
一樣,使用 Chrome 的 Performance 來分析當前幀率狀況以下,
幀率明顯穩定在 60fps 左右,沒有上面出現的鋸齒了,確保了每一幀中都只會觸發一次updateBoll
。
JavaScript 的執行是在一幀的開頭,JavaScript 執行的時間越長,那麼當前幀花費的時間就越長。當瀏覽器要開始更新屏幕了,若是當前幀還未完成,那麼當前幀就會被丟棄,延遲到一下次更新,表現出來的就是丟幀。咱們應該儘量縮短 JavaScript 的執行時間,一般不要超過 10ms,由於後面的 Style,Layout,Paint,Composite 至少要花費 6ms 的時間。
當有一個大任務,須要長時間的執行時,咱們能夠把它分散到每一幀中去完成其中一小部分。這樣,能夠縮短在每幀中執行的時間。咱們來看一個例子,好比要在一幀裏繪製 100 個小球,假設須要花費 3s 的時間。
function createBoll() {
sleep(30) // 模擬30ms操做
const boll = document.createElement("div")
boll.classList.add("boll")
boll.style.background = randomColor()
document.body.append(boll)
}
const COUNT = 100
/* 直接執行大任務 */
function longTask() {
requestAnimationFrame(() => {
for (i = 0; i < COUNT; i++) {
createBoll()
}
})
}
複製代碼
能夠明顯的看到,這一幀裏花費了 3022.7ms,期間頁面一直是上一幀的內容(空白)。只有等到任務完成了,纔會所有顯示小球出來。下面,咱們把這個長任務分散到每一幀裏執行,每一幀只建立一個小球。
/* 分塊執行小任務 */
function chunkTask() {
let i = 0
requestAnimationFrame(function doOne() {
// 每一幀,只建立一個小球
createBoll()
i++
if (i < COUNT) {
requestAnimationFrame(doOne)
}
})
}
複製代碼
如今每一幀都只花費了 30ms 左右,而且頁面不會空等 3s 才顯示小球,而是小球逐個繪製出來了,體驗明顯好多了。
對於 JavaScript 步驟,還有其餘一些很好的建議,下面簡單列舉出來,就不作 demo 了,
使用 JavaScript 來繪製動畫時,咱們每每須要根據當前 dom 狀態(好比位置,大小)來更新它的狀態。一般的最佳實踐是,先讀取,後設置。在 raf 中,頁面 dom 元素樣式都已經在上一幀中計算好了,在讀取某一個 dom 樣式時,不會觸發瀏覽器從新計算樣式和佈局。可是,若是咱們是先設置,而後在讀取,那麼這個 dom 樣式已經被改變了,爲了保證獲取到正確的樣式值,瀏覽器必須從新對當前 dom 進行樣式計算,若是還涉及佈局改變,也會進行從新佈局,這種狀況就是咱們一般說的觸發了 reflow。
下面咱們來驗證一下,在 raf 中,每次都是先設置了小球的 top,而後再讀取它的 offsetTop 和屏幕的 offsetHeight。
function reflow() {
requestAnimationFrame(() => {
boll.style.top = boll.offsetTop + 2 + "px"
if (boll.offsetTop < document.documentElement.offsetHeight) {
reflow()
}
})
}
複製代碼
經過分析,能夠看到在 JavaScript 中就觸發了 Recalculation Forced 和 Layout Forced。如今咱們優化一下,先讀取它的 offsetTop 和屏幕的 offsetHeight 值,而後在去設置它的 top
function optimizeReflow() {
requestAnimationFrame(() => {
const top = boll.offsetTop
const bodyHeight = document.documentElement.offsetHeight
if (top >= bodyHeight) {
return
}
boll.style.top = top + 2 + "px"
optimizeReflow()
})
}
複製代碼
經過分析能夠看到,在 JavaScript 步驟沒有出現上面的 Recalculation Forced 和 Layout Forced。
若是咱們在沒有更改 dom 的幾何屬性(width,height,top,left,bottom,right 等),就不會觸發 layout 步驟。當一個 dom 的 layout 被改變時,一般都會影響到其餘關聯的 dom 的 layout 也被改變。在設置動畫時,能避免直接改變 dom 的 layout,就應該盡力避免。好比咱們可使用 transform 來位移元素,而沒必要直接改變它的 layout。仍是上面的例子,一樣的動畫,咱們改動 transform 來實現。
function transform() {
let distance = document.documentElement.offsetHeight - boll.offsetTop
let dropDistance = 0
requestAnimationFrame(function drop() {
if (dropDistance > distance) {
return
}
boll.style.transform = "translateY(" + dropDistance + "px)"
dropDistance = dropDistance + 2
requestAnimationFrame(drop)
})
}
複製代碼
一般,對某一個 dom 的改變,都會觸發該 dom 所在層的整個從新繪製。爲了不對整個層的從新繪製,咱們能夠經過把該 dom 提高到一個新的 composite layer,減小對原始層的繪製。
未提高前,每一幀裏都會有對整個 document 的繪製,對整個 document 的繪製會消耗 48us 左右
優化以後,咱們經過will-change: top
把小球提高到一個新的 composite layer 上,沒有了對整個 document 的繪製,節省了 48us。
即便在同一層上,咱們對某一個 dom 使用 transform 或者 opacity 來實現動畫,也不會每幀都觸發對整個 layer 的繪製,它只會對當前改變的 dom 的繪製和適當減小對整個 layer 的繪製次數。這個特性實際上是瀏覽器的自身優化策略,transform 和 opacity 被視爲合成器屬性,它們在默認狀況下都會生成新的 paint layer。相比上面的優化方式,使用合成器屬性,實現了一樣的優化效果,可是能夠減小增長 composite layer,每建立一個 composite layer 也是會有性能開銷的。
咱們沒必要直接改變 dom 的 top 或者 width 等幾何屬性,而是使用 transform 的 translate,scale 等來實現一樣的效果,既能夠減小繪製區域,也能夠避免 layout。
不一樣的繪製效果,消耗的時間也是不一樣的。一般越複雜的效果,消耗的資源和花費的時間越多,好比陰影,漸變,圓角等,就屬於比較複雜的樣式。這些樣式效果,一般都是設計師設計出來的,咱們沒有理由直接否認。可是,咱們能夠根據不一樣設備來作一些不一樣的降級處理,好比較低設備上,就只用純色來代替漸變,去掉陰影效果等。經過降級處理,保證了頁面功能不受影響,也提升了頁面的性能。
處理完了上面這些步驟,終於來到了 Composite 步驟。dom 經過適當條件觸發,會提高爲新的 composite layer,在 paint 階段是發生在多個 layer 上的,最後瀏覽器經過合併多個 layer,根據它們的順序來正確的顯示在屏幕上。composite layer 提高觸發條件很是多,這裏我只列舉幾個常見的條件:
composite layer 有本身的 graphic context,因此在渲染的時候,速度很是快,它是直接在 GPU 上完成的,不須要經過 CPU 處理。可是每新增一個 composite layer 都會消耗額外的內存,也不能盲目的將元素提高爲新的 composite layer。
除了有 Composite layer,還有一種 paint layer。paint layer 是在stacking context上的,例如咱們在使用z-index
時就會生成新的 paint layer,在幀處理步驟中,其實還有一步 Update Layer Tree 就是用來更新 paint player 的。
每一個 dom node 都會有對應的 layout object。若是 layout object 在同一個座標系空間中,就會在同一個 paint layer 上。在某些條件下,好比 z-index,absolute 等會生成新的 paint layer。默認狀況下,paint layer 都共享同一個 composite layer,可是在某些條件下,好比 animation,will-change 等會把當前 paint layer 提高爲新的 composite layer,從而加速渲染。
如今,一幀的任務都處理完了,是否是就結束了呢?
若是一幀的處理時間少於 16.7ms,多餘出來的時間,瀏覽器會執行requestIdleCallback
的任務。這個 API 目前還處在不穩定階段,某些瀏覽器還未實現它。咱們在使用的時候,必定要加上兼容性檢測。
if ("requestIdleCallback" in window) {
// Use requestIdleCallback to schedule work.
} else {
// Do what you’d do today.
}
複製代碼
rIC 階段,frame 的樣式佈局等都己經 commit 了,因此不能在 rIC 裏直接改變 dom 的佈局或者樣式。若是改變了,會致使樣式佈局計算失效,在下一幀就會觸發 forced layout 等。例以下面這個例子,咱們直接在 rIC 裏改了小球的位置。
if ("requestIdleCallback" in window) {
requestIdleCallback(() => {
const left = boll.offsetLeft
// 這裏,咱們直接改了boll的位置
boll.style.left = left + 2 + "px"
moveLeftWithBadRic()
})
}
複製代碼
而後,打開 chrome 調試一下,就會發現問題,在下一幀的開頭就會觸發了 layout,這是咱們不但願的。
更好的方式就是,咱們能夠在 rIC 裏計算好樣式,而後在 rAF 裏去更新樣式。
if ("requestIdleCallback" in window) {
requestIdleCallback(() => {
const left = boll.offsetLeft + 2
const top = boll.offsetTop + 2
// 在rAF裏更新位置
requestAnimationFrame(() => {
boll.style.left = left + "px"
boll.style.top = top + "px"
})
moveLeftWithGoodRic()
})
}
複製代碼
優化以後,咱們再調試一下,發現,幀開頭沒有了 layout,這是咱們想要的結果。
rIC 裏,可用時間是很是有限的,不能一次執行長時間任務。可根據參數 deadline.timeRemaing()來判斷當前可用時間,若是時間到了,必需要結束,或者放在下一個 rIC 裏執行
function myNonEssentialWork(deadline) {
// Use any remaining time, or, if timed out, just run through the tasks.
while (
(deadline.timeRemaining() > 0 || deadline.didTimeout) &&
tasks.length > 0
)
doWorkIfNeeded()
if (tasks.length > 0) requestIdleCallback(myNonEssentialWork)
}
複製代碼
rIC 不能保證必定會執行。因此通常放在 rIC 裏的任務是無關核心邏輯或用戶體驗的,通常好比數據上報或者預處理數據。可用傳入 timeout 參數,保證任務必定會執行。
// Wait at most two seconds before processing events.
requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 })
複製代碼
經過完整的學習一幀的繪製過程,而後針對每一個過程,咱們都採起一些優化手段,那麼整個動畫都將表現的很是流暢。最好,用一張圖來做爲結尾吧。