原文發表於知乎專欄cloudjfed 《chrome瀏覽器頁面渲染工作原理淺析》
1. 簡介
本篇文章基於自己對chrome瀏覽器工作原理的一些理解,重點在於頁面渲染的分析。此文會隨著我理解的深入持續更新,若有錯誤,歡迎隨時指正!比心 (。♥‿♥。)
參考資料重點來源於:
- 《WebKit技術內幕》
作者是朱永盛,Chromium專案的committer。作者的個人部落格:http://blog.csdn.net/milado_nju。 - HTML5規範
該規範來自whatwg。和w3c制定的html規範不同的是,前者來自Mozilla、Chrome、Safari等,而後者背後是微軟公司。因為本文主要探究的是chrome瀏覽器的行為,所以主要參考的是whatwg的規範。
文章目錄:
- 簡介
- 當我們談論Webkit的時候,我們在談論什麼?
- 瀏覽器核心
- Webkit
- Chromium
- 渲染引擎做了啥
- JavaScript引擎做了啥
- js是單執行緒的
- JavaScript引擎怎麼做
- 解釋過程
- v8的解釋過程
- v8之前的做法——機器碼
- v8新版本——位元組碼
- 瀏覽器的多執行緒
- 多執行緒有哪些
- 執行緒之間如何配合
- 我是一段野生程式碼
- 事件迴圈機制
- 事件機制的概念
- 事件機制的原理
- 程式碼執行過程分析
- 分析
- 驗證
- 基於瀏覽器(chrome核心)工作原理的程式碼優化
- 參考資料
2. 當我們談論Webkit的時候,我們在談論什麼?
對於前端同學來說,webkit這個名詞非常熟悉了,那麼當我們在說chrome是Webkit核心的時候,我們到底在說什麼?
2.1 瀏覽器核心
瀏覽器有一個重要的模組,它主要的作用是將頁面變成可視(聽)化的圖形、音訊結果,這就是瀏覽器核心。不同瀏覽器有不同核心,常用的有Trident(IE)、Gecko(Firefox)、Blink(Chrome)、Webkit(Safari)
瀏覽器核心又可以分成兩部分:渲染引擎和 JS 引擎。
最開始渲染引擎和 JS 引擎並沒有區分的很明確,後來 JS 引擎越來越獨立,核心就傾向於只指渲染引擎。
2.2 Webkit
一說到Webkit,最先想起來的可能就是chrome。但其實Webkit最早是蘋果公司的一個開源專案。
蘋果同學哭瞎了眼。
Webkit專案的結構如下
(圖片來自《WebKit技術內幕》第三章)
從圖中可以看到,Webkit主要由WebCore(渲染引擎)、JavaScriptCore(JavaScript引擎)和Webkit Port(不同瀏覽器自己實現的移植部分)構成。
整個專案稱為Webkit,而我們前端開發者在談到Webkit的時候,往往指的是WebCore,即渲染引擎部分。
當我們開啟一個頁面的時候,網頁的渲染流程如下:
(圖片來自《WebKit技術內幕》第一章)
圖中DOM和js引擎的雙向箭頭,指的是dom和js引擎的橋接介面,用於呼叫對方的一些方法。
2.3 chromium
那為什麼我們提到Webkit的時候,往往會和chrome聯絡在一起呢?
2008 年,谷歌公司釋出了 chrome 瀏覽器,瀏覽器使用的核心被命名為 chromium。
谷歌公司研發了自己的JavaScript引擎,v8, 但fork 了開源引擎 Webkit。後來谷歌公司在 Chromium 專案中研發 Blink 渲染引擎,也就是說,最初Blink 是從Webkit複製過來,沒有太大區別,但谷歌逐漸進行了優化,並慢慢將其中於chromium 不相關的程式碼進行移除。所以可以預見的是,以後兩者差距會愈來愈大。
對此,我採訪了一下蘋果公司,他們表示:
可能需要給他們寄一箱原諒套餐== 當然是假的!畢竟我又不是隔壁老王!
3. 渲染引擎做了啥!
渲染引擎顧名思義,負責渲染,它將網路或者本地獲取的網頁和資源從位元組流進行解釋,呈現出來,流程如下圖:
從圖中可以看到,渲染引擎具體做了:
1. 用HTML 直譯器 將位元組流解釋成DOM樹
HTML直譯器的工作如下:
(圖片來自《Webkit技術內幕》第五章)
直譯器進行分詞後,生成節點,並從節點生成DOM樹。
那如何從“並列”的節點,生成具有層次結構的樹呢?
直譯器在構建節點屬性的時候,使用了棧結構,即這樣一個程式碼片段<div><p><span></span></p></div>
,當解釋到span
時,此時棧中元素就是 div、p、span
,當解釋到</span>
時,span
出棧,遇到</p> p
出棧,以此類推。
當然,HTML直譯器在工作時很有可能遇到全域性的js程式碼!我知道此刻你要說,那就停下來執行js程式碼啊!
事實上,直譯器確實是停下來了,但並不會馬上執行js程式碼,瀏覽器的預掃描和預載入機制會先掃描後面的詞語,如果發現有資源,那就會請求併發下載資源,然後,再執行js程式碼。
詳細可參考:HTML5解析演算法
2. CSS直譯器:把css字串解釋後生成style rules
3. RenderObject 樹
Webkit檢查DOM樹中每個DOM節點,判斷是否生成RenderObject物件,因為有些節點是不可見的,比如 style head 或者 display為none的節點(現在你知道為啥display:none和visibility:hidden為什麼表現不一樣了吧)。RenderObject物件疊加了2中相應的css屬性。
4. 佈局(Layout)
此時的RenderObject 樹,並不包含位置和大小資訊。Webkit根據模型來進行遞迴的佈局計算。所以當樣式發生變化時,就需要重新計算佈局,這很耗費效能,更糟糕的是,一旦重排就要重繪了!
5. 繪製(Paint)
佈局完,終於可以呼叫方法進行繪製了!
而我們常說的重繪(repaint),就是當這些元素的顏色、背景等發生變化時,需要進行的。
6. 複合圖層化(Composite)
事實上,網頁是有層次結構的,基於RenderObject樹,建立了 RenderLayer樹,每個節點都是RenderLayer節點,一個RenderLayer節點上有n個RenderObject。
什麼是RenderLayer呢? 舉個栗子:比如有透明效果的RenderObject節點和使用Canvas(或WebGL技術)的RenderObject節點都需要新建一個RenderLayer。
事實上,可以從chrome的開發皮膚中看到這些層,下圖是淘寶頁面的RenderLayer:
最後,瀏覽器使用GPU對這些層合成!
4.JavaScript引擎做了啥!
4.1 js是單執行緒的
我們都知道js是單執行緒的。為什麼呢?js設計之初是為了進行簡單的表單驗證,操作DOM,與使用者進行互動。若是多執行緒操作,則極有可能出現衝突,比如同時操作同一個DOM元素,那到底聽誰的?我們當然可以使用 “鎖”機制來解決這些衝突,但這提高了複雜度。畢竟,js是由js之父 Brendan Eich 花了10天開發出來的。
媽媽問我為什麼跪著打下了這些字=。=
4.2 JavaScript引擎怎麼做
JavaScript引擎的主要作用,就是讀取檔案中的JavaScript,處理它並執行。
js是一門解釋型語言。解釋型語言和編譯型語言分別由直譯器和編譯器處理,下面是兩者的處理過程:
解釋型語言和編譯型語言的區別在於,它不提前編譯,或者說,你能不能拿到中間程式碼。
4.2.1 解釋過程
一般的JavaScript引擎(比如JavaScriptCore)的執行過程是這樣的:
原始碼→抽象語法樹(AST)→位元組碼 → JIT →原生程式碼
解釋執行效率很低,因為相同的語句被反覆解釋。因此優化的思路是動態觀察哪些程式碼經常被呼叫,對於那些被高頻率呼叫的程式碼,就用編譯器把它編譯並且快取下來,下次執行的時候就不用重新解釋,從而提升速度。這就是 JIT(Just-In-Time)。
4.2.2 v8 的解釋過程
4.2.2.1 v8之前的做法----機器碼
基於位元組碼的實現是主流,然而v8獨闢蹊徑,它的解釋過程是這樣的
原始碼→抽象語法樹(AST)→JIT→原生程式碼
v8放棄了編譯成位元組碼的過程,少了AST轉化成位元組碼轉化,節約了轉化時間,而且原生機器碼執行更快。在V8生成原生程式碼後,也會通過Profiler採集一些資訊,來優化原生程式碼。換句話說,v8的做法,是犧牲空間換時間。
4.2.2.3 v8 新版本—位元組碼
然而,今年4月末,v8推出了新版本,他們啟動了 Ignition 位元組碼直譯器。v8又迴歸了位元組碼。
講道理,機器碼既然執行快,為什麼又要“回退”到位元組碼呢?不能因為我超可愛,你就欺負我啊!
詳細可以看《V8 Ignition:JS 引擎與位元組碼的不解之緣》
文章作者認為原因如下:
1. 減輕機器碼佔用的記憶體空間,即犧牲時間換空間(主要動機)
位元組碼是機器碼的抽象,同一段程式碼,在位元組碼和機器碼中的儲存如下:
(圖片來自Understanding V8’s Bytecode)
顯然,機器碼佔用記憶體過大
2. 提高程式碼的啟動速度;
3. 對 v8 的程式碼進行重構,降低 v8 的程式碼複雜度
我的補充解釋如下:
JIT優化過程中,safari的JSC的做法如下圖:
(圖片來自:[WebKit] JavaScriptCore解析)
然而,js是無型別語言,也就是變數的型別有可能會改變。舉一個典型的栗子:
function add(a, b) {
return a + b;
}
複製程式碼
如果這裡的 a 和 b 都是整數,可見最終的程式碼是彙編中的 add 命令。如果類似的加法運算呼叫了很多次,直譯器可能會認為它值得被優化,於是編譯了這段程式碼。但如果下一次呼叫的是 add("你好哇", "雲霽!"),之前的優化就無效了,因為字串加法的實現和整數加法的實現完全不同。
而v8之前並沒有位元組碼這個中間表示,所以優化後的程式碼(二進位制格式)還得被還原成原先的形式(字串格式),這樣的過程被稱為優化回滾。反覆的優化 -> 優化回滾 -> 優化 …… 非常耗時,大大降低了引入 JIT 帶來的效能提升。
於是JIT 就很難過
而現在的v8 使用 Ignition(位元組碼直譯器) 加 TurboFan(JIT 編譯器)的組合,緩解了這個問題
前後效能對比如下圖:
(圖片來自:emm...找不到出處了,有好心人知道望告知)
5. 瀏覽器的多執行緒
js是單執行緒的,但為什麼能執行ajax和setTimeout等非同步操作呢? 很簡單,因為瀏覽器是多執行緒的呀!
5.1 多執行緒有哪些
一個瀏覽器通常由以下執行緒組成:
- GUI 渲染執行緒
- JavaScript引擎執行緒
- 定時觸發器執行緒
- 事件觸發執行緒(如滑鼠點選、AJAX非同步請求等)
- 非同步http請求執行緒
5.2 執行緒之間如何配合
5.2.1 我是一段野生程式碼
我們先來看一段程式碼
var init = new Date().getTime()
function a1(){
console.log('1')
}
function a2(){
console.log('2')
}
function a3(){
console.log('3')
}
function a4(){
console.log('4')
}
function a5(){
console.log('5')
}
function a6(){
console.log('6')
}
function a7(){
console.log('7')
}
function a8(){
console.log('8')
}
function a9(){
console.log('9')
}
function a10(){
for(let i = 1;i<10000;i++){}
console.log('10')
}
a1()
setTimeout(() => {
a2()
console.log(new Date().getTime()-init)
Promise.resolve().then(() => {
a3()
}).then(() => {
a4()
})
a5()
}, 1000)
setTimeout(()=>{
a6()
console.log(new Date().getTime()-init)
}, 0)
Promise.resolve().then(() => {
a7()
}).then(() => {
a8()
})
a9()
a10()
複製程式碼
之所以有n個a*函式,是為了後續方便除錯,核心程式碼從a1()開始
執行結果:你猜?
程式碼裡用到了定時器和非同步請求,那麼他們到底是怎麼配合執行的呢?
這裡需要引入一個概念,event loop。
5.2.2 事件迴圈機制
5.2.2.1 事件機制的概念
瀏覽器的主執行緒是event loop即事件迴圈,什麼是eventloop呢?
HTML5規範是這麼說的
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.
為了協調事件、使用者互動、指令碼、UI 渲染、網路請求,使用者代理必須使用 eventloop。
5.2.2.2 事件機制的原理
理解事件迴圈機制的工作原理是這樣的:
我們基於規範學習一下這幾個名詞:
task queue(任務佇列)
An event loop has one or more task queues. A task queue is an ordered list of tasks, which are algorithms that are responsible for work as...
一個事件迴圈會有一個或者多個任務佇列,每個任務佇列都是一系列任務按照順序組成的列表。
而多個任務列表源於:每個任務都有指定的任務源,相同的任務源的任務按順序放在同一個任務列表裡。不同的任務列表按照優先順序執行任務。
哪些是task任務源呢?
規範在Generic task sources中有提及(原文可看連結,為節省篇幅,此處直接給出翻譯):
DOM操作任務源
此任務源用於對DOM操作作出反應,例如一個元素以非阻塞的方式插入文件。
使用者互動任務源
此任務源用於對使用者互動作出反應,例如鍵盤或滑鼠輸入
響應使用者操作的事件(例如click)必須使用task佇列。
網路任務源
此任務源用於響應網路活動。
歷史遍歷任務源
此任務源用於將對history.back()和類似API的呼叫排隊
此外 setTimeout、setInterval、IndexDB 資料庫操作等也是任務源。
Microtask
Each event loop has a microtask queue. A microtask is a task that is originally to be queued on the microtask queue rather than a task queue. There are two kinds of microtasks: solitary callback microtasks, and compound microtasks.
一個事件迴圈會有一個microtask列表,microtask中的任務通常指:
- Promise.then
- MutationObserver
- Object.observe
簡單來說,事件迴圈機制是這樣執行的(此處規範原文):
- 從任務佇列中取出最早的一個任務執行
執行時產生堆疊 - 執行 microtask 檢查點
如果microtask checkpoint的flag(標識)為false,則設為true。執行 佇列中的所有 microtask,直到佇列為空,然後將microtask checkpoint的flag設為flase - 執行 UI render 操作(可選)
非每次迴圈必須,只要滿足瀏覽器60HZ的頻率即可 - 重複1
5.2.3 程式碼執行的過程分析
5.2.3.1 分析
根據以上理論,我們很容易分析到上述程式碼執行的事件迴圈,如下:
執行棧讀到script,開始執行任務
第一次迴圈:
- a1()
- setTimeout1丟到定時執行緒中去計時
- setTimeout2丟到定時執行緒中去計時
- Promise.then() 的cb a7()放入microtask佇列
- a9()
- a10()
- 檢查執行microtask
- a7() ,將cb a8放入microtask
- a8()
(計時執行緒到時間後,將計時器的回撥函式按順序放入任務佇列中)
第二次迴圈:
從任務佇列中讀到setTimeout2 cb
- a6()
- 輸出時間console.log(new Date().getTime()-init)
因為setTimeout總是計時結束之後,在任務佇列中排隊等待執行,所以它執行的時間,總是大於等於開發者設定的時間
但是,即便設定為0,且當前沒有正在執行的任務的情況下,時間也不可能為0,因為規範規定,最小時間為4ms!
第三次迴圈:
從任務佇列中讀到setTimeout1 cb
- a2()
- 輸出時間console.log(new Date().getTime()-init)
- 將 Promise.then() 的cb a3放入microtasks
- a5()
- 檢查執行microtask
- a3() 將cb a4放入microtasks
- a4()
5.2.3.2 驗證
好了,我說了不算,我們用chrome developer tools的Perfomance皮膚來驗證是否正確
步驟是醬的:
1. 開啟隱身模式,或者去掉chrome啟動的外掛,因為這些外掛會干擾我們分析
2. 開啟控制檯
3. 開啟皮膚:新版chrome是Perfomance皮膚,老版是Timeline皮膚
4. 看見左上角那個實心圈圈沒有?
趁他不注意,趕緊懟他一下!
5. 現在已經開始錄製了,迅速重新整理一下頁面,等個3,4s就停止錄製
6. 仔細看下面那個 Main那條來一起分析。
第一次迴圈:
看到一個很醒目的a1(紫條)了!
a1 後面是 黃色的setTimeout(黃條),再後面是a9 a10(紫條) run microtasks(黃條),下面一次是a7 a8(紫條)
(這就是為什麼要寫函式名,不然全世界都是匿名函式,乍一看還分不清誰是誰)
來鏡頭拉近看一下setTimeout那裡的兩個小黃條在做什麼
紅色框裡的文字,是滑鼠移上去看到的字,橙色框是詳細資訊,點選最後一行 index.html 可以看到具體程式碼,這裡忘了截圖。戳進去會跳轉到第一個setTimeout那一行(也就是89行)。
這個是第二個setTimeout,定位是在第二個setTimeout那裡。
可驗證第一次迴圈判斷正確!First Blood!
第二次迴圈:
Double Kill!
第三次迴圈:
可能有人會疑惑這裡為什麼沒有a4,那是因為程式碼執行太快,而控制皮膚顯示時間是精確到0.0m的,所以會有些誤差,事實上,我們在a3中多執行一些耗時程式碼就能看到了。或者也可以多錄製幾次,每次結果都會有些出入,但是函式執行順序是不會不一致滴!
Aced!一百昏!一百昏!老鐵們雙擊666!
6. 基於瀏覽器引擎工作原理(chrome核心)的程式碼優化
說了那麼多,此時難道我們不應該做點什麼?
- 編寫正確的HTML 程式碼,瀏覽器在html解釋的時候,遇到錯誤標籤,會啟動容錯機制,開發者應當規避這些錯誤。
- css優先,css優先於js引入,因為渲染樹需要拿到DOM樹和CSS規則,而js會停止DOM樹的構建。
- 可以用媒體查詢(media query)載入css,來解除對渲染的阻塞,這樣,只有當出現符合media的情況時,才會載入該資源。
- 儘量不要使用css import 來載入css,@import無論寫在哪一行,都會在頁面載入完再載入css
- 優化css選擇器。瀏覽器在處理選擇器時依照從右到左的原則,因此最右端的選擇器應該是優先順序最高的,比如 div > span.test 優於 div span。 兩個原因,一是 .test 比 span更準確,二是,瀏覽器看到 > span.test 會去找 div 的子元素,而不加大於號,則會尋找全域性的span標籤。
- 減少重繪重排
- 當你需要修改DOM節點樣式時,不要一條一條改n次,直接定義好樣式,修改css類即可,儘管chrome做了優化,並不會真的重繪/重排n次,但是不不能保證你沒有強制重繪的程式碼破壞這個機制,更何況,作為開發者,應當有意識編寫高質量程式碼
- 將多次對DOM的修改合併。或者,你先把它從渲染樹移除(display:none),這會重排一次,然後你想做什麼做什麼
- 當需要頻繁獲取元素位置等資訊時,可先快取
- 不要使用table佈局
- transform和opacity屬性只會引起合成,所以寫css動畫的時候,注意兩個屬性的使用,儘量只開啟GPU合成,而不重繪重排。
- 必要時使用函式防抖
- 防止js阻塞頁面,將script標籤放在</body>前面,或者使用defer async 屬性載入
- 檔案大小和檔案數量做好平衡,不要因為數量太多,大大超過了瀏覽器可並行下載的資源數量,要不要因為檔案太大,提高了單一資源載入的時間
- 優化回滾。不要書寫觸發優化會滾動的程式碼。
7. 參考資料
《WebKit技術內幕》