讓你的網頁更絲滑(一)

奇舞週刊發表於2019-03-11

編者按:本文作者Berwin,W3C效能工作組成員,360導航高階前端工程師。《深入淺出Vue.js》(正在出版)作者。

前段時間,我將精力專注在Web效能領域;在這個領域下有個重要的課題是如何讓網頁更絲滑(流暢)。

想讓網頁變得絲滑,首先,我們需要一個標準來判斷什麼樣的網頁是絲滑的;其次,我們要準確的測量出網頁的效能資料;最後,使用有效的方法讓網頁變得絲滑。

本篇文章將針對這三個方面進行詳細的介紹。

1. RAIL

到底怎樣的網頁是絲滑的?我們需要一個標準來輔助判斷我們的網頁是否絲滑。

Chrome團隊提出了一個以使用者為中心的效能模型被稱為RAIL,它為工程師提供一個目標,只要達到目標的網頁,使用者就會覺得很流暢;它將使用者體驗拆解為一些關鍵操作,例如:點選,載入等;並給這些操作規定一個目標,例如:點選一個按鈕後,多長時間給反饋使用者會覺得流暢。

RAIL將影響效能的行為劃分為四個方面,分別是:Response響應Animation動畫Idle空閒Load載入。沒錯,RAIL這個名字來自於這四個單詞的首字母,方便記憶。

1.1 響應Response

研究表明,100ms內對使用者的輸入操作進行響應,通常會被人類認為是立即響應。時間再長,操作與反應之間的連線就會中斷,人們就會覺得它的操作有延遲。例如:當使用者點選一個按鈕,如果100ms內給出響應,那麼使用者就會覺得響應很及時,不會察覺到絲毫延遲感。

1.2 動畫Animation

現如今大多數裝置的螢幕重新整理頻率是60Hz,也就是每秒鐘螢幕重新整理60次;因此網頁動畫的執行速度只要達到60FPS,我們就會覺得動畫很流暢。

FFrames PPer SSecond 指的畫面每秒鐘傳輸的幀數,60FPS指的是每秒鐘60幀;換算下來每一幀差不多是16毫秒。
(1 秒 = 1000 毫秒) / 60 幀 = 16.66 毫秒/幀
複製程式碼

但通常瀏覽器需要花費一些時間將每一幀的內容繪製到螢幕上(包括樣式計算、佈局、繪製、合成等工作),所以通常我們只有10毫秒來執行JS程式碼。

1.3 空閒Idle

為了更好的效能,通常我們會充分利用瀏覽器空閒週期Idle Period做一些低優先順序的事情。例如:在空閒週期預請求一些接下來可能會用到的資料或上報分析資料等。

RAIL規定,空閒週期內執行的任務不得超過50ms,當然不止RAIL規定,W3C效能工作組的Longtasks標準也規定了超過50毫秒的任務屬於長任務,那麼50ms這個數字是怎麼得來的呢?

瀏覽器是單執行緒的,這意味著同一時間主執行緒只能處理一個任務,如果一個任務執行時間過長,瀏覽器則無法執行其他任務,使用者會感覺到瀏覽器被卡死了,因為他的輸入得不到任何響應。

為了達到100ms內給出響應,將空閒週期執行的任務限制為50ms意味著,即使使用者的輸入行為發生在空閒任務剛開始執行,瀏覽器仍有剩餘的50ms時間用來響應使用者輸入,而不會產生使用者可察覺的延遲。如圖1-1所示:

圖1-1
圖1-1

事實上,不論是空閒任務還是高優先順序的其他任務,執行時間都不得超過50ms

1.4 載入Load

如果不能在1秒鐘內載入網頁並讓使用者看到內容,使用者的注意力就會分散。使用者會覺得他要做的事情被打斷,如果10秒鐘還打不開網頁,使用者會感到失望,會放棄他們想做的事,以後他們或許都不會再回來。

1.5 小結

通過RAIL,我們可以判斷出我們的網頁是否絲滑。RAIL從使用者感知角度出發規定了一些指標,只要我們的網頁符合標準,則我們的網頁是絲滑的,使用者會覺得我們的網頁很流暢。

RAIL 關鍵指標 使用者操作
響應(Response) 小於100ms 點選按鈕。
動畫(Animation) 小於16ms 滾動頁面,拖動手指,播放動畫等。
空閒(Idle) 小於50ms 使用者沒有與頁面互動,但應該保證主執行緒足夠處理下一個使用者輸入。
載入(Load) 1000ms 使用者載入頁面並看到內容。

