讓你的網頁更絲滑(全)

Berwin發表於2019-06-01

這篇文章是2019年5月11號,我在上海FDConf2019上的分享整理。

  • 演講主題:【讓你的網頁更絲滑】
  • 時間:2019年5月11日(下午)
  • 地點:上海 - FDCon2019 - B會場(全棧&全端專場)
  • 演講嘉賓:劉博文

PPT地址:ppt.baomitu.com/d/b267a4a3

原文地址:github.com/berwin/Blog…

讓你的網頁更絲滑

簡介

大家好,我叫劉博文,今天給大家分享的主題叫《讓你的網頁更絲滑》,其實就是更流暢的意思。

簡單介紹一下自己,2012年我從中專畢業,當時是17歲,2015年我加入了360最大的前端團隊奇舞團,那一年我是20歲;2017年由於組織架構的變動,我們組被拆分到360導航,所以我就變成360導航的一名前端工程師;2018年就是去年,因為公司是W3C的會員,所以我就加入了W3C的效能工作組。

自我介紹

訊息比較靈通的應該聽說過我在上個月出版了一本講Vue的書,叫做《深入淺出Vue.js》

圖片

雖然出版了一本Vue的書,但其實從去年加入W3C效能工作組之後,我一直在學習和了解Web效能領域相關的知識。

什麼樣的網頁是流暢的

在討論如何讓網頁更流暢之前,需要先思考一個問題就是什麼樣的網頁是流暢的?

這個問題我總結了一句話:在網頁與使用者產生互動的過程中,讓使用者感覺流暢

圖片

你的網頁不一定要有多快,它沒有一個標準,你的標準就是讓使用者感覺流暢就夠了。另一個重點就是說在互動過程中,讓使用者感到流暢。所以延伸出一個問題,如何通過互動讓使用者感覺流暢。這裡面我把互動總結為兩種型別,一種是被動的,一種是主動的。

圖片

所謂被動互動就是不需要使用者主動去觸發什麼,就可以讓網頁在視覺上與使用者產生互動。 比如說:Animation(動畫)、開屏廣告、自動播放的輪播圖等都算被動互動。與之相反,需要使用者主動去觸發某些行為從而產生的反饋,我稱它為主動互動,比如說用滑鼠點某一個按紐產生的反饋,或使用鍵盤按下了某個鍵位產生的反饋。這個反饋可以是動畫,任何東西都可以。那麼被動互動如何讓使用者感覺流暢?這是今天第一個關於優化的話題。

被動互動如何讓使用者感覺流暢

我在京東上搜尋顯示器,發現有一個篩選條件叫重新整理率,最低的是60HZ,高的可以達到165HZ以上。

這個60HZ是什麼意思?就是指螢幕每秒鐘重新整理60次。所以我們可以通過螢幕作為參考,如果我們的網頁也可以每秒鐘往螢幕傳輸60個畫面,使用者就會覺得這個網頁是流暢的,有一個單位叫做FPS,意思就是每秒鐘往螢幕上傳輸的影像數量。FPS達到60,使用者就會覺得這個網頁比較流程,換算下來,每一幀是16.7毫秒。

圖片

主動互動如何讓使用者感覺流暢

主動互動如何讓使用者感覺流暢?我也把它總結成一句話,這句話叫:“通過響應的時間影響使用者的感覺”。就是說我們可以通過操控這個時間來影響使用者對網頁的感覺。

圖片

