瀏覽器渲染優化

保證網頁應用擁有很高的流暢度是相當重要的,即便只有細微的卡頓,用戶也是能夠感知到並對應用留下負面的印象。假如卡頓很是嚴重,那麼用戶頗有可能會放棄這款應用而尋找其餘的選擇,這對於辛苦工做已久的整個團隊都是很是大的災難。
css

渲染流程

簡單來講,頁面的渲染包含如下這些步驟。 當頁面文檔抵達瀏覽器時,瀏覽器會對文檔上的元素進行解析,組成DOM樹。在ChromeDevTools中,這個過程被稱爲Parse HTML。 接下來DOM會與CSS樣式相結合,成爲渲染樹。在DevTools中,這個過程被稱爲Recalculate Styles。渲染樹和DOM樹結構很相似,但又不徹底相同。不會被渲染的元素,好比<head>和被設置爲display: none的元素,就不會存在與渲染樹中。而DOM中不存在的一些元素,例如僞元素,卻會被加入到渲染樹中。html



知道頁面中的元素和CSS的對應關係後,瀏覽器開始計算每個元素會佔用多少空間,以及位於屏幕中的什麼位置。這一過程被稱爲Layout,有時也會被稱爲Reflow。因爲元素之間的佈局是彼此影響的,因此該過程可能會很是複雜。git

元素的佈局肯定後,瀏覽器使用光柵器(rasterizer)計算出元素怎樣以像素爲單位進行展現,這一過程在DevTools中被叫作Paint。對於圖片類型的元素,瀏覽器還須要將圖片文件解碼到內存中以便顯示,這個過程叫作Image Decode,有的時候還會須要對其進行尺寸上的調整,也就是Resizegithub

與在Photoshop等圖像處理軟件中相似,瀏覽器有時也會將界面分爲多個圖層,從而免除各圖層之間的相互影響。每一個圖層中的內容被繪製完畢後,瀏覽器須要根據圖層之間的位置關係,將他們進行整合,這個過程叫作Composite Layersweb



到此爲止,一個頁面文檔上的全部內容已經完成了渲染,用戶能夠看見網頁究竟長成什麼樣子。但對於咱們來講,這僅僅是個開始。

渲染管道

網頁的樣式並不是是一成不變的。針對用戶的操做,頁面必須及時給出合理的相應,這意味着頁面顯示的內容會頻繁地發生變化。chrome

一般來講,頁面的變化是由js觸發的。在js代碼中,會存有對瀏覽器各類事件的監聽以及相應的回調函數,以便對用戶的各類操做進行響應。若是這些回調函數中含有對頁面佈局進行改動的地方,那麼接下來就會觸發瀏覽器對頁面樣式進行從新計算,以後應用更新的樣式進行佈局,在以後對圖層進行繪製,並將它們整合在一塊兒。整個過程能夠視爲一個管道,左側的事件會致使右側所有或部分的事件依次發生。瀏覽器



這基本就是每一幀在顯示以前要經歷的所有事情,但每次之間又會有一些不同。假如說js修改了某些元素的幾何結構或是位置,這將觸發管道中每個階段的執行:重進計算網頁上各個元素的佈局,並將受影響的區域進行從新繪製,再將各個圖層整合。可是若是你僅僅更改了相似於背景圖片、顏色這種與佈局無關的樣式,那麼從新計算佈局的步驟就能夠省略。在更理想的狀況下,咱們對頁面上的圖層進行了很是合理的劃分,只對某一圖層進行位移(利用`transform`)或透明度的變化,那麼佈局和繪製兩個階段均可以被省略掉,這將對提升渲染速度很是有幫助。

因此總的來講,以什麼方式進行頁面進行修改是相當重要的,修改不一樣的樣式,瀏覽器的工做量也是大不相同的。開發者能夠參考這一網站,來了解本身的操做對於瀏覽器來講都意味着要作哪些工做。bash


LIAR!

若是用一個詞來描述網頁應用的整我的生(app生),那麼最恰當的應該就是LIAR。這倒不是由於它們善於欺騙用戶,而是由於這四個字母表明的四個詞彙(load-載入、idle-閒置、animations-動畫、response-響應),能很好地總結一款應用的生命週期。實際上,這一輩子命週期模型更著名的名稱是RAIL網絡



