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. 渲染引擎做了啥!
3.1 Webkit 渲染過程
渲染引擎顧名思義,負責渲染,它將網路或者本地獲取的網頁和資源從位元組流進行解釋,呈現出來,流程如下圖:
從圖中可以看到,渲染引擎具體做了:
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。最後,瀏覽器使用GPU對這些層合成!
3.2 Blink 渲染流程
待補充
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) {n return a + b;n}n
如果這裡的 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()nfunction a1(){n console.log(1)n}nfunction a2(){n console.log(2)n}nfunction a3(){n console.log(3)n}nfunction a4(){n console.log(4)n}nfunction a5(){n console.log(5) n}nfunction a6(){n console.log(6)n}nfunction a7(){n console.log(7)n}nfunction a8(){n console.log(8)n}nfunction a9(){n console.log(9)n}nfunction a10(){n for(let i = 1;i<10000;i++){}n console.log(10)n}nna1()nsetTimeout(() => {n a2()n console.log(new Date().getTime()-init)n Promise.resolve().then(() => {na3()n}).then(() => {n a4()n})n a5()n}, 1000)nsetTimeout(()=>{na6()nconsole.log(new Date().getTime()-init)n}, 0)nnPromise.resolve().then(() => {n a7()n}).then(() => {n a8()n})nna9()na10()n
之所以有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技術內幕》
How browsers work
大前端開發者需要了解的基礎編譯原理和語言知識
《V8 Ignition:JS 引擎與位元組碼的不解之緣》
瀏覽器進程?線程?傻傻分不清楚!
event-loop-processing-model
JavaScript 運行機制詳解:再談Event Loop
推薦閱讀:
※超大文件如何計算md5?
※Chrome DevTools: 在 Profile 性能分析中顯示原生 JS 函數
※Runtime, Engine, VM 的區別是什麼?
※在不使用node的情況下,開發者怎樣在js里調用一個自己實現的c/c++函數?
※新手應該如何讀Google V8引擎源代碼?
TAG:GoogleChrome | V8 |