我們看一個演示(Demo),這個演示很簡單,就是我點選按紐的時候,我讓這個函式延遲多少秒,然後把這個方塊改變一下顏色。這下面是八個按紐,分別是10毫秒、30毫秒、50毫秒、100毫秒、200毫秒、300毫秒、500毫秒、1秒。(文章無法演示,可以到線上PPT裡去體驗,或者訪問code.h5jun.com/pojob

圖片

你會發現當我點選200毫秒的按鈕時,這個反饋速度,使用者會覺得這個東西有一點卡,當我點選100毫秒的按鈕時,已經感覺不卡了,當然更快更好。所以你會發現100毫秒是一個臨界點,從我們的輸入,包括鍵盤按鍵和滑鼠點選到最終輸出到眼睛裡,這個時間100毫秒是臨界點。超過這個時間,使用者就會覺得有點卡,所以100毫秒是關鍵點。

圖片

我們再看一個例子,程式碼和剛才是一樣的,現在只有一個按紐是100毫秒,剛才我說100毫秒,使用者就會覺得很流暢。其實你會發現還是卡一下,但是不是說每次都卡,有的時候不卡,為什麼有的時候卡有的時候不卡?

因為我們的目標是從輸入到輸出總時間是100毫秒以內,使用者才會覺得流暢。但其實我這個程式碼有一個問題是這個函式的執行時間是100毫秒,所以如果當我點選這個按紐一瞬間,如果有其他任務在執行,就會把我這個函式堵塞住,被阻塞的時間加上函式執行的100毫秒,現在整體時間已經超過100毫秒,所以我剛才點選這個按紐,你會發現有時候卡,有時候不卡,不卡的時候是因為我點選這個按紐的時候,恰巧沒有其他的任務在執行。

所以為什麼會有這個問題?因為大家都知道JS是單執行緒的,瀏覽器同一時間內只能執行一個任務,所以為了避免這個問題,解決方案就是說所有的任務執行時間不能超過50毫秒。如果我所有的任務都不超過50毫秒,假設最糟糕的情況下,我點選這個按紐的一瞬間,有其他的任務在執行,但其實他的任務執行時間最多是50毫秒,我的任務執行時間也是保持在50毫秒以內,其實總共也不會超過100毫秒,所以使用者依然會覺得很流暢,即便是最糟糕的情況下。

圖片

可以看一下這個粉色的地方,從input到response總時間是100毫秒,紅色區域是被阻塞的部分,黃色是函式執行的時間和時機,你會發現我這兩個任務都保持在50毫秒以內的情況下,我可以保證我的總時間是100毫秒以內完成的,這個50毫秒不是我定的,W3C效能工作組有一個Longtask規範也對這種情況做了規定。

圖片

這個規範就規定所有的任務,包括函式執行,包括什麼都算上,不能超過50毫秒,超過50毫秒就被定義為長任務,所謂長任務就是執行時間過長的任務,這是不合理的,應該被解決的任務。效能監控一般都會通過圖中的程式碼來監控與捕獲長任務,可以看到這個entryType是longtask的。

圖片

總結一下,如何讓使用者感覺流暢?就是響應時間保持在100毫秒以內,動畫要16.7毫秒傳輸一幀到螢幕上,空閒任務不能超過50毫秒,其實不只是空閒任務,所有任務都不能超過50毫秒,載入時間是1000毫秒,所謂的頁面秒開就是從這裡來的。這四個單詞的首字母加在一起組成一個單詞叫RAIL,這是一個術語,它代表以使用者為中心的效能模型,我們剛才講的也是這個話題,感興趣大家可以回去查一下。

畫素管道

今天講第二個概念叫畫素管道。所謂畫素管道,就是說我們通常會在網頁觸發一些視覺變化,你用JS改了顏色和寬度等等,隨後瀏覽器就會做樣式計算,瀏覽器還會做佈局、繪製,合併圖層等,這個過程叫做畫素管道。

圖片

但是有的時候,不是所有的樣式都會觸釋出局,有的時候不需要佈局的,我們通過一些優化手段也可以取消Paint(繪製)這一步。有一個網站叫 csstriggers,可以看哪些屬性觸發了佈局,哪些觸發了Paint,這個網站有列表可以看。

避免長任務

今天第一個關於如何優化的話題叫如何保證主動互動讓使用者感覺流暢,其實剛才我們介紹說想保證主動互動讓使用者感覺流暢需要避免長任務,所以這個副標題叫如何避免長任務

圖片

如何避免長任務,有兩種方案:一種叫 Web Worker ,還有一種方案叫 Time Slicing(時間切片)。

圖片

Web Worker

先說Web Worker,我們看一段程式碼,我的網頁裡面有一個while迴圈,通常來講這個迴圈會把瀏覽器卡死一秒鐘,因為迴圈了一秒,現在我把它移動到 worker中 執行,就不會卡死瀏覽器了,它在worker線層中工作,就不會卡死主執行緒。這是一種解決方案,可以看一下效果。(由於文章無法演示效果,感興趣的小夥伴可以到線上PPT裡觀察 ppt.baomitu.com/d/b267a4a3#…

const testWorker = new Worker('./worker.js')
setTimeout(_ => {
  testWorker.postMessage({})
  testWorker.onmessage = function (ev) {
    console.log(ev.data)
  }
}, 5000)

// worker.js
self.onmessage = function () {
  const start = performance.now()
  while (performance.now() - start < 1000) {}
  postMessage('done!')
}
複製程式碼

可以看到現在瀏覽器沒有被堵塞掉。

圖片

我們通過捕獲火焰圖,發現優化前其實長任務是主執行緒中工作,優化之後是放在 Worker 來進行的,所以我的主線依然可以處理其他的任務。

Web Worker雖然好,但是它有一個缺陷,就是它沒有辦法摸DOM。如果你想操作DOM,那麼就沒法在Worker中執行。我就是要迴圈超過100毫秒,我又想在迴圈中操作DOM,這時候怎麼辦?有一個方案叫 Time Slicing。

Time Slicing

Time Slicing就是把一個長任務給切割成無數個執行時間很短的任務。

圖片

可以看到中間使用者紅框框起來的,內部有很多黃顏色的小豎線,其實每一個都是任務,放大之後,就是圖中最下面的火焰圖,可以看到中間是有空隙的。因為中間有空隙,瀏覽器就可以在這些空隙中做其他的事,比方說佈局、樣式計算、UI事件,所有事情都可以做。

實現時間切片功能的程式碼也並不是很複雜,就是下面這段程式碼,其實核心程式碼只有三四行。程式碼雖然不多,但是可能理解起來也沒有那麼容易,我為大家簡單介紹一下。

function block () {
  ts(function* () {
    const start = performance.now()
    while (performance.now() - start < 1000) {
      console.log(11)
      yield
    }
    console.log('done!')
  })
}

setTimeout(block, 5000)

function ts (gen) {
  if (typeof gen === 'function') gen = gen()
  if (!gen || typeof gen.next !== 'function') return

  (function next () {
    const res = gen.next()
    if (res.done) return
    setTimeout(next)
  })()
}
複製程式碼

這些程式碼首先有兩個點,第一個點就是我利用 yield 關鍵字,讓函式暫停執行,大家都知道在Generator函式中有一個 yield 關鍵字,這個關鍵字可以讓函式暫停執行,這是很關鍵的特性。我利用的另一個特性就是 setTimeout 的能力,它可以將任務丟到巨集任務佇列裡面排隊讓我的任務恢復執行,所以我結合這兩個特性,用這個程式碼就可以實現Time Slicing的功能。

程式碼中我下面這個ts函式其實是我封裝的工具函式,我上面其實是我的案例。案例中我這個迴圈其實正常來說是同步的,迴圈時會把我的瀏覽器卡死一秒鐘,但是我在裡面加了一個 yield 關鍵字。所以每次執行都會停一下,停止這一瞬間,其實就是把瀏覽器的主執行緒給讓出來,或者說叫釋放出來了,如果不停的執行,在這一秒鐘內瀏覽器幹不了別的事,現在我的這個任務執行了一會就停了,瀏覽器就可以去執行別的任務。然後我在後面的巨集任務中再讓我這個任務恢復執行。這個程式碼可能不是那麼好理解,可以自己回去慢慢研究。

(關於Time Slicing後來我寫了一篇文章進行了更詳細與全面的介紹,文章地址:github.com/berwin/Blog…

我這裡有一個例子(觀看文章的同學可以通過線上PPT來檢視視訊,地址:ppt.baomitu.com/d/b267a4a3#…),我們會看到瀏覽器並沒有卡死,通過捕獲出的火焰圖可以看到每個被切割的小任務中間有很多空隙。

保證被動互動讓使用者感覺流暢

現在我們聊下一個話題,保證被動互動讓使用者感覺流暢

前面我們講,若想保證被動互動讓使用者感覺流暢,我們需要保證每16.7毫秒傳輸新的一幀到螢幕上,所以我們這個標題應該改成 如何保障動畫每16.7毫秒傳輸新的一幀到螢幕上

這張圖是前面我們講的管道,這個只是圖變了一下,若想保證每16.7毫秒傳輸新的一幀到螢幕上,我們需要保障這個畫素管道的總時間在16.7毫秒之內。

圖片

所以為了保障這個總時間在16.7毫秒之內,我們首先需要保障的事情就是JavaScript的執行時間一定要小於10毫秒,因為瀏覽器去執行渲染也是有時間消耗的,所以我們應該給瀏覽器預留出來6.7毫秒。

但其實畫素管道的每一步,都有可能導致總時間超過16.7毫秒,所以只是保障JavaScript執行時間小於10毫秒是不夠的。我們要針對每一步進行更細緻的優化,來保證總時間小於16.7毫秒。

更快的樣式計算

我們先討論樣式計算,關於樣式計算有一個重要的話題是選擇器匹配。

選擇器匹配

圖片

我們這裡有兩個選擇器,其實選擇的是同一個元素,但其實在瀏覽器裡,處理選擇器匹配的時候,時間是不一樣的,下面更簡單的選擇器速度更快一點。我在Chrome文件中看到他們說計算某元素的樣式時,有50%的時間是用於選擇器匹配。

通常如果只是用選擇器匹配了一個元素或很少的元素,那麼再複雜的選擇器,時間上也沒有什麼太多的影響。但是當選擇器匹配到的元素越多的時候,選擇器之間的效能差異就體現出來了。

圖片

下面有三個圈,和三個選擇器,我們可以看到第一個選擇器是稍微複雜一點的,第二個選擇器就是普通的選擇器,第三個選擇器也比較複雜。我點選這個按紐看三個選擇器的執行時間是多少。

圖片

可以看到第一個是1.28毫秒,第二個是0.5毫秒,第三個是4.9毫秒,結果雖然在數量上沒差太多,但是第三個比第二個慢了9.8倍。

所以我們會發現選擇器越簡單速度越快,其實這個差距在元素越來越多的情況下,它就會越來越嚴重,但通常絕大部分的專案其實並沒有那麼多的元素,所以這個問題也沒有暴露的這麼明顯,瞭解一下就可以了。

佈局抖動

第二個問題是佈局抖動,它是新手寫程式碼最容易出現的問題,一不小心就犯錯了。

我們還是回到畫素管道,其實畫素管道的每一步都是非同步的,js改了樣式,其實它是非同步的去計算樣式,佈局,繪製,圖層合併,每一步都是非同步的。

但是有時候一不小心就會出現一個詞叫做強制同步佈局,通過這個名就知道,這個佈局變成了同步的佈局。

圖片

瀏覽器本應是非同步的去執行佈局操作,但現在卻跑到了JS裡面去同步的執行了。為什麼會導致強制同步佈局呢?我們來看一段程式碼。

圖片

第一行程式碼是設定一個元素的寬度,第二行程式碼是獲取元素的寬度,仔細思考一下會發現第一行程式碼設定了元素的寬,但其實佈局操作是非同步的,所以我執行第二行程式碼的時候,瀏覽器沒有還沒有進行佈局。因為我第二行程式碼是想獲取這個元素的寬,但是這時候瀏覽器還沒有佈局,那麼瀏覽器為了回答我這個問題(寬度是多少),它必須要在此時此刻做一次佈局,這個時候這個佈局是同步的。

圖片

我們將火焰圖捕獲出來也驗證了這一點,佈局在我們這個js的裡面執行,因為JS裡面執行了佈局所以把JS的執行時間拉長了。這樣是不對的,解決方案很簡單,只是調換一下順序,我如果先獲取一個元素出來,其實獲取的是上次佈局的寬度,我並沒有改變佈局,所以直接讀就可以了,我第二行程式碼才會改寬度,然後再非同步觸釋出局,這樣捕獲出來的火焰圖佈局就跑到JS後面去了。

圖片

圖片

但是通常如果只是這個案例(Demo),其實很簡單,你這個再怎麼寫,也不會有什麼問題,因為影響就是很小,但是如果這個問題發生在迴圈裡面,你的元素很多的情況下,這個問題就被放大。

圖片

這個案例(Demo)也比較簡單,程式碼右邊有很多DIV,粉紅色的框是這些DIV的父容器,可以看到父容器比這些DIV窄,當我點選“走你~”按鈕時,讓所有子元素的寬度等於父元素的寬度。(觀看文章的同學可以通過線上PPT來操作DEMO,地址:ppt.baomitu.com/d/b267a4a3#…

通過這個案例(Demo)我們會看到當我點選按鈕時,延遲了一會,子元素的寬度才縮小。這是為什麼呢?

仔細觀察這段程式碼,我們會發現,迴圈中的這行程式碼,其實是兩個操作,一個是讀取元素的寬度,另一個操作是設定元素的寬度。因為它是在迴圈裡面執行,所以會導致一個現象,每次迴圈到讀取元素寬度時,都會觸發一次佈局操作。

圖片

我們來看這張圖,當執行 container.offsetWidth 時瀏覽器由於不知道元素的寬度是多少,但我現在馬上就要知道這個元素的寬度是多少,所以這個佈局不能非同步,那麼為了告訴我這個元素有多寬,必須馬上執行一次同步的佈局操作,而隨後的程式碼中又設定了元素的寬度,這其實就是要把剛剛執行的佈局給否定掉,讓佈局失效。當下一輪迴圈又執行到 container.offsetWidth 讀取元素的寬時,由於剛剛執行了設定元素的寬,所以瀏覽器又不知道當前元素的寬度是多少,所以它又要做一次強制同步佈局。所以瀏覽器在不停的佈局,讓佈局失效,佈局,讓佈局失效直到迴圈結束。

我們將火焰圖捕獲出來之後,我們會在下面看到一排密密麻麻很多個任務。

圖片

放大之後是下面這張圖,我們可以看到這些任務全是樣式計算和佈局。這個問題嚴重就嚴重在,同一個頁面內,兩個沒有任何關聯的元素之間,也會存在這個問題,比如說我的logo改了寬,我再讀取其他不相干的元素的寬,兩個元素沒有任何關係,但是也會有這個影響,只要他們在同一個文件內,所以有時候我們一不小心就會犯錯。

解決方案比較簡單,就是我把會觸釋出局的操作踢出去,踢到迴圈的外面,這時候只讀一次寬度,並且由於之前並沒有改變任何元素的幾何屬性,所以瀏覽器不需要做同步的佈局,直接使用之前佈局的結果就可以,然後用迴圈只設定子元素的寬度,就會避免剛才的問題。同樣的案例(Demo),只是改了這一行程式碼,我們點選按鈕看一下效果(觀看文章的同學可以通過線上PPT來操作DEMO,地址:ppt.baomitu.com/d/b267a4a3#…),已經看不到任何的延遲了。

圖片

圖片

最終我們捕獲出的火焰圖就比較正常,就是一個常規的管道應該有的樣子,我們先用 js 來觸發樣式計算,然後瀏覽器再去佈局,再執行綠色的Paint和圖層合併,每一步都是非同步的。

繪製與合成

圖片

下一個話題是繪製與合成,你會發現前面我們講的,就是 JavaScript 和樣式計算,還有佈局都是單獨講的,但是繪製與合成我們放在一起講,等下我們再講為什麼。

合成

圖片

我們先講什麼是合成,所謂合成就是瀏覽器和PhotoShop一樣,都有圖層的概念,可以看到我這張圖最左側有三個圖層,我們從側面觀察這個圖層,你會發現眼睛在上面,鼻子在中間,最下面是臉,其實是三個圖層是疊加在一起的,這三個圖層合併成一張圖之後,就是我們最右邊的這張圖,就是一個人的臉。

圖層有一個最大的特點就是如果圖層的位置變了,瀏覽器只需要重新去合成,就可以得到一張新的圖。注意,如果圖層的位置變了,但是圖層的內容沒變,那麼瀏覽器只需要重新合併圖層,就可以得到一張新的圖,這個過程是不需要繪製(Paint)的。

繪製(Paint)

圖片

我們在說說繪製的意思。圖中白色的框是一個圖層,這個框裡面有一個黃色的方框;右邊的與左邊的是同一張圖層,但是右邊這個圖層裡面的黃色方塊跑右邊去了。注意,我同一張圖層,但是內容變了,這時候瀏覽器要做一個事情就是“繪製”,通過重新繪製圖層,才能讓圖層裡面的內容發生變化。可以理解為,你有一個畫板,你想把方框移到右面,那隻能把之前的擦掉然後重新在右面畫一個上去。

新增圖層可以取消Paint

所以你發現繪製產生的效果和圖層合併產生的效果是一樣的,我通過改變圖層的位置能實現和我重新繪製的效果是一樣的。

實際上我想說明什麼?我想告訴大家告訴大家新增圖層可以取消Paint。

圖片

我們都知道畫素管道有五步,JavaScript->樣式計算->佈局->繪製->合成,但是通過新增圖層可以取消繪製這步,五步變成四步,那其實這個時間要更簡短一些。

圖片

可以看到這個圖,主要看右邊的圖,就是圖層這個位置,這張圖的圖層在不停的變,瀏覽器通過合併圖層就可以實現方框移動的效果。這個過程不需要繪製的,你用這個火焰圖捕獲也是捕獲不到繪製的。

如何建立圖層?

圖層這麼好,如何建立圖層?

我們可以使用CSS的will-change來建立圖層,在will-change不相容的情況下,你可以用 transform: translateZ(0);來代替。

你會發現圖層這東西這麼好,可以把畫素管道從五步變成四步,我們是不是可以這樣操作,所有元素都設定will-change,瀏覽器是不是就沒有繪製了?

圖片

這其實是不行的,因為瀏覽器做圖層管理也是需要消耗的,如果你這樣做,其實帶來的效果反而是負面的,所以這個是不推薦的。

避免丟幀

現在我們從 JavaScript 到圖層合併,我們通過一系列的手段已經可以保證每一幀的畫素管道總時間在 16.7 毫秒以內,那麼就可以保證每 16.7 毫秒給螢幕傳輸新的一幀嗎?

還不夠。

圖中這是一個時間軸,每個時間節點之間的間隔是 16 毫秒,我們通常會使用Timer觸發一個函式改變一些樣式,從而實現視覺的效果。

圖片

圖片

你會發現中間有一個16毫秒沒有輸出的,這 16 毫秒丟幀了,這一幀在螢幕上並沒有傳輸任何影像,因為我這個Timer不能保證函式在每一幀最開始執行,保證不了函式的執行頻率,所以就會導致這個問題。

圖片

現在整個Web平臺,只有一個API可以解決這個問題,可以讓我們的函式在每一幀最開始執行。這個API叫做requestAnimationFrame,使用它觸發函式可以保證函式在每一幀的最開始執行,同時只有我們保證函式總體時間在 16.7 毫秒以內,現在就可以下圖的效果,我第一幀、第二幀、第三幀、第四幀很均勻,從時間軸上也看不到丟幀的現象存在。現在我們終於可以保證不丟幀的情況下達到 60 FPS。

圖片

總結

圖片

最後做一個總結,首先我們講了什麼樣的網頁是使用者覺得比較流暢的,我們講的第二個概念叫畫素管道,通過後面的介紹,你會發現畫素管道還是很重要的。

然後我們講了優化主動互動,有兩種方案,一個是web-worker,還有一個是 time-slicing。

我們還介紹瞭如何優化被動互動,保證 JS 執行時間 10 毫秒以為,樣式計算(選擇器)與效能,佈局抖動以及如何避免佈局抖動,做好圖層管理和繪製的權衡,和requestAnimationFrame。

謝謝大家。

相關文章