`Load`就是頁面的加載。對於時下最爲主流的SPA來講,這個過程基本只會在剛剛打開網頁應用時發生一次(若是實現了按需加載,切換頁面時仍須要必定時間加載頁面資源,但相比傳統MPA來講過程會快不少)。最理想的狀況下,頁面能在1秒以內完成加載。但考慮到具體的網絡情況,以及頁面自己的複雜程度,通常來講1.5秒內完成加載對於用戶來講已是能夠接受的時間。

Response表明對用戶操做的響應。及時、準確的響應對於提升用戶體驗來講相當重要。通常來講,假如在用戶操做100ms後才提供響應,那麼會有被察覺到的輕微延時。而響應須要的時間越長,對於用戶體驗的破壞程度就越大。app

Idle表明閒置時間。爲了知足更快的加載和相應時間,有些不那麼重要的任務須要被延後執行,而用戶的閒置時間就是絕佳的機會。頁面剛剛加載完以後,用戶每每會花必定的時間在當前位置進行瀏覽(或者僅僅是沒反應過來...),這給了咱們加載一些次要資源的時間。但這個過程也不能太長,由於咱們還要保證對於用戶操做能夠及時地進行響應(100ms內),因此最好將閒置時間內處理的內容劃分在50ms內能夠完成的片斷內,以便在用戶操做時能夠及時響應。

Animation就是動畫了。目前大部分屏幕設備的刷新頻率都在60幀每秒左右,那麼在這種狀況下,達到60fps(frames per second)的頁面刷新頻率就是咱們的終極目標,這將讓用戶徹底相信頁面上的變化沒有任何不流暢的地方。通過一個很是簡單的計算,咱們知道1秒鐘顯示60幀意味着留給每一幀的渲染時間大約只有16.7毫秒。而假如將瀏覽器的處理時間考慮在內的話,其實每一幀留給你的時間只有10到12毫秒。在某些狀況下,這可能會成爲一項比較艱鉅的任務。爲了實現這一點,有時咱們須要充分利用發出響應前容許的100ms延時,完成一些預處理工做。


FLIP

FLIP是由任職於Google的Paul Lewis(事實上這篇文章的不少內容都是引用了他的著做)提出的一種動畫實現準則,能幫助動畫更容易地達到60fps的流暢度。

FLIPFistLastInvertPlay的縮寫,分別表明動畫的開始狀態、最終狀態、翻轉,以及播放。總的來講,FLIP就是要求你先計算出動畫中元素的起止狀態,而後把元素直接放置在最終位置上,經過一段「反向」的transition動畫,把元素從起始狀態轉化到最終狀態。

下面是一個示例,用來講明FLIP具體是如何實現的。線上的DEMO能夠點擊這裏查看。

const el = document.getElementById('el')

// F: First
// 獲取元素最初的狀態
const positionAtFirst = el.getBoundingClientRect()
const opacityAtFirst = document.defaultView.getComputedStyle(el).opacity
		 
// L: Last
// 獲取元素最終的狀態
el.classList.add('end')
const positionAtLast = el.getBoundingClientRect()
const opacityAtLast = document.defaultView.getComputedStyle(el).opacity
		 
// I: Invert
// 讓元素反轉回最初狀態
const invertTop = positionAtFirst.top - positionAtLast.top
const invertLeft = positionAtFirst.left - positionAtLast.left
el.style.transform = `translate(${invertTop}px, ${invertLeft}px)`
el.style.opacity = opacityAtFirst
		 
// P: Play
// 等待樣式生效,在下一幀再開始過渡動畫,不然瀏覽器將忽略樣式的更改,動畫會沒法顯示
requestAnimationFrame(function() {
  el.style.transition = 'all 2s'
  // 清除反轉的位移,從而回到最終狀態
  el.style.transform = ''
  el.style.opacity = opacityAtLast
})
		 
// 當一切結束後,就能夠移除動畫相關的CSS屬性
el.addEventListener('transitionend', () => {
  el.style.transition = ''
})
複製代碼

FLIP能實現使動畫更爲流程的緣由是,它將一些代價較高的計算安排在了動畫開始前執行。由於從操做到瀏覽器給出反饋前,用戶是能夠接受必定時間的延時的(好比100ms),這會是一個很好的進行復雜計算的時機。當計算完成後,使用CSS提供的transition功能,能以代價很是小的方式完成動畫(對位移進行transform,以及改變opacity,都只會從新進行composition,而不會觸發layout以及paint,這將節省至關大的工做量)。這樣就保證了動畫一旦開始,就能很是流暢地執行下去。