2. 畫素管道

畫素管道是製作絲滑網頁的靈魂,我們後面將要介紹的技術都與它有關。

畫素管道

上圖就是畫素管道,通常我們會使用JS修改一些樣式,隨後瀏覽器會進行樣式計算,然後進行佈局,繪製,最後將各個圖層合併在一起完成整個渲染的流程,這期間的每一步都有可能導致頁面卡頓。

注意,並不是所有的樣式改動都需要經歷這五個步驟。舉例來說:如果在JS中修改了元素的幾何屬性(寬度、高度等),那麼瀏覽器需要需要將這五個步驟都走一遍。但如果您只是修改了文字的顏色,則佈局(Layout)是可以跳過去的,如下圖所示:

畫素管道2

除了最後的合成,前面四個步驟在不同的場景下都可以被跳過。例如:CSS動畫就可以跳過JS運算,它不需要執行JS。

css-triggers1給出了不同的CSS屬性被更改後會觸發畫素管道的哪些步驟。

簡單來說,畫素管道經歷的步驟越多,渲染時間就越長,單個步驟內可能也會因為某種原因而變得耗時很長;所以不管是步驟多還是單個步驟耗費的時間長,最終都會導致整體渲染時間變長。整體時間越長就越有可能超出RAIL所規定的指標。

舉個簡單的例子:網頁動畫的渲染若是達到60FPS,則動畫不會丟幀。假設渲染管道的佈局與繪製耗費了10ms,那麼加上樣式計算與合成的時間,則留給JS處理動畫的時間就只有幾毫秒,如果JS的執行超過了幾毫秒那麼該動畫每一幀所耗費的時間就會超過16ms,這時候動畫一定會丟幀,使用者用肉眼就可以看到明顯的卡頓。

當然,即便能保證每一幀的總耗時小於16ms,依然無法保證不會丟幀。關於這點後面我們會詳細介紹。

3. 如何讓動畫更絲滑

動畫需要達到60FPS才能變得絲滑,本節我們介紹如何讓動畫在不丟幀的情況下穩定保持在60FPS。

3.1 使用Chrome開發者工具測量動畫效能

在評估動畫效能時,通常需要逐幀評估畫素管道的開銷;使用 Chrome 開發者工具可以輔助我們進行精準的測量。

在Chrome開發者工具中,點選Performance皮膚,然後選中Screenshots核取方塊,。如圖3-1所示:

Chrome Devtools Performance
圖3-1Chrome開發者工具Performance皮膚

然後點選錄製按鈕,錄製完畢後點選停止按鈕就可以捕獲當前頁面的效能資料。如圖3-2所示:

捕獲效能資料
圖3-2捕獲效能資料

捕獲出的結果如圖3-3所示:

捕獲出的效能結果
圖3-3捕獲出的效能結果

我們可以放大主執行緒從而精準的看到每一幀瀏覽器都執行了哪些任務以及每個任務耗費了多長時間。如圖3-4所示:

畫素管道
圖3-4效能皮膚最主要的部分

從上圖可以看到,瀏覽器每一幀渲染所執行的任務與前面我們介紹的畫素管道是相同的。上圖中因為是CSS動畫,所以沒有執行JS,但每一幀都需要計算樣式、佈局、繪製與合成。

3.2 如何讓JS動畫更絲滑

JS動畫是使用定時器不停的執行JS,通過在JS中修改樣式完成網頁動畫;若想保證動畫流暢,從JS的執行到最終瀏覽器顯示出畫面,每一幀總耗時最多16ms,這樣動畫才能達到60FPS。

如圖3-4所示,即便是在不執行JS的情況下,瀏覽器計算樣式、佈局、繪製等工作也是需要時間的,所以需要給瀏覽器預留出 充分的時間6ms 做這些事情,現在留給JS的執行時間就只有 10ms

每一幀的總體耗時必須小於16ms
圖3-5每一幀總體耗時必須小於16ms,JS執行時間小於10ms

一旦JS執行時間超過10ms,就很有可能導致這一幀的畫素管道整體耗時超過16ms,從而無法達到60FPS,但你以為只要保證JS的執行時間小於10ms就一定能保證不丟幀?Naive~

3.2.1 使用requestAnimationFrame

即便你能保證每一幀的總耗時都小於16ms,也無法保證一定不會出現丟幀的情況,這取決於觸發JS執行的方式。

