瀏覽器渲染優化
保證網頁應用擁有很高的流暢度是至關重要的,即使只有細微的卡頓,使用者也是可以感知到並對應用留下負面的印象。假如卡頓非常嚴重,那麼使用者很有可能會放棄這款應用而尋找其他的選擇,這對於辛苦工作已久的整個團隊都是非常大的災難。
渲染流程
簡單來說,頁面的渲染包含以下這些步驟。 當頁面文件抵達瀏覽器時,瀏覽器會對文件上的元素進行解析,組成DOM樹。在 Chrome 的 DevTools 中,這個過程被稱為 Parse HTML 。 接下來DOM會與CSS樣式相結合,成為渲染樹。在 DevTools 中,這個過程被稱為 Recalculate Styles 。渲染樹和DOM樹結構很類似,但又不完全相同。不會被渲染的元素,比如 <head>
和被設定為 display: none
的元素,就不會存在與渲染樹中。而DOM中不存在的一些元素,例如偽元素,卻會被加入到渲染樹中。

知道頁面中的元素和CSS的對應關係後,瀏覽器開始計算每一個元素會佔用多少空間,以及位於螢幕中的什麼位置。這一過程被稱為**Layout**,有時也會被稱為**Reflow**。由於元素之間的佈局是彼此影響的,所以該過程可能會非常複雜。
元素的佈局確定後,瀏覽器使用光柵器(rasterizer)計算出元素怎樣以畫素為單位進行展示,這一過程在 DevTools 中被叫做 Paint 。對於圖片型別的元素,瀏覽器還需要將圖片檔案解碼到記憶體中以便顯示,這個過程叫做 Image Decode ,有的時候還會需要對其進行尺寸上的調整,也就是 Resize 。
與在 Photoshop 等影象處理軟體中類似,瀏覽器有時也會將介面分為多個圖層,從而免除各圖層之間的相互影響。每個圖層中的內容被繪製完畢後,瀏覽器需要根據圖層之間的位置關係,將他們進行整合,這個過程叫做 Composite Layers 。

到此為止,一個頁面文件上的所有內容已經完成了渲染,使用者可以看見網頁究竟長成什麼樣子。但對於我們來說,這僅僅是個開始。
渲染管道
網頁的樣式並非是一成不變的。針對使用者的操作,頁面必須及時給出合理的相應,這意味著頁面顯示的內容會頻繁地發生變化。
通常來說,頁面的變化是由js觸發的。在js程式碼中,會存有對瀏覽器各種事件的監聽以及相應的回撥函式,以便對使用者的各種操作進行響應。如果這些回撥函式中含有對頁面佈局進行改動的地方,那麼接下來就會觸發瀏覽器對頁面樣式進行重新計算,之後應用更新的樣式進行佈局,在之後對圖層進行繪製,並將它們整合在一起。整個過程可以視為一個管道,左側的事件會導致右側全部或部分的事件依次發生。

這基本就是每一幀在顯示之前要經歷的全部事情,但每次之間又會有一些不一樣。假如說js修改了某些元素的幾何結構或是位置,這將觸發管道中每一個階段的執行:重進計算網頁上各個元素的佈局,並將受影響的區域進行重新繪製,再將各個圖層整合。但是如果你僅僅更改了類似於背景圖片、顏色這種與佈局無關的樣式,那麼重新計算佈局的步驟就可以省略。在更理想的情況下,我們對頁面上的圖層進行了非常合理的劃分,只對某一圖層進行位移(利用`transform`)或透明度的變化,那麼佈局和繪製兩個階段都可以被省略掉,這將對提高渲染速度非常有幫助。
所以總的來說,以什麼方式進行頁面進行修改是至關重要的,修改不同的樣式,瀏覽器的工作量也是大不相同的。開發者可以參考這一網站,來了解自己的操作對於瀏覽器來說都意味著要做哪些工作。
LIAR!
如果用一個詞來描述網頁應用的整個人生(app生),那麼最恰當的應該就是 LIAR 。這倒不是因為它們善於欺騙使用者,而是因為這四個字母代表的四個詞彙(load-載入、idle-閒置、animations-動畫、response-響應),能很好地總結一款應用的生命週期。實際上,這一生命週期模型更著名的名稱是 RAIL 。