Taking advantage of user perception.


充分利用開發者工具

若是遇到了渲染相關的問題,並想進行細緻的分析的話,Chrome的開發者工具(DevTools)會是一個得力的幫手。其中的Performance功能,能詳細地展現網頁渲染過程當中的每個細節,從而給想進行優化的開發者提供頗有價值的線索。

以剛纔的demo爲例(由於它很簡單,從而查看起來會更加清晰)。在開發者工具的Performance一欄中,點擊左上角的錄製按鈕,刷新頁面,再點擊Stop按鈕結束錄製,就能看見從頁面開始加載到動畫完成的所有瀏覽器工做細節。



圖表上方是一些總覽信息,包括FPS、CPU和網絡狀況。能夠看到FPS一欄在動畫全程都保持了很高的水平,說明咱們的目的達到了(High five!)。而若是這一欄上方出現了醒目的紅線,說明畫面的卡頓程度極可能到了影響用戶體驗的程度,須要開發者進行適當的優化。

緊貼着是頁面的快照,能夠在這裏看到有一排快照直觀地顯示了頁面的變化過程。假如沒有發現這一欄,須要用戶在頂部勾選Screenshots功能。

下面的圖表是要着重分析的部分,它完整地展現了瀏覽器在什麼時間都進行了什麼工做。由於真實的場景下,瀏覽器進行的任務會是很是密集的,這時你能夠點擊W鍵(或滑動滾輪)放大其中某一部分。圖中顯示的是js中動畫開始的過程,瀏覽器的主線程(圖表中的Main部分)開始了一次Animation Frame Fired事件,這意味着requestAnimationFrame方法開始執行。它下方的Function Call就是做爲參數傳遞的回調函數,點擊這個矩形,在下方的Summary欄中能夠看到這個函數的具體信息,包括函數名(本例中爲匿名函數),在代碼中的位置,和函數執行的時間(包括總時間和自身執行時間)。

就像上面提到的管道圖中所展現的同樣,js執行完以後,每每會跟着從新計算樣式、更新佈局、重繪、以及合併圖層,這些流程均可以在圖表中準確地找到對應的執行位置和時間。經過這些圖表中的信息,咱們能夠很容易地判別瀏覽器是在哪個步驟耗費了過多的時間從而致使頁面的卡頓。

Frames一欄中記錄了每一幀的快照,和渲染花費的時間。如以前所討論過的,咱們要盡力保證每一幀的渲染時間都接近16.7ms,但因爲咱們選擇在提早進行一些複雜的計算,從而保證後面的動畫能夠流暢進行,因此目前顯示的時間還是在咱們控制範圍內的。

若是你勾選了頂部的Memory功能,還能看到內存使用量跟隨時間的變化狀況,這將幫助你更好地偵測到內存溢出等異常現象。

除了上面介紹的以外,DevTools還有不少其它強大的功能,咱們將在後面具體的實例用進行說明。


看好你的JS代碼

現代的js編譯器會從新編譯咱們的代碼,從而使代碼的運行速度更快,這一過程是經過即時編譯器完成的(Just In Time compiler),它很是龐大而複雜,因此通常的開發者基本沒法猜想本身的代碼會被編譯成什麼樣子。既然如此,咱們不如放棄一些所謂的微優化(由於他極可能不會按咱們的預期產生理想的效果),把時間花在一些其餘能提升頁面渲染性能的措施上。

從js的執行時機入手多是個比較好的辦法。在某些狀況下,瀏覽器可能正在處理着一些有關樣式的工做,但此時出現了一段js代碼須要被執行,因而瀏覽器開始執行這段代碼。但這段代碼改變了一些頁面的樣式,因而瀏覽器以前的工做白作了!它必須重來一遍以前關於樣式的工做。若是這是一個脾氣很差的瀏覽器,那麼這極可能讓它氣得丟了一些幀來報復你。

想要瀏覽器更有效率地工做,避免這一類的返工,咱們須要更好地安排本身的js代碼而不是常常去給瀏覽器添亂,此時requestAnimationFrame可能會是一個好的選擇。


requestAnimationFrame

window.requestAnimationFrame方法告訴瀏覽器您但願執行動畫,並請求瀏覽器調用指定的函數在下一次重繪以前更新動畫。該方法使用一個回調函數做爲參數,這個回調函數會在瀏覽器重繪以前調用。

