——從Webkit內部渲染機制出發,談網站渲染效能優化
本文是對前文:imweb.io/topic/5b6fd… 相關知識的補充,文中的“前文”一詞同此。
特以此文向《WebKit技術內幕》作者朱永盛前輩致敬。
0.引言
自上次釋出了《網站效能優化實戰——從12.67s到1.06s的故事》一文後,發現自己對頁面渲染效能這個版塊介紹的內容還不夠完善,為了更清晰的梳理瀏覽器渲染頁面的機制,以讓讀者更為全面瞭解渲染效能優化的深層次原理,筆者在課餘時間重新研讀了一遍《WebKit技術內幕》一書,將自己的總結經驗分享予論壇同僚。文章更新可能之後不會實時同步在論壇上,歡迎大家關注我的Github,我會把最新的文章更新在對應的專案裡,讓我們一起在程式碼的海洋裡策馬奔騰:github.com/jerryOnlyZR… 。
讓我們用自己的雙手,創造出極致的頁面渲染效能。
因為本文是基於前文的基礎上擴充了相關內容,所以可能會有部分文字重複,希望大家不要介意。
1.瀏覽器核心
還是獻上前文的那張瀏覽器渲染引擎、HTML直譯器、JS直譯器關係圖:
我們平時開啟瀏覽器所看到的介面,就是圖裡的User Interface,我們常說的瀏覽器核心,指的就是我們的渲染引擎——Rendering engine,最著名的還屬Chrome的前任、Safari的搭檔WebKit,我們使用的大多數移動端產品(Android、IOS 等等)都是使用的它,也就是說我們可以在手機上實現我們的CSS動畫、JS互動等等效果,這也是我們的前端開發人員能夠開發出Web和Hybrid APP的原因,包括現在的Blink,其實也應該算是Webkit的一個變種,它是從WebKit衍生來的,但是Google在和WebKit分手後便在Blink裡使用了聲名遠播的V8引擎,打出了一場漂亮的翻身戰。還有IE的Trident,火狐的Gecko瀏覽器核心,平時我們需要為部分CSS樣式新增相容性字首,正是因為不同的瀏覽器使用了不同的渲染引擎,產生了不同的渲染機制。
渲染引擎內包括了我們的HTML直譯器,CSS樣式直譯器和JS直譯器,不過現在我們會常常聽到人們說V8引擎,我們經常接觸的Node.js也是用的它,這是因為JS的作用越來越重要,工作越來越繁雜,所以JS直譯器也漸漸獨立出來,成為了單獨的JS引擎。
2.瀏覽器架構
在你深入探知瀏覽器內部機理之前,你必須知道,瀏覽器是多程式、多執行緒模型,這裡我們以基於Blink核心的Chromium瀏覽器為例,講講在Chromium瀏覽器中,幾個常見的程式:
- Browser程式:這是瀏覽器的主程式,負責瀏覽器介面的顯示、各個頁面的管理。每次我們開啟瀏覽器,都會啟動一個Browser程式,結束該程式就會關閉我們的瀏覽器。
- Renderer程式:這是網頁的渲染程式,負責頁面的渲染工作,一般來說,一個頁面都會對應一個Renderer程式,不過也有例外。
- GPU程式:如果頁面啟動了硬體加速,瀏覽器就會開啟一個GPU程式,但是最多隻能有一個,當且僅當GPU硬體加速開啟的時候才會被建立。
剛剛我們提到的所有程式,他們都具有如下特徵:
- Browser程式和頁面的渲染是分開的,這保證了頁面渲染導致的崩潰不會導致瀏覽器主介面的崩潰。
- 每個頁面都是獨立的程式,這樣就保證了頁面之間不會相互影響。
- GPU程式也是獨立的。
為了能讓大家更為直觀的理解Chromium多程式模型,筆者附上一張Chrome瀏覽器在Windows上的多程式示例圖:(開啟工作管理員,將程式按照“命令列”排序,找到“Google Chrome”相關內容)
從程式的type引數中,我們可以區分出不同型別的程式,而那個不帶type引數的程式,指的就是我們的Browser瀏覽器主程式。
每個程式的內部,都有很多的執行緒,多執行緒的主要目的就是為了保持使用者介面的高響應度,保證UI執行緒(Browser程式中的主執行緒)不會被被其他費事的操作阻礙從而影響了對使用者操作的響應。就像我們平時所說的JS指令碼解釋執行,都是在獨立的執行緒中的,這也是JS這門程式語言特立獨行的地方,它是單執行緒指令碼。
在這裡做一些簡單的擴充,大家看看下面這段程式碼:
setTimeout(function(){
console.log("我能被輸出嗎?")
}, 0)
while(true){
var a = 1;
}
複製程式碼
大家肯定都知道因為執行緒阻塞,定時器裡的console並不會被輸出,這就是因為我們的JS解釋執行是單執行緒的,所以在執行過程中需要將同步和非同步的兩類程式碼分別壓入同步堆疊和非同步佇列中,通過Event Loop實現非同步操作:
也就說我們的JS定時器其實並不是完全準確的,還需要考慮同步堆疊中程式碼執行產生的延遲。
不過現在有很多技術可以讓我們的JS程式碼模擬多執行緒執行,包括之前一位日本大牛編寫的一個名為Concurrent.Thread.js
的外掛,還有HTML5標準中提出的Web Worker,這些工具都能讓我們實現多執行緒執行JS程式碼的效果。
3.HTML網頁結構及渲染機制淺析
在瞭解瀏覽器渲染機制之前你必須理解瀏覽器的層級結構,或許你知道瀏覽器的渲染頻率是60fps,知道瀏覽器的頁面呈現就如同電影般是逐幀渲染的效果,但並不代表頁面就像膠片一樣,從頭至尾都是單層的。頁面所經歷的,是從一個像千層麵一樣的東西一步步合成的過程,中間經歷了軟硬體渲染等等過程,最後形成一個完整的合成層才被渲染出來。千層麵的效果大概就像Firefox的3D View外掛所呈現出的那般:
有人可能會說刨得這麼深我們實際開發中用得到嗎?如果我這麼和你說“效能優化不是講究減少重排重繪嘛,我現在手上有一套方案,能讓你的頁面動畫直接跳過重排重繪的環節”,你是否會對此產生一點興趣?不過不著急,在我們還沒有把其中原理理清之前,我是不會草率地放出解決方案的,不然很容易就會讓大家的思想偏離正軌,因為我就是經歷了那樣一個慘痛的過程過來的。
如果要驗證我上述所言非憑空捏造,大家可以開啟chrome開發者工具中的performance版塊,錄製一小段頁面渲染,並將輸出結果切換至Event Log版塊,大家就可以清晰地看見網站渲染經歷的過程:
在Activity欄位中我們可以看到,我們的頁面經歷了重新計算樣式→更新Layer樹→繪製→合成合成層的過程,結合我們的Summary版塊中的環形圖,我們可以大致把頁面渲染分為三個階段:
- 第一階段,資源載入及指令碼執行階段:在Summary圖中我們可以看到,頁面在渲染時藍色的Loading(資源請求)部分和黃色的Scripting(指令碼執行)部分佔用了大量的時間,可能是因為我們請求的資源體積較多,執行的指令碼複雜度較大,我們可以依據網路傳輸效能優化的相關內容對這一階段進行優化。
- 第二階段,頁面佈局階段:在Summary圖中,紫色的Rendering部分指的就是我們的layout頁面佈局階段,在Event Log版塊之所以沒有看到layout activity,是因為我啟動了硬體加速,使得頁面在重新渲染時不會發生重排,可能對這句話你現在還聽的雲裡霧裡,等你看完這篇文章,你就會明白其中道理的。之所以把layout單獨提取出來,是因為它是一個很特別的過程,它會影響RenderLayers的生成,也會大量消耗CPU資源。
- 第三階段,頁面繪製階段:其中就包括了最後的Painting和Composite Layers的所有過程。
4.DOM樹及事件機制
如果你學過計算機網路,或者數位電子技術,那麼你一定知道,資源在網路中傳輸的形式是位元組流。我們每次請求一個頁面,都經過了位元組流→HTML文件→DOM Tree的過程,其中細節我已在前一篇文章中的navigation timing版塊作了詳細介紹,今天我們只談DOM樹構建之後瀏覽器的相關工作。
DOM樹的根是document,也就是我們經常在瀏覽器審查元素時能看到的HTMLDocument,HTML文件中的一個個標籤也被轉化成了一個個元素節點。
既然說到了DOM樹,就不得不提及瀏覽器的事件處理機制。事件處理最重要的兩個部分便是事件捕獲(Event Capture)和事件冒泡(Event Bubbling)。事件捕獲是自頂向下的,也就是說事件是從document節點發起,然後一路到達目標節點,反之,事件冒泡的過程則是自下而上的順序。
我們常使用addEventListener()
方法來監聽事件,它包含三個引數,前兩個大家都太熟悉,我們來聊聊第三個引數,MDN上將它稱作useCapture,型別為Boolean。它的取值顯而易見,便是true和false(預設),如果設定為true,表示在捕獲階段執行回撥,而false則是在冒泡階段執行,它決定了父子節點的事件繫結函式的執行順序。
5.RenderObject和RenderLayer的構建
在DOM樹之中,某些節點是使用者不可見的,也就是說這些只是起一些其他方面而不是顯示內容的作用。例如head節點、script節點,我們可以稱之為“非視覺化節點”。而另外的節點就是用來展示頁面內容的,包括我們的body節點、div節點等等。對於這些“可視節點”,因為WebKit需要將它們的內容渲染到最終的頁面呈現中,所以WebKit會為他們建立相應的RenderObject物件。一個RenderObject物件儲存了為了繪製DOM節點所需要的各種資訊,其中包括樣式佈局資訊等等。
但是構建的過程並沒有就此結束了,因為WebKit要對每一個可視節點都生成一個RenderObject物件,如果立即對所有的物件進行渲染,假設我們的頁面有上百個視覺化元素,那將會是多麼複雜的一項工程啊。為了減小網頁結構的複雜程度,並在很多情況下能夠減少重新渲染的開銷,WebKit會依據節點的樣式為網頁的層次建立響應的RenderLayer物件。
當某些型別的RenderObject節點或者具有某些CSS樣式的RenderObject節點出現的時候,WebKit就會為這些節點建立RenderLayer物件。RenderLayer節點和RenderObject節點不是一一對應關係,而是一對多的關係,其中生成RenderLayer的基本規則主要包括:
- DOM樹的Document節點對應的RenderView節點
- DOM樹中Document節點的子女節點,也就是HTML節點對應的RenderBlock節點
- 顯式指定CSS位置的節點(position為absolute或者fixed)
- 具有透明效果的節點
- 具有CSS 3D屬性的節點
- 使用Canvas元素或者Video元素的節點
RenderLayer節點的使用可以有效地減小網頁結構的複雜程度,並在很多情況下能夠減小重新渲染的開銷。經過梳理,RenderObject和RenderLayer的構建大概就是下圖這樣一個過程:
最後的構建結果將會以具體程式碼的形式在WebKit中儲存起來:
“layer at (x, x)”表示的是不同的RenderLayer節點,下面的所有的RenderObject物件均屬於該RenderLayer物件。
這一板塊的內容大家只需要瞭解就好,有興趣可以深究。
6.瀏覽器渲染方式
瀏覽器的渲染方式,主要分為兩種,第一種是軟體渲染,第二種是硬體渲染。如果繪製工作只是由CPU完成,那麼稱之為軟體渲染,如果繪製工作由GPU完成,則稱之為硬體渲染。軟體渲染與硬體渲染有不同的快取機制,只要我們合理利用,就能發揮出最好的效果。
在軟體渲染中,通常的結果就是一個點陣圖(Bitmap)。如果在頁面的某一元素髮生了更新,WebKit只是首先計算需要更新的區域,然後只繪製同這些區域有交集的RenderObject節點。也就是說,如果更新區域跟某個Render-Layer節點有交集,WebKit就會繼續查詢RenderLayer樹中包含的RenderObject子樹中的特定的一個或一些節點(這話好拗口,說的我都喘不過氣了),而不是去重新繪製整個RenderLayer對應的RenderObject子樹。以上內容,我們也可以稱之為CPU快取機制。
而硬體渲染的相關內容,我們將在下一模組以一個單獨的模組進行介紹,因為相關的理論和優化的知識太多了。
7.深入淺出硬體渲染
終於到了我們的重頭戲了,如果你能參透硬體渲染機制並物盡其用,那麼基本上可以說你在瀏覽器渲染效能上的造詣已經快登峰造極了。我們剛剛已經說過,瀏覽器還有一種名為硬體渲染的渲染方式,它是使用GPU的硬體能力來幫助渲染頁面。那麼,硬體渲染又是怎樣的一個過程呢?
WebKit會依據指定條件決定將那些RenderLayer物件組合在一起形成一個新層並快取在GPU,這一新層不久後會用於之後的合成,這些新層我們統稱為合成層(Compositing Layer)。對於一個RenderLayer物件,如果他不會形成一個合成層,那麼就會使用它的父親所使用的合成層,最後追溯到document。最後,由合成器(Compositor)將所有的合成層合成起來,形成網頁的最終視覺化結果,實際上同軟體渲染的點陣圖一樣,也是一張圖片。
同觸發RenderLayer條件相似,滿足一定條件或CSS樣式的RenderLayer會生成一個合成層:
- 根節點document,因為所有不會生成合成層的RenderLayer最終都會追溯到它
- RenderLayer具有CSS 3D屬性或者CSS透視效果(設定了translateZ()或者backface-visibility為hidden)
- RenderLayer包含的RenderObject節點表示的是使用硬體加速的HTML5 Video或者Canvas元素。
- RenderLayer使用了基於animation或者transition的帶有CSS透明效果(opacity)或者CSS變換(transform)的動畫
- RenderLayer有一個Z座標比自己小的兄弟節點,且該節點是一個合成層(在瀏覽器中的形成原因Compositing Reason會提示:
Compositing due to association with a element thay may overlap other composited elements
,意思就是你這個RenderLayer蓋在別的合成層至上啦,所以我瀏覽器要把你強制變成一個合成層)
如果大家想要更直觀地瞭解合成層究竟是一個什麼樣的形式,Chrome開發者工具為我們提供了十分好用的工具。便是開發者工具中的Layers功能模組(具體的新增及使用流程已在前文中做了詳細介紹,如有需要還望讀者移步):
版塊的左側的列表裡將會列出頁面裡存在哪些渲染層,右側的Details將會顯示這些渲染層的詳細資訊。包括渲染層的大小、形成原因等等,從圖中我們可以清楚知道,百度首頁只存在一個合成層document(因為百度首頁本身沒有過多的動畫需要大量重排重繪,所以一個合成層足夠了),這個合成成的形成原因是因為它是一個根Layer(Root Layer),和我們說的形成合成層的第一個條件別無二致。
大家可以試著在開發者工具里根據我們剛剛提出的幾條規則試著去修改元素的CSS樣式,嘗試一下看看是否會生成一個新的Compositing Layer。↖(^ω^)↗
不過這時候問題來了,為什麼我們已經對RenderObject合成了一次RenderLayer,之後還需要再合成一次Compositing Layer呢,這難道不是多此一舉嗎?其實原因是,首先我們再一次對頁面的層級進行了一次合成,這樣可以減少記憶體的使用量;其二是在合併之後,GPU會盡量減少合併後元素髮生更新帶來的重排重繪效能和處理上的困難。
上面的兩個原因大家聽起來可能還雲裡霧裡,究竟是什麼意思呢?
我們都知道,提升渲染效能的第一要義是減少重排重繪,我們之前也說過,在軟體渲染的過程中,如果發生元素更新,CPU需要找到更新到RenderObject進行重新繪製,其中過程包括了重排和重繪。但如果頁面只是某個合成層發生了位置的偏移、縮放、透明度變化等操作,那麼GPU會取代CPU去處理重新繪製的工作,因為GPU要做的知識把更新的合成層進行相應的變換並送入Compositor重新合成即可。
PS:大家可以嘗試的自己寫一個動畫,比如某個div從left: 0
變化到 left: 200px
,如果觸發了合成層它是不會發生重排和重繪。(觀察元素是否發生了重排重繪的方法已在前文進行了詳細介紹)
綜上所述,瀏覽器的渲染方式大概是下面這樣一個流程:
筆者自己畫的流程圖可能比較簡陋,希望大家見諒啊。也就是說,網頁載入後,每當重新繪製新的一幀的時候,需要經歷三個階段,就是流程圖中的佈局、繪製和合成三個階段。並且,layout和paint往往佔用了大量的時間,所以我們想要提高效能,就必須儘可能減少佈局和繪製的時間,最佳的解決方案當然是在重新渲染時觸發硬體加速而直接跳過重排和重繪的過程。
8.【擴充】JS效能監測
自從前文釋出後,就有小夥伴向我提到了JS阻塞效能這部分內容介紹的較少,今天就為此作些許補充。大家都知道JS程式碼會阻塞我們的頁面渲染,而且相對於另外兩部分效能優化而言(前文提到過的網路傳輸效能優化與頁面渲染效能優化),JS效能調優是一項很大的工程,因為作為一門程式語言,其中涉及到的演算法、時間複雜度等知識對於大多數CS專業的學生而言應該是很熟悉的名詞了吧,這也是大廠筆試面試必考的知識點。舉個最簡單的例子,學過C的小夥伴肯定熟悉這麼一個梗,請輸出給定範圍(N)內所有的素數,你可能會想到使用兩個for迴圈去實現,的確,這樣輸出的值沒有一點問題,但是沒有作任何優化,做過這道題的人都知道可以在內層的for迴圈裡將區間限制在j<=(int)sqrt(i)
這句簡單的程式碼有什麼效果呢,給你舉個簡單的例子,如果N的取值是100,它能幫你省去內層迴圈最多90次的執行,具體原理大家就自行去研究吧。
如果你對這些計算機基礎知識還不是特別瞭解,或者之前沒有傳統程式語言的基礎,我推薦大家去翻閱這樣一篇文章,能夠快速地帶你瞭解關於程式碼執行效能的重要指標——時間複雜度的相關知識。傳送門:mp.weixin.qq.com/s?__biz=MzA…
而這個模組的內容,不會給大家去介紹JS常用的演算法或者是降低複雜度的技巧,因為如果我這麼一篇簡短的文章能夠說得清楚的話,這些知識在大學裡面就不會形成一門完整的課程了。今天主要就是為大家推薦兩款非常實用的JS程式碼效能監測工具,供大家比較自己與他人書寫的程式碼的效能優劣。
8.1.Benchmark.js
首先提到的便是聲名遠播的Benchmark.js這款外掛啦,這是它的官網:benchmarkjs.com/ (圖片來自官網截圖)
使用方法很簡單,按照官網的教程一步步走就行了:
-
首先現在專案裡安裝Benchmark:
$ npm i --save benchmark
-
在檢測檔案中引入Benchmark模組:
var Benchmark = require('benchmark');
-
例項化Benchmark下的Suite,使用例項下的add方法新增函式執行控制程式碼
-
例項的on方法就是用於監聽Benchmark監測程式碼執行丟擲的事件,其中cycle會在控制檯輸出類似這樣的執行結果:
其中,Ops/sec 測試結果以每秒鐘執行測試程式碼的次數(Ops/sec)顯示,這個數值肯定是越大越好。除了這個結果外,同時會顯示測試過程中的統計誤差(百分比值)。
- 如果你手動設定了監聽了complete事件,通過示例上的方法就可以幫你自動比較出執行效率較高的函式控制程式碼。
Benchmark的使用方法就是這麼簡便,它的作用就好像是我們平時運動會短跑比賽上裁判的讀秒器,而我們的程式碼就像是我們的運動員,試著去和你們的小夥伴比比看,看實現同一需求,誰的程式碼更有效率吧。
8.2.JsPerf
JsPerf和Benchmark的功能實際上是一模一樣的,包括它的輸出內容,只不過它是一款線上的程式碼執行監測工具,無需像Benchmark那樣安裝模組,書寫本地檔案,只需要簡單的複製粘帖就行,傳送門:jsperf.com/ (圖片來自官網截圖)
我們只需要使用github登陸,然後點選Tests
下的Add
連結就可疑新建一個監測專案
接下來會讓我們填一些描述資訊,基本的英文大家應該都能看懂吧,這就不用我再去介紹了,只要把帶星號的部分填完就沒問題了:
重點就是把Code snippets to compare
這個模組裡面的內容天完整就行了,顧名思義,這裡面填寫的就是我們需要去監測的兩個程式碼執行控制程式碼。
點選save test case監測結果就是這樣,具體評判標準參照Benchmark:
9.結語
花了三天的時間才終於把瀏覽器的渲染機制這篇文章的相關內容整理完成,筆者也是建立在自己粗略的理解上將自己總結的經驗分享給大家,這篇文章比前文寫起來難度要高很多,因為所涉及的理論和知識太深,又只有太少的素材對這些理論展開了深入的介紹,但在我們實際的開發中,如果只知其然而不知其所以然,往往會在很多地方陷入迷茫,或者濫用硬體加速造成移動產品不可逆轉的壽命消耗,所以筆者在研讀完《WebKit技術內幕》一書之後,便立刻將書中知識結合開發所學撰寫成文,與廣大前端愛好者分享。如果文中有歧義或者錯誤,歡迎大家在評論區提出意見和批評,我會第一時間回答和改正。成文不易,不喜勿噴。