深刻淺出動畫幀

在前端性能優化策略中,耳熟能詳的手段有,雅虎 35 條軍規,使用 cache,減小請求數量,使用cookie-free domaincritical 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

  • JavaScript,咱們會經過 js 來改動一些視覺效果,好比基於 js 的動畫,或者響應用戶事件等。
  • Style,若是經過 js 改變了某一個 dom 的樣式,就會從新計算受影響的元素的樣式。
  • Layout,若是樣式改變中涉及了佈局屬性,例如 top,left,width 等幾何位置,還會從新計算它的佈局位置,以及受影響的其餘元素的佈局。固然,若是不涉及佈局樣式,也不會執行這一步。
  • Paint,計算出 Style 和 Layout 後,就能夠把元素繪製到它所屬的 paint layer 上。
  • Composite,最後會將多個 composite layer 輸出到屏幕上顯示出來。

要控制一幀的執行時間在 16.7ms 內,就只須要把上述 5 個步驟處理時間總和控制在 16.7ms 內。接下來,咱們一個步驟一個步驟來看,有哪些優化建議。node

JavaScript

requestAnimationFrame

JavaScript 是前端工程師的利器,有了它,咱們能夠實現很是複雜的系統,或者很是流暢的遊戲等。JavaScript 一般會處理用戶輸入或者點擊等事件,而後作一系列的視覺效果的改變。當涉及 dom 元素改變時,老是把操做放在 requestAnimationFramecss3

requestAnimationFrame會有什麼神奇之處呢?web

  • 它老是會確保 fn 的執行在瀏覽器一幀的開頭
  • 瀏覽器會自動調節幀率,間接調節了 fn 的執行頻率

在不支持 raf 的瀏覽器中,一般使用setTimeout(fn, 16.7)來實現。可是setTimeout有一些很差的地方,chrome

  • 它並非保證 fn 每隔 16.7ms 就執行一次,它只能每 16.7ms 將 fn 加入到MacroTask Queue中,具體何時執行,要根據當前執行隊列決定
  • 它不能保證每次執行時機都是一幀的開頭,可能某一次執行觸發是在幀的中間或者結尾,致使延長當前幀在 16.7ms 內沒法執行完成,就會出現丟失當前幀
  • 不一樣設備幀率不同,並非固定的 60fps,給低幀率(好比 30fps)的設備上執行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 了,

  • 將一些純計算型,不涉及 dom 訪問的任務,放到Web Workers
  • 對於可使用 css3 來實現的動畫,就不須要用 JavaScript 來實現了
  • 對於用戶事件的響應,同步執行時間不要太長,邏輯儘可能簡單,複雜的邏輯能夠延遲處理或分塊處理

Style & Layout

先讀取,後設置

使用 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。

減小 layout

若是咱們在沒有更改 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)
  })
}
複製代碼

Paint

減小繪製區域

一般,對某一個 dom 的改變,都會觸發該 dom 所在層的整個從新繪製。爲了不對整個層的從新繪製,咱們能夠經過把該 dom 提高到一個新的 composite layer,減小對原始層的繪製。

未提高前,每一幀裏都會有對整個 document 的繪製,對整個 document 的繪製會消耗 48us 左右

優化以後,咱們經過will-change: top把小球提高到一個新的 composite layer 上,沒有了對整個 document 的繪製,節省了 48us。

使用 transform 和 opacity

即便在同一層上,咱們對某一個 dom 使用 transform 或者 opacity 來實現動畫,也不會每幀都觸發對整個 layer 的繪製,它只會對當前改變的 dom 的繪製和適當減小對整個 layer 的繪製次數。這個特性實際上是瀏覽器的自身優化策略,transform 和 opacity 被視爲合成器屬性,它們在默認狀況下都會生成新的 paint layer。相比上面的優化方式,使用合成器屬性,實現了一樣的優化效果,可是能夠減小增長 composite layer,每建立一個 composite layer 也是會有性能開銷的。

咱們沒必要直接改變 dom 的 top 或者 width 等幾何屬性,而是使用 transform 的 translate,scale 等來實現一樣的效果,既能夠減小繪製區域,也能夠避免 layout。

減小繪製的複雜度

不一樣的繪製效果,消耗的時間也是不一樣的。一般越複雜的效果,消耗的資源和花費的時間越多,好比陰影,漸變,圓角等,就屬於比較複雜的樣式。這些樣式效果,一般都是設計師設計出來的,咱們沒有理由直接否認。可是,咱們能夠根據不一樣設備來作一些不一樣的降級處理,好比較低設備上,就只用純色來代替漸變,去掉陰影效果等。經過降級處理,保證了頁面功能不受影響,也提升了頁面的性能。

Composite

處理完了上面這些步驟,終於來到了 Composite 步驟。dom 經過適當條件觸發,會提高爲新的 composite layer,在 paint 階段是發生在多個 layer 上的,最後瀏覽器經過合併多個 layer,根據它們的順序來正確的顯示在屏幕上。composite layer 提高觸發條件很是多,這裏我只列舉幾個常見的條件:

  1. 3D 相關的 css 樣式
  2. will-change 設置爲 opacity,transform,top,left,bottom,right
  3. fixed(在高的 DPI 上會默認提高,在低 DPI 上不會)
  4. Hardware-accelerated video element
  5. Hardware-accelerated 2D canvas
  6. 3D WebGL
  7. Overlay(好比 A 覆蓋在 B 上,而 B 是提高的 composite layer,則 A 也會提高到新的 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,從而加速渲染。

如今,一幀的任務都處理完了,是否是就結束了呢?

requestIdleCallback

若是一幀的處理時間少於 16.7ms,多餘出來的時間,瀏覽器會執行requestIdleCallback的任務。這個 API 目前還處在不穩定階段,某些瀏覽器還未實現它。咱們在使用的時候,必定要加上兼容性檢測。

if ("requestIdleCallback" in window) {
  // Use requestIdleCallback to schedule work.
} else {
  // Do what you’d do today.
}
複製代碼

必定不能在 requestIdleCallback 裏更改 dom 樣式

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 })
    複製代碼

小結

經過完整的學習一幀的繪製過程,而後針對每一個過程,咱們都採起一些優化手段,那麼整個動畫都將表現的很是流暢。最好,用一張圖來做爲結尾吧。

參考

相關文章
相關標籤/搜索