使用requestAnimationFrame的好處是,它會安排js代碼儘可能在每一幀的開始時進行,以後纔會繼續處理跟樣是有關的後續工做。這就避免了不知何時出現的js任務致使的返工,從而保證了動畫能更流暢地展現。

下面是一個數字增長的動畫效果的實現。

// 動畫的起始時間
let startTime = null

// 增長顯示數額
const increase = (timeStamp) => {
  // 第一次執行時,將執行時間設爲起始時間
  if (!startTime) {
    startTime = timeStamp
  }

  // 計算這一幀距離起始時間的時間差
  const timeOffset = timeStamp - startTime

  // 若是時間差在兩秒內,那麼執行動畫
  if (timeOffset < 2000) {
    // 根據時間差計算當前應該增加到多少百分比。開方是爲了實現ease-out的效果(想一想它的曲線圖)
    const percent = Math.pow((timeOffset / 2000), 0.5)
    this.setShownAmount(percent)
    // 開始下一幀的計算
    requestAnimationFrame(increase)
  } else {
    // 若是動畫結束,將數額設置回初始值,防止在最後一幀中出現精度誤差
    this.setShownAmount()
  }
}

// 開始咱們的動畫
requestAnimationFrame(increase)
複製代碼

能夠看到咱們並無像使用setTimeoutsetInterval同樣指定每一幀的間隔時間,這也是使用requestAnimationFrame的另一個好處,你不須要去管究竟要多久來展現一個幀,瀏覽器會盡力作到最好。


requestAnimationFrame動畫示例


僱傭一個Web Worker

以前的狀況都是比較理想的,js能在很是短的時間內完成,能夠經過合理地安排執行時間從而保證動畫的流暢執行。但若是遇到了一些須要花費很是久才能夠完成的任務,那麼不管把它安排到哪裏(鑑於js是單線程執行的),它都會毫無疑問地阻塞頁面的動畫。

此時也許能夠考慮幫咱們的主線程找一個幫手,Web Workers是個物美價廉的選擇(僱傭他們是免費的!)。

Web Workers能讓你在一個徹底獨立的上下文中,在一個獨立的線程中執行js代碼,與主線程互不影響。因而你能夠開啓一個Web Worker,並將一些耗時很長的任務交給它,等它執行完的時候,再利用它的執行結果進行下一步的操做,從而避免了主線程上的阻塞。

Web Workers的使用很是簡單,你只要在須要的時候建立好它,並在它和主線程的代碼內部各自作好數據的監聽和傳遞便可。

在主線程內:

// 經過文件來建立一個Web Worker
const myWorker = new Worker('./worker.js')

// 向你的免費勞工傳遞信息
myWorker.postMessage(msg)

// 作好數據監聽的工做,好在它完成任務的時候可以及時響應
myWorker.onmessage = function(e) {
  // 在這裏你能夠充分利用它的勞動成果作任何你想作的事
}
複製代碼

woker.js中:

// 隨時等候主人的調遣
this.onmessage = function(e) {
  // 主人的命令就藏在e.data中
  const msg = e.data

  // 不辭辛苦,勤勤懇懇...

  // 告訴主人個人工做作完了
  postMessage(result)
}
複製代碼

怎麼樣,使喚別人的感受是否是特別的舒暢呢?


不要強迫瀏覽器

有時咱們無心中就會強迫瀏覽器作了一些它不肯意作的事,既然是不情願的,過程有時也就不會很順暢。

下面是一段將全部段落的寬度改成基準寬度的一段代碼,它向你展現瞭如何強迫瀏覽器作它不情願的事情。

const ps = document.querySelectorAll('.paragraph')
const benchmark = document.querySelector('.benchmark')
let i = ps.length

// 若是你想和你的瀏覽器搞好關係 你最好這麼寫
size = benchmark.offsetWidth
while (i--) {
  ps[i].style.width = size + 'px'
}

// 不然你能夠強迫瀏覽器這麼作
while (i--) {
  ps[i].style.width = benchmark.offsetWidth + 'px'
}
複製代碼

若是咱們選擇下面這種方式,並且受影響的元素數量(i)很可觀的話,瀏覽器將會在渲染過程當中表現得很是的不情願。這是由於針對每個元素,咱們都從新獲取了一遍基準元素(benchmark)的寬度,而瀏覽器爲了得知這一數據,須要對頁面進行從新佈局。因此總的來講,咱們總共從新佈局了至少i次,但很明顯咱們並沒必要要這麼作(其實一次就夠了)。



