王鈺css
2016 年加入 Qunar,目前在去哪兒網平臺事業部前端架構組(YMFE)任前端工程師一職。歡迎訪問團隊博客YMFE(http://ymfe.tech)查看更多技術...。前端
不久前我在 YMFE Conf 上分享了關於構建流暢動畫相關的內容,這篇文章是我分享內容的文字版,你能夠在這裏()看到對應的 PPT。瀏覽器
什麼是 fps,60fps 意味着什麼?前端工程師
fps(frames per second),指一秒內屏幕刷新的次數或者動畫在一秒內更新的幀數。現代瀏覽器大多每秒刷新 60 次,爲了和設備的刷新頻率保持一致,動畫也要保證每秒 60 更新幀。若是低於 60 fps,稱動畫發生了掉幀,若是掉幀嚴重,用戶則可以明顯地感受到卡頓。高的幀率,意味着更連貫的動畫,更流暢的滾動,這些老是能帶來極好的用戶體驗。架構
本文首先談了談現代瀏覽器的渲染流程,並結合各個流程談了一下構建流程動畫的技巧和注意事項。函數
本文的結構以下:工具
從 HTML/CSS 到 Web 頁面佈局
要想高效地操做DOM, 完成流暢的動畫,須要瞭解瀏覽器是如何將 HTML/CSS/Java 等資源渲染爲 Web 頁面的。下面就此過程進行描述:性能
瀏覽器接收到 HTML 文檔後就會開始解析文檔,並創建 DOM 樹 (Document Object Model Tree),DOM 樹中記錄了當前文檔的全部節點。同時瀏覽器使用內聯的 style 標籤或者外部加載的 CSS 文檔來構建 CSSOM 樹(CSS Object Model Tree),CSSOM 樹中記錄了各個節點的樣式規則。隨後聯合 DOM 樹和 CSSOM 樹構建出渲染樹(Render Tree),渲染樹中記錄了當前頁面中全部可見節點的實際樣式。之因此說實際樣式,是由於 CSS 中可能出現width: 50%或color: inherit這樣的寫法,瀏覽器須要自頂向下地去根據父節點來計算出某個節點的實際樣式。測試
整個步驟,以下圖所示:
△渲染樹的構建過程(圖片來自 Chrome developer)
獲得了渲染樹,瀏覽器還不能開始進行繪製,由於頁面上存在太多元素,若是頁面中有一個元素被改變,這個時候若是重繪整個頁面就顯得很浪費,畢竟不少時候只是很小的一部分被改變了。瀏覽器爲了高效地繪製,提出了圖層(layer)的概念,按照某些規則將 DOM 節點劃分在不一樣的圖層中,這樣一個節點的改變,瀏覽器會智能地去重繪那些受到影響的圖層,而非全部圖層,瀏覽器繪製的時候是以圖層爲單位的。
細分後的過程,大體是這樣:
△Web 頁面渲染流程
繪製過程就是瀏覽器調用繪圖 API 來完成圖層的繪製,繪製過程就是填充像素的過程,瀏覽器會調用一些相似於moveTo, lineTo 這樣的繪圖 API,將 各圖層繪製出來,獲得一些像素點的集合,相似於一張位圖(bitmap),這些位圖隨後被上傳至 GPU,GPU 幫助瀏覽器將這些位圖合併起來,獲得最終顯示在屏幕上的圖片。
綜上,瀏覽器渲染出 Web 頁面的過程,大致可分爲如下幾個步驟:
能夠想象瀏覽器內部實現本來以上論述複雜千萬倍,以上也只是從很是宏觀的角度去描述了瀏覽器渲染頁面的過程。其中還沒牽扯到 Java,不過知道以上這些內容,起碼對瀏覽器的渲染流程有了一個大致的認識。
瀏覽器在每一幀中要作的工做
Java 經過 API 來修改 DOM 樹和 CSSOM 樹,CSS 中的 animation 或 transition 都會改變渲染樹,每當渲染樹被改變後,瀏覽器都須要從新計算樣式,樣式計算會涉及多個 DOM 節點,由於有些樣式存在繼承關係,還有則是相對父節點的。
每一幀中瀏覽器都可能要進行下列部分或所有步驟:
△每一幀瀏覽器可能要進行的工做
對上圖中的各個步驟進行一個簡要的解釋說明:
在 Chrome DevTools 能夠清楚地看到這幾個步驟:
部分步驟能夠被跳過
若是修改了一個會影響元素的尺寸或位置的屬性,好比 width 和 height 或者 top 等,須要從新進行 Layout 操做,隨後會進行重繪,隨後將圖層合併獲得新一幀。這就會執行以上的全部步驟。
但若是隻是修改了 color 這樣的不涉及節點尺寸或定位的屬性,則不須要執行 Layout 這一步驟。由於 color 的修改,並不會影響元素的尺寸和位置,只須要進行一次重繪就行了,此時以上步驟中的 Layout 就被跳過了。
△不須要重排
一樣的,若是修改了一個都不須要進行重繪的屬性,那麼能夠跳過 Layout 和 Paint 這兩個步驟,此時只須要要進行圖層的合併操做就能獲得新一幀的圖片。
△不須要重排和重繪
不須要進行重排(Layout)和重繪(Paint)操做,天然會耗時更短,每一幀中瀏覽器須要進行的工做也就越少,必定程度上也就可以提高性能。由此看來對 DOM 樹的修改、對 DOM 節點屬性或樣式的修改,須要付出的代價是不一樣的,某些操做可能會觸發重排和重繪操做,而有些操做則能夠徹底跳過以上步驟。
規律
不過也能夠得出以下的一個規律:
參考資料
paul irish 羅列了那些操做會觸發重排,你能夠在這裏看到:What forces layout / reflow()
另外在https://csstriggers.com/這個...,Chrome 團隊的一夥人列出了對 CSS 各屬性的修改會引起以上那些操做。
在實踐中能夠時刻參考這兩個列表,並結合調試工具,來避免沒有不要的重排和重繪。
構建流程動畫的技巧和注意事項
前面介紹了很多關於瀏覽器渲染過程的基礎知識,旨在幫助對此不清楚的朋友從宏觀上理清楚 Web 頁面的渲染過程。
實現連貫的動畫,流暢的滾動,瞭解以上基礎知識對後續編碼、優化有着巨大的好處。下面根據瀏覽器渲染原理,結合每一幀的瀏覽器須要作的各個步驟,給出了一些切實可行的優化方案,並提出一些注意事項。
後面的內容我想分 5 個點來介紹,分別是:
1 避免沒有必要的重排
每一個前端工程師在入門的時候,都被告知 DOM 很慢,使用腳本對 DOM 進行操做的代價很昂貴,要批量修改 DOM 等等,關於 DOM 操做的話題已經有很多著做進行過論述了。強烈推薦《高性能 Java》()這本書,我以爲這本書應該是前端工程師必讀。
雖然說已經有不少關於 DOM 操做的內容了,這裏我仍是想提一個注意事項:避免強制性同步佈局,由於我常常看到這個字眼,不妨提出來談談。
避免強制性同步佈局
強制性同步佈局(forced synchonous layout),發生在使用 Java 改變了 DOM 元素的屬性,然後又讀取 DOM 元素的屬性的時候,一般也說讀取了髒 DOM 的時候。好比改變了 DOM 元素的寬度,然後又使用clientWidth 讀取 DOM 元素的寬度。這個時候爲了獲取到 DOM 元素真實的寬度,須要從新計算樣式。也就是會從新進行計算樣式(Recalculate Style)和計算佈局( Layout)操做。
設想如下案例,有一組 DOM 元素,須要將其其高度設爲與寬度一致,新手很快就能寫出如下代碼:
解決方案 1 - 簡單粗暴:
執行這段代碼的時候,每次迭代開始的時候,DOM 都是髒的(被改動過),爲了得到真實的 DOM 尺寸,都會從新計算佈局。該循環就會引起屢次強制性同步佈局,這是很低效的作法,千萬要避免。
△引起了強制性同步佈局
從 Chrome DevTools 中很容易地發現該低效操做,能夠看到瀏覽器進行了不少次的從新計算樣式(Recalculate Style)和佈局(Layout),也叫作 reflow(重排)的操做,且這一幀用時很長。
解決方案 2 - 分離讀和寫:
能夠很輕鬆地解決這個問題,使用兩次循環,在第一次循環中讀取 DOM 元素寬度並將結果保存起來,在第二個循環中修改 DOM 元素的高度。
△分離讀寫後
分離讀寫,一個時刻只讀取,另外一個時刻只改寫,這樣就能頗有效地避免強制性同步佈局。
在實際項目中每每沒有上面提到的那樣簡單,有時儘管已經分離了讀和寫,但在寫操做後面仍是不可避免地存在讀取操做,這個時候不妨將寫操做放在requestAnimationFrame中,瀏覽器會在下一幀執行這個對 DOM 的改寫操做。關於requestAnimationFrame後文有詳細的講解。
補充資料
2 避免沒有必要的重繪
在開始以前須要回顧一下何時須要重繪:
在 Chrome DevTools 的 Rendering 選擇卡中勾選 Painting Flashing 選項後,能夠觀察到頁面上正在進行重繪的區域。
避免 fixed 定位元素在滾動時重繪
一個常見的場景是,網頁有一個 fixed 定位的頭部導航欄或者側邊欄。問題存在於每次滾動後,這些 fixed 定位的元素相對於整個內容區域的位置改變了。這就至關於一個圖層中的某個元素的位置改變了,爲了得到滾動後的圖層,須要進行重繪,所以每次滾動都會進行重繪操做。
舉個例子,在騰訊網首頁上有以下 fixed 定位的元素:
不幸的是這幾個 fixed 定位的元素和整個網頁位於同一個圖層:
滾動後,由於定位元素相對於整個文檔的位置發生了改變,所以整個文檔都須要被重繪。解決此類問題的方法就是將 fixed 定位的元素提高至單獨的圖層。使用transform:translateZ(0);這樣的寫法,能夠強制將元素提高至單獨圖層,關於此後文中還有詳細說明。
注:Chrome 在高 dpi 的屏幕上會自動將 fixed 定位的元素提高至單獨的圖層,在低 dpi 的屏幕上不會提高,所以不少開發者在 MacBook Pro 上測試的時候,不會發現問題,但用戶在低 dpi 的屏幕上訪問的時候就出問題了。
將部分元素提高至單獨圖層,避免大面積重繪
使用 transform:translateZ(0); 這樣的 CSS hark 寫法會將元素提高至單獨的圖層。在這麼作以前要考慮爲何要這樣作,建立新的圖層的目的應該是,避免某個元素的改變致使大面積重繪,好比某個小標籤的顏色的改變,致使大面積重繪,所以將其提高至單獨的圖層中。
這是一個面板,其中內容區域的文字會不斷地閃爍(文本的顏色會改變),若是將該文本使用transform:translateZ(0); 提高至單獨的圖層,那麼文本的顏色改變,就只會致使它所在的圖層重繪,而不須要整個面板重繪。這是正確地利用 transform:translateZ(0); 的方式。所以,若是頁面中存在小面積的 DOM 節點須要頻繁地重繪,能夠考慮將其提高至單獨的圖層中。你能夠在這裏看到 demo —— 避免大面積重繪()。
正確地處理動圖
頁面加載的時候爲了更好的用戶體驗經常會使用一個 loading,但在頁面加載完成後如何處理 loading 呢?一個錯誤的方法是將其 z-index 設置一個更小的值,將其隱藏起來,不幸的是就算 loading 不可見,瀏覽器依然會在每一幀對它進行重繪。所以對於像 loading 這樣的動態圖,在不須要顯示的時候最好使用display:none或者visibility: hidden;來完全隱藏,或者乾脆移除 DOM。
3 利用 GPU 加速網頁渲染
前端工程師應該都據說過硬件加速,一般是指利用 GPU 來加速頁面的渲染。早期瀏覽器徹底依賴 CPU 來進行頁面渲染。如今隨着 GPU 的能力加強和普及,且目前絕大多數運行瀏覽器的設備上都集成了 GPU。瀏覽器能夠利用 GPU 來加速網頁渲染。
GPU 包含幾百上千個核心,但每一個核心的結構都相對簡單, GPU 的結構也決定了它適合用來進行大規模並行計算。進行圖層合併須要操做大量的像素,這方面 GPU 能比 CPU 更高效的完成。這裏有個視頻(),很清楚地說明 CPU 與 GPU 的差異。
經常看到有文章指出使用transform:translateZ(0);這樣的 hark 能夠強制開啓硬件加速來提升性能,這是錯誤的說法。下面就來講說硬件加速的實質。
何爲硬件加速
GPU 可以存儲必定數量的紋理(texture),也就是一個矩形的像素點集合。一般這個集合會對應到 Web 頁面上的某個圖層,GPU 可以高效地對這些像素點進行多種變換(位移、旋轉、拉伸)操做。在實現動畫的時候,利用 GPU 的這一特性,若是隻須要對原像素集合在 GPU 內進行一次變換,就能獲得新一幀的圖層,那麼動畫的全部操做都在 GPU 內高效地完成了,沒有重繪操做。
獲得了變換後的圖層,只須要再進行一次圖層的合併,將該變換後的圖層和其餘圖層合併起來,最終獲得在屏幕上顯示的整幅圖片。GPU 的這一特性就經常被稱爲硬件加速。
要利用硬件加速也是有條件的,盲目地使用transform:translateZ(0);而不知原理,只會讓事情變得更糟糕。硬件加速的本質是說讓下一幀的圖層在 GPU 內通過變換得來,可是若是某些操做 GPU 沒法完成,必須動畫修改了 DOM 節點的寬度,顏色等,這依然是須要在 CPU 端進行軟件的重繪的,這種狀況就沒法利用硬件加速的機制。
使用transform:translateZ(0);會強制瀏覽器建立一個新的層,每建立一個層都須要消耗額外的內存,有太多的層就會消耗大量內存,這會致使設備內存不夠用,有可能致使應用奔潰。另外這些圖層最後須要上傳至 GPU 進行圖層合併,太多的層,會致使 GPU 和 CPU 之間的帶寬不夠用,反而影響性能。
目前常見的 CSS 屬性中只有 filter, transform, opacity 這幾個屬性的改變能夠在 GPU 端進行處理,這在前面已經提到過了,所以應該儘量使用這些屬性來完成動畫。
後面會有更多關於利用 GPU 的這一特性的例子,下面先看一個須要注意的點:
避免無謂地新建圖層
一個真實案例:
△每一個列表項都是一個圖層
這是一個城市選擇頁,這個頁面中的每一項都使用了 transform:translateZ(0); 強制提高至了單獨的圖層,滾動列表,並錄製了一段 Timeline。
△優化前
從上圖中能夠看到,性能是至關糟糕的,大量時間都花費在了圖層的合併上,每一幀都須要合併上千個列表子項,這不是一件很輕鬆的事情。
爲了體現,錯誤使用 transform:translateZ(0); 的嚴重性,下面來看看去掉後的效果,去掉該屬性後,一片綠,沒有任何性能問題。
△優化後
所以在談起硬件加速的時候,必定知道,什麼是硬件加速,硬件加速是如何工做的,它能作什麼,不能作什麼。合理的利用 GPU 才能利用它幫咱們構建出 60fps 的體驗。
4 構建更加流暢的動畫
上面講了,使用 transform 和 opacity 來建立動畫(filter 的支持度還不夠好)最爲高效。所以每當須要用到動畫的時候,首先要考慮使用這兩個屬性來完成。
避免使用會觸發 Layout 的屬性來進行動畫
有時候看起來不太可能使用這兩個屬性來完成,不過仔細想一想每每可以想到解決方案。考慮下面動畫:
demo 地址:expand cord()
通常的想法多是修改每一個卡片的 top, left, width, height 來實現這個功能,這樣作固然能夠實現效果,只是改變這些屬性都會觸發 Layout 進而觸發 Paint 操做,在複雜應用上勢必形成卡頓。下面介紹一種使用 transform 來完成此動畫的方法。
以上思路是使用getBoundingClientRect將動畫的始態和終態的尺寸和位置計算出來,而後利用 transform 來進行過渡,思路在代碼註釋中已經進行了說明。
通過這樣的處理,本來須要使用top,left,width,height來進行的動畫使用transfrom就搞定了,這會大大地提示動畫的性能。
使用 transform, filter 和 opacity 來完成動畫
使用以上 3 個屬性來完成動畫,能夠避免在動畫的每一幀進行重繪。但若是在動畫中改變了其餘屬性,那也不能避免從新繪製。要儘量地利用這幾個屬性來完成動畫。涉及位移的考慮使用 translate,涉及大小的考慮 scale,涉及顏色的考慮 opacity,爲了實現流暢的動畫要想盡一切辦法。
這裏給出一個案例,Instagram 的安卓 APP 在登陸的時候,有一個顏色漸變的效果,這種效果經常見到。
△Instagram 登陸頁的背景色漸變效果
經過地不斷地改變背景顏色能很快地實現,測試後會發如今低端設備上會感到卡頓,CPU 使用率飆升,這是由於修改背景顏色會致使頁面重繪。爲了避免重繪也能達到一樣的效果,咱們可使用兩個 div,給它們設置兩個不一樣的背景色,在動畫中改變兩個 div 的透明度,這樣兩個不一樣透明度的 div 疊加在一塊兒就能獲得一個顏色演變的效果,而整個動畫只使用了 opacity 來完成,徹底避免了重繪操做。
關於示例,你能夠在此處看到: 使用 background 完成漸變 vs 使用 opacity 完成漸變()
不要混用 transform, filter, opacity 和其餘可能觸發重排或重繪的屬性,雖然使用 transform, filter, opacity 來完成動畫可以有很好的性能,可是若是在動畫中混合使用了其餘的會觸發重排或重繪的屬性,那麼依然不能達到高性能。
使用 requestAnimationFrame 來驅動動畫
前面提到的動畫大可能是使用 CSS 動畫 和 CSS 過渡 CSS 動畫一般是事先定義好的,沒法很靈活地控制,某些時候可能須要使用 Java 來驅動動畫。新手經常使用 setTimeout 來完成動畫,問題在於使用 setTimeout 設置的回調會在主線程空閒的時候纔會調用,想象下面場景:
setTimeout在一幀的中間位置被觸發,隨後致使從新計算樣式進而致使一個長幀。setTimeout/setInterval 主要存在如下侷限性:
setTimeout/setInterval 會週期性的調用,及時當前網頁並無在活動。另外由於調用時機不肯定可能引起的在同一幀內屢次調用同一個回調,若是回調中觸發了屢次重繪,那麼會出如今一幀中重繪屢次的狀況,這是沒有必要的,且會致使掉幀。
而requestAnimationFrame,一個專門用來驅動動畫的 API,它有如下好處:
雖然requestAnimationFrame是一個已經存在不少年的 API 了,可是仍是存在諸多誤讀,其中最嚴重的是認爲使用requestAnimationFrame可以避免從新佈局和重繪,瀏覽器可以啓動優化措施,讓動畫更流暢,這是錯誤的,瀏覽器能保證的僅僅是以上 3 條,在requestAnimationFrame的回調中進行強制同步佈局依然會觸發重排。
在編寫使用 Java 驅動的動畫時,使用requestAnimationFrame能夠將對 DOM 的寫操做放在下一幀進行,這樣該幀後面對 DOM 的讀取操做就不會引起強制性同步佈局,瀏覽器只須要在下一幀開始的時候進行一次重排。
5 正確地處理滾動事件
現代瀏覽器都使用一個單獨的線程來處理滾動和輸入,這個線程叫作合成線程,它可以和 GPU 進行通訊來告訴 GPU 如何移動圖層,進行頁面的滾動。若是頁面上綁定了 touchmove,mousemove 這類事件,合成線程須要等待主線程執行相應的事件監聽函數,由於這些函數裏面可能會調用 preventDefault 來阻止滾動。
對於優化 scroll,touchmove,mousemove 等事件,其中一個最爲重要的建議就是,要控制此類高頻事件的回調的執行頻率。說到控制頻率,天然會想到 debounce 和 throttle 這兩個函數。曾一度爲止迷惑,不妨簡要對這兩個函數進行科普:
使用 debounce 或 throttle 控制高頻事件觸發頻率
debounce 和 throttle 是兩個類似(但不相同)的用於控制函數在某段事件內的執行頻率的技術。
debounce
屢次連續的調用,最後只調用一次
想象本身在電梯裏面,門將要關上,這個時候另一我的來了,取消了關門的操做,過了一下子門又要關上,又來了一我的,再次取消了關門的操做。電梯會一直延遲關門的操做,直到某段時間裏沒人再來。
throttle
將頻繁調用的函數限定在一個給定的調用頻率內。它保證某個函數頻率再高,也只能在給定的事件內調用一次。好比在滾動的時候要檢查當前滾動的位置,來顯示或隱藏回到頂部按鈕,這個時候可使用 throttle 來將滾動回調函數限定在每 300ms 執行一次。
須要注意的是這兩個函數的使用方法,它們接受一個函數,而後返回一個節流/去抖後的函數,所以下面第二種用法纔是正確的
使用 requestAnimationFrame 來觸發滾動事件的回調
若是在事件監聽函數中進行了 DOM 操做,這可能會消耗很多時間,事件監聽函數執行的時間變長,與 GPU 進行通訊的合成線程也就遲遲接收不到通知,瀏覽器也就遲遲不知道如何滾動頁面,由此引起的就是卡頓。對於這類同步的事件(瀏覽器等待事件執行完成),能夠在事件觸發的時候先讀取須要獲取的 DOM 元素的尺寸位置等信息,而後將其餘改寫 DOM 的操做安排在 requestAnimationFrame 中完成,瀏覽器可以更快地執行完事件回調,還能避免後續的讀取 DOM 的時候發生重排。
另外,有時候但願事件在每一幀執行一次,此時是使用 throttle 是沒法知足需求的,使用requestAnimationFrame 能夠保證每一幀都會調用,須要注意的是有的事件觸發的頻率多是一幀好幾回。所以在使用 requestAnimationFrame 的時候要注意判斷是否在一幀內屢次觸發了回調。
總結
這兩篇文章對瀏覽器的渲染過程進行了簡要描述,而後根據瀏覽器渲染原理,分析實現流暢的動畫須要注意的方方面面,並給出多個實現流暢動畫的實用技巧。
不過規則最是不停在改變的,瀏覽器也不斷在更新,一年前是性能瓶頸的點,如今可能已經不是瓶頸了。在開發過程當中應該結合調試工具,去分析每一次重排和重繪,分析各個階段的耗時,找出真正的問題所在。而不是僅僅記住一些條條框框。
歡迎留言交流或投稿,和咱們一塊兒分享知識。