讓你的網頁更絲滑(全)

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

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

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

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

讓你的網頁更絲滑

簡介

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

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

自我介紹

消息比較靈通的應該據說過我在上個月出版了一本講Vue的書,叫作《深刻淺出Vue.js》git

圖片

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

什麼樣的網頁是流暢的

在討論如何讓網頁更流暢以前,須要先思考一個問題就是什麼樣的網頁是流暢的?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。

謝謝你們。

相關文章
相關標籤/搜索