DevTools中咱們能看見,對於一個擁有一千個元素的示例來講,從新設置他們的寬度使這一幀的渲染時間達到了560.6ms,在實際場景中這是很難被接受的。好在貼心的DevTools給出了明顯的提示,右上角標記爲紅色的部分就是Chrome認爲存在異常的部分,而且貼心地給出了提示:

Warning Forced reflow is a likely performance bottleneck.

它在提醒你代碼中存在強制同步佈局(Forced synchronous layout)。

許多獲取元素佈局信息(好比尺寸、位置)的方法,以及其餘一些方法(getComputedStyleinnerTextfocus 等)都會致使頁面從新佈局,在代碼中咱們都須要儘量地減小執行這些方法的次數,並認真考慮執行他們的時機。具體會觸發FSL的方法能夠參閱這篇文章


爲你的網頁分層

像以前所提到的,合理地利用分層能提升網頁的渲染速度。將一些只會應用transformopacity變換的元素分離到一個獨立的層中,能夠在渲染時只進行Composite的步驟從而避免瀏覽器進行額外的工做。那麼如何利用這一特性,隨時隨地把想要的元素抽離到一個圖層上呢?

你能夠利用will-change: transform,這個屬性告訴瀏覽器該元素接下來會進行transform的修改,從而提早準備好,將元素放置到一個獨立圖層中。對於一些還不支持這個屬性的瀏覽器,你能夠利用transform: translateZ(0)實現一樣的效果,它僞裝要在縱向進行3D轉換(實則沒有),使瀏覽器不得不爲它建立一個新的圖層。

合理地建立圖層有時會對頁面的渲染速度帶來很大的改善,你能夠嘗試這個來自優達學城的魔性demo來感覺到這一點。進入這個網頁後,點擊左上角的Animate按鈕,就會開始循環一段詭異的動畫。若是你對本身的機器性能不是很自信,那麼建議你立刻點擊旁邊的Isolate按鈕,它會爲在這些元素增長一個Z軸方向的位移從而爲爲其各自建立獨立圖層,以達到避免把你的瀏覽器卡到崩潰的結果...

若是想要更細緻地觀察瀏覽器都作了什麼,能夠再次打開DevTools,打開Rendering面板,勾選Paint FlashingLayer BordersPaint Flashing功能能夠幫你更清晰地查看到哪些元素正在被重繪,假如你沒有點擊,或是再次點擊Isolate按鈕,你會發現幾乎整個畫面都被綠色的區域覆蓋着,這意味着他們都在不斷地進行重繪。此時若是再次點擊Isolate按鈕,綠色的區域會消失,意味着再也不有持續的重繪發生。這些元素轉而由橙色的方框包裹,這些橙色的方框就是瀏覽器爲它們新建的圖層。



雖然這一回,分層將你的瀏覽器從崩潰的邊緣拯救了回來,但它也並不老是有效。這種辦法只適用於圖層內的元素僅會發生`transform`和`opacity`這種不會觸發重繪的樣式的變化,加入圖層內的元素須要更改一些諸如顏色、尺寸的樣式,那麼重繪和佈局仍是會被觸發,分層將不會帶來實質的效果。因此咱們應該合理地使用這一特性,在建立新圖層的代價和它帶來的收益之間作出謹慎的權衡。

若是想知道瀏覽器具體建立了多少圖層,以及這些圖層的具體信息,還能夠利用DevTools裏這個酷炫的功能。在Preformance面板中點擊具體的一幀(標記着時間的那一欄),而後選擇Layers標籤,能夠以3D的方式展現各個圖層在瀏覽器上的位置關係。下面是demo中某一幀的圖層信息,數不清的圖層組成的螺旋形柱體讓我想起了一種小時候玩過的玩具。



結語

固然還有不少其餘提升瀏覽器渲染效率的方法沒有在本文中進行討論,但瞭解以上全部的信息,應該已經能讓你在解決渲染性能問題時擁有更多的思路。 事實上這篇文章基本是對優達學城的同名課程的總結和拓展。這是一門很棒的課程,若是你時間充裕,而且更喜歡經過視頻進行學習,推薦你也完整地學習一遍。


參考和引用

相關文章
相關標籤/搜索