假設使用 setTimeoutsetInterval 來觸發JS執行並修改樣式從而導致視覺變化;那麼會有這樣一種情況,因為setTimeoutsetInterval沒有辦法保證回撥函式什麼時候執行,它可能在每一幀的中間執行,也可能在每一幀的最後執行。所以會導致即便我們能保障每一幀的總耗時小於16ms,但是執行的時機如果在每一幀的中間或最後,最後的結果依然是沒有辦法每隔16ms讓螢幕產生一次變化。如圖3-6所示:

使用定時器觸發動畫
圖3-6使用定時器觸發動畫

也就是說,即便我們能保證每一幀總體時間小於16ms,但如果使用定時器觸發動畫,那麼由於定時器的觸發時機不確定,所以還是會導致動畫丟幀。現在整個Web只有一個API可以解決這個問題,那就是requestAnimationFrame,它可以保證回撥函式穩定的在每一幀最開始觸發。如圖3-7所示:

使用requestAnimationFrame觸發動畫
圖3-7使用requestAnimationFrame觸發動畫

3.2.2 避免FSL

FSL (Forced Synchronous Layouts) 被稱為強制同步佈局;前面介紹畫素管道時說過,將一幀送到螢幕會通過如下順序:

畫素管道

先執行JS,然後在JS中修改了樣式從而導致樣式計算,然後樣式的改動觸發了佈局、繪製、合成。但JavaScript可以強制瀏覽器將佈局提前執行,這就叫 F 強制 S 同步 L 佈局

圖3-8強制同步佈局
圖3-8強制同步佈局

通常我們一不小心就造成了FSL,請看下面程式碼:

box.classList.add('big');
const width = box.offsetWidth;
複製程式碼

程式碼中通過新增class修改了元素的樣式,隨後使用offsetWidth讀取元素的寬度。乍一看似乎沒什麼問題,但這段程式碼會導致FSL。

在 JavaScript 執行時,上一幀已經渲染好的所有佈局值都是已知的,我們可以使用offsetWidth這樣的語法獲得值;但這一幀剛修改完的樣式瀏覽器還沒渲染呢,這時候使用offsetWidth這樣的語法讀取元素的寬度,那麼瀏覽器為了告訴我們寬度值,它必須先計算該寬度,這就需要佈局。如圖3-8所示,佈局跑到了樣式計算的前面。

所以正確的做法是先獲取寬度,然後再更改樣式:

const width = box.offsetWidth;
box.classList.add('big');
複製程式碼

看起來,似乎即使觸發了FSL也不過就是管道的順序變了而已,影響好像並沒有那麼大。?

單個FSL對效能的影響確實不大,但如果觸發了佈局抖動,則影響會變得非常大。看下面程式碼:

const container = document.querySelector('.container');
const boxes = document.querySelectorAll('p');

for (var i = 0; i < boxes.length; i++) {
  // Read a layout property
  const newWidth = container.offsetWidth;
    
  // Then invalidate layouts with writes.
  boxes[i].style.width = newWidth + 'px';
}
複製程式碼

上面程式碼的作用是批量修改N個P元素的寬度;在迴圈中我們先獲取容器元素的寬度,隨後設定了P元素的樣式。這會導致瀏覽器去佈局,然後計算樣式。每次更改樣式,都會導致剛剛執行的佈局失效,因為我們又改了新的樣式,所以下一輪迴圈讀取寬度時,瀏覽器又要執行一次佈局,如此反覆直到迴圈結束。在迴圈期間,瀏覽器不停地執行無效佈局,這被稱為 佈局抖動Layout Thrashing;這種錯誤導致的效能問題非常高。

如果我們不小心觸發了FSL,Chrome開發者工具會給出紅色的線提示,如圖3-9所示:

開發者工具提示FSL
圖3-9開發者工具提示FSL

同時任務的右上角會有紅色的三角形表示,我們可以放大任務進一步檢視,如圖3-10所示:

開發者工具提示FSL詳情
圖3-10開發者工具提示FSL詳情

若想看Demo可以點選我2,在Demo中點選按鈕可以讓P標籤的寬度變長。

為了避免佈局抖動,我們可以將讀取元素寬度的程式碼放到迴圈的外面。程式碼如下:

const container = document.querySelector('.container');
const boxes = document.querySelectorAll('p');

// Read a layout property
const newWidth = container.offsetWidth;

for (var i = 0; i < boxes.length; i++) {    
    // Then invalidate layouts with writes.
    boxes[i].style.width = newWidth + 'px';
}
複製程式碼