`Load`就是頁面的載入。對於時下最為主流的SPA來說,這個過程基本只會在剛剛開啟網頁應用時發生一次(如果實現了按需載入,切換頁面時仍需要一定時間載入頁面資源,但相比傳統MPA來說過程會快很多)。最理想的情況下,頁面能在1秒之內完成載入。但考慮到具體的網路狀況,以及頁面本身的複雜程度,一般來說1.5秒內完成載入對於使用者來說已經是可以接受的時間。
Response
代表對使用者操作的響應。及時、準確的響應對於提高使用者體驗來說至關重要。一般來說,假如在使用者操作100ms後才提供響應,那麼會有被察覺到的輕微延時。而響應需要的時間越長,對於使用者體驗的破壞程度就越大。
Idle
代表閒置時間。為了滿足更快的載入和相應時間,有些不那麼重要的任務需要被延後執行,而使用者的閒置時間就是絕佳的機會。頁面剛剛載入完之後,使用者往往會花一定的時間在當前位置進行瀏覽(或者僅僅是沒反應過來...),這給了我們載入一些次要資源的時間。但這個過程也不能太長,因為我們還要保證對於使用者操作可以及時地進行響應(100ms內),所以最好將閒置時間內處理的內容劃分在50ms內可以完成的片段內,以便在使用者操作時可以及時響應。
Animation
就是動畫了。目前大部分螢幕裝置的重新整理頻率都在60幀每秒左右,那麼在這種情況下,達到60fps(frames per second)的頁面重新整理頻率就是我們的終極目標,這將讓使用者完全相信頁面上的變化沒有任何不流暢的地方。經過一個非常簡單的計算,我們知道1秒鐘顯示60幀意味著留給每一幀的渲染時間大約只有16.7毫秒。而假如將瀏覽器的處理時間考慮在內的話,其實每一幀留給你的時間只有10到12毫秒。在某些情況下,這可能會成為一項比較艱鉅的任務。為了實現這一點,有時我們需要充分利用發出響應前允許的100ms延時,完成一些預處理工作。
FLIP
FLIP
是由任職於Google的Paul Lewis(事實上這篇文章的很多內容都是引用了他的著作)提出的一種動畫實現準則,能幫助動畫更容易地達到60fps的流暢度。
FLIP
是 Fist
、 Last
、 Invert
和 Play
的縮寫,分別代表動畫的開始狀態、最終狀態、翻轉,以及播放。總的來說, 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 ,這將節省相當大的工作量)。這樣就保證了動畫一旦開始,就能非常流暢地執行下去。

充分利用開發者工具
如果遇到了渲染相關的問題,並想進行細緻的分析的話, 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) 複製程式碼
可以看到我們並沒有像使用 setTimeout
或 setInterval
一樣指定每一幀的間隔時間,這也是使用 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)。
許多獲取元素佈局資訊(比如尺寸、位置)的方法,以及其他一些方法( getComputedStyle
, innerText
, focus
等)都會導致頁面重新佈局,在程式碼中我們都需要儘可能地減少執行這些方法的次數,並認真考慮執行他們的時機。具體會觸發FSL的方法可以參閱這篇文章。
為你的網頁分層
像之前所提到的,合理地利用分層能提高網頁的渲染速度。將一些只會應用 transform
和 opacity
變換的元素分離到一個獨立的層中,可以在渲染時只進行 Composite 的步驟從而避免瀏覽器進行額外的工作。那麼如何利用這一特性,隨時隨地把想要的元素抽離到一個圖層上呢?
你可以利用 will-change: transform
,這個屬性告訴瀏覽器該元素接下來會進行transform的修改,從而提前準備好,將元素放置到一個獨立圖層中。對於一些還不支援這個屬性的瀏覽器,你可以利用 transform: translateZ(0)
實現同樣的效果,它假裝要在縱向進行3D轉換(實則沒有),使瀏覽器不得不為它建立一個新的圖層。
合理地建立圖層有時會對頁面的渲染速度帶來很大的改善,你可以嘗試這個來自優達學城的魔性demo來感受到這一點。進入這個網頁後,點選左上角的 Animate
按鈕,就會開始迴圈一段詭異的動畫。如果你對自己的機器效能不是很自信,那麼建議你馬上點選旁邊的 Isolate
按鈕,它會為在這些元素增加一個Z軸方向的位移從而為為其各自建立獨立圖層,以達到避免把你的瀏覽器卡到崩潰的結果...
如果想要更細緻地觀察瀏覽器都做了什麼,可以再次開啟 DevTools ,開啟 Rendering 面板,勾選 Paint Flashing 和 Layer Borders 。 Paint Flashing 功能可以幫你更清晰地檢視到哪些元素正在被重繪,假如你沒有點選,或是再次點選 Isolate 按鈕,你會發現幾乎整個畫面都被綠色的區域覆蓋著,這意味著他們都在不斷地進行重繪。此時如果再次點選 Isolate 按鈕,綠色的區域會消失,意味著不再有持續的重繪發生。這些元素轉而由橙色的方框包裹,這些橙色的方框就是瀏覽器為它們新建的圖層。

雖然這一回,分層將你的瀏覽器從崩潰的邊緣拯救了回來,但它也並不總是有效。這種辦法只適用於圖層內的元素僅會發生`transform`和`opacity`這種不會觸發重繪的樣式的變化,加入圖層內的元素需要更改一些諸如顏色、尺寸的樣式,那麼重繪和佈局還是會被觸發,分層將不會帶來實質的效果。所以我們應該合理地使用這一特性,在建立新圖層的代價和它帶來的收益之間做出謹慎的權衡。
如果想知道瀏覽器具體建立了多少圖層,以及這些圖層的具體資訊,還可以利用 DevTools 裡這個酷炫的功能。在 Preformance 面板中點選具體的一幀(標記著時間的那一欄),然後選擇 Layers 標籤,可以以3D的方式展示各個圖層在瀏覽器上的位置關係。下面是demo中某一幀的圖層資訊,數不清的圖層組成的螺旋形柱體讓我想起了一種小時候玩過的玩具。