若想看Demo可以點選我3,可以看到這個Demo與前一個demo一模一樣,甚至我們無法用肉眼分辨出哪個更快,這是因為DOM元素少,所以總體時間都比較少,但我們可以通過Chrome開發者工具來捕獲效能資料。

優化後的時間
圖3-11優化後的時間

圖3-11可以看到,優化後這一幀的總時間用了4.7ms,而優化前的是101ms,如圖3-12所示:

優化前的時間
圖3-12優化前的時間

優化後比優化前,每幀所耗費的時間快了21.7倍,數字非常驚人。

3.3 如何讓CSS動畫更絲滑

CSS動畫通常使用@keyframetransition結合樣式的變動來實現視覺變化的效果。我們同樣可以通過減少畫素管道的步驟和每個步驟所耗費的時間讓CSS動畫更流暢。

本節介紹的CSS動畫的優化方式同樣適用於JS動畫,但上一節介紹的JS動畫優化方法不適用於CSS動畫,它們是包含關係。

繪製Paint通常需要花費很長時間,我們可以通過Chrome開發者工具來觀察正在繪製的區域。開啟開發者工具,按下鍵盤上的 Esc 鍵。在出現的皮膚中,切換到“rendering”標籤,然後選中“Paint flashing”。如圖3-13所示:

圖3-13開啟繪製閃爍
圖3-13開啟繪製閃爍

開啟繪製閃爍Paint flashing後,每當頁面發生繪製時,我們都可以在螢幕上看到繪製發生區有綠色在閃爍。如圖3-14所示:

繪製區域閃爍
圖3-14繪製區域閃爍

如圖3-14所示,當我們開啟了繪製閃爍,則會繪製區域出現了綠色的閃爍,可以點選我檢視Demo4

當我們看到我們認為不應該繪製的區域時,我們應該進一步研究並取消繪製區域。

如何才能避免繪製的發生呢?答案是:圖層。

事實上瀏覽器在渲染頁面時,可以將頁面分為很多個圖層,有點類似於PhotoShop,一張圖片在PotoShop中是由多個圖層組合而成,而瀏覽器最終顯示的頁面實際也是由多個圖層構成的。如圖3-15所示:

圖層
圖3-15圖層

將原本不斷髮生變化的元素提升到單獨的圖層中,就不再需要繪製了,瀏覽器只需要將兩個圖層合併在一起即可,檢視Demo請狠狠的點選我5

如果您點選了上面的Demo地址,並開啟了繪製閃爍,您會發現沒有任何閃爍發生,因為瀏覽器沒有進行繪製。如果您檢視Layers皮膚,你會看到這樣的場景,如圖3-16:

圖層
3-16圖層

當我們使用Performance皮膚捕獲效能資料時會發現繪製Paint已經不見了。如圖3-17所示:

捕獲不到繪製
圖3-17捕獲不到繪製

建立圖層的最佳方式是使用will-change,但某些不支援這個屬性的瀏覽器可以使用3D 變形(transform: translateZ(0))來強制建立一個新層。

在Chrome開發者工具“rendering”標籤中,選中“Layer borders”。可以看到頁面中有哪些合成層。合成層會使用橘黃色的邊框,如圖3-18所示:

顯示合成層
圖3-18顯示合成層

為了減少繪製,可以通過新增圖層,但是圖層的管理也是需要成本的,所以要避免濫用,通常需要具體情況具體分析,做出合適的選擇。

前面我的Demo都是修改元素的left屬性讓方塊移動,這避免不了需要進行佈局操作,最佳的方法是使用transform屬性,這個屬性是由合成器單獨處理的,所以使用這個屬性可以避免佈局與繪製。

總結

RAIL可以幫助我們判斷什麼樣的網頁是絲滑的,而開發者工具可以讓我們進一步準確的捕獲出網頁的效能資料。

JS動畫要保證預留出6ms的時間給瀏覽器處理畫素管道,而自身執行時間應該小於10ms來保證整體執行速度小於16ms。但觸發動畫的時機也很重要,定時器無法穩定的觸發動畫,所以我們需要使用requestAnimationFrame觸發JS動畫。同時我們應該避免一切FSL,它對效能的影響非常大。

CSS動畫我們可以通過降低繪製區域並且使transform屬性來完成動畫,同時我們需要管理好圖層,因為繪製和圖層管理都需要成本,通常我們需要根據具體情況進行權衡並做出最好的選擇。

相關文章