前言
移動互聯網時代,用戶對於網頁的打開速度要求越來越高。百度用戶體驗部研究表明,頁面放棄率和頁面的打開時間關係如下圖 所示。
根據百度用戶體驗部的研究結果來看,普通用戶期望且能夠接受的頁面加載時間在 3 秒以內。若頁面的加載時間過慢,用戶就會失去耐心而選擇離開。
首屏作為直面用戶的第一屏,其重要性不言而喻。優化用戶體驗更是我們前端開發非常需要 focus 的東西之一。
本文我們通過 8 道面試題來聊聊瀏覽器渲染過程與性能優化。
我們首先帶著這 8 個問題,來了解瀏覽器渲染過程,後面會給出題解~
- 為什麼 Javascript 要是單線程的 ?
- 為什麼 JS 阻塞頁面加載 ?
- css 加載會造成阻塞嗎 ?
- DOMContentLoaded 與 load 的區別 ?
- 什麼是 CRP,即關鍵渲染路徑(Critical Rendering Path)? 如何優化 ?
- defer 和 async 的區別 ?
- 談談瀏覽器的迴流與重繪 ?
- 什麼是渲染層合併 (Composite) ?
進程 (process) 和線程 (thread)
進程(process)和線程(thread)是操作系統的基本概念。
進程是 CPU 資源分配的最小單位(是能擁有資源和獨立運行的最小單位)。
線程是 CPU 調度的最小單位(是建立在進程基礎上的一次程序運行單位)。
現代操作系統都是可以同時運行多個任務的,比如:用瀏覽器上網的同時還可以聽音樂。
對於操作系統來說,一個任務就是一個進程,比如打開一個瀏覽器就是啟動了一個瀏覽器進程,打開一個 Word 就啟動了一個 Word 進程。
有些進程同時不止做一件事,比如 Word,它同時可以進行打字、拼寫檢查、打印等事情。在一個進程內部,要同時做多件事,就需要同時運行多個“子任務”,我們把進程內的這些“子任務”稱為線程。
由於每個進程至少要做一件事,所以一個進程至少有一個線程。系統會給每個進程分配獨立的內存,因此進程有它獨立的資源。同一進程內的各個線程之間共享該進程的內存空間(包括代碼段,數據集,堆等)。
借用一個生動的比喻來說,進程就像是一個有邊界的生產廠間,而線程就像是廠間內的一個個員工,可以自己做自己的事情,也可以相互配合做同一件事情。
當我們啟動一個應用,計算機會創建一個進程,操作系統會為進程分配一部分內存,應用的所有狀態都會保存在這塊內存中。
應用也許還會創建多個線程來輔助工作,這些線程可以共享這部分內存中的數據。如果應用關閉,進程會被終結,操作系統會釋放相關內存。
瀏覽器的多進程架構
一個好的程序常常被劃分為幾個相互獨立又彼此配合的模塊,瀏覽器也是如此。
以 Chrome 為例,它由多個進程組成,每個進程都有自己核心的職責,它們相互配合完成瀏覽器的整體功能,
每個進程中又包含多個線程,一個進程內的多個線程也會協同工作,配合完成所在進程的職責。
Chrome 採用多進程架構,其頂層存在一個 Browser process 用以協調瀏覽器的其它進程。
優點
由於默認 新開 一個 tab 頁面 新建 一個進程,所以單個 tab 頁面崩潰不會影響到整個瀏覽器。
同樣,第三方插件崩潰也不會影響到整個瀏覽器。
多進程可以充分利用現代 CPU 多核的優勢。
方便使用沙盒模型隔離插件等進程,提高瀏覽器的穩定性。
缺點
系統為瀏覽器新開的進程分配內存、CPU 等資源,所以內存和 CPU 的資源消耗也會更大。
不過 Chrome 在內存釋放方面做的不錯,基本內存都是能很快釋放掉給其他程序運行的。
瀏覽器的主要進程和職責
主進程 Browser Process
負責瀏覽器界面的顯示與交互。各個頁面的管理,創建和銷燬其他進程。網絡的資源管理、下載等。
第三方插件進程 Plugin Process
每種類型的插件對應一個進程,僅當使用該插件時才創建。
GPU 進程 GPU Process
最多隻有一個,用於 3D 繪製等
渲染進程 Renderer Process
稱為瀏覽器渲染進程或瀏覽器內核,內部是多線程的。主要負責頁面渲染,腳本執行,事件處理等。 (本文重點分析)
渲染進程 (瀏覽器內核)
瀏覽器的渲染進程是多線程的,我們來看看它有哪些主要線程 :
1. GUI 渲染線程
- 負責渲染瀏覽器界面,解析 HTML,CSS,構建 DOM 樹和 RenderObject 樹,佈局和繪製等。
- 當界面需要重繪(Repaint)或由於某種操作引發迴流(reflow)時,該線程就會執行。
- 注意,GUI 渲染線程與 JS 引擎線程是互斥的,當 JS 引擎執行時 GUI 線程會被掛起(相當於被凍結了),GUI 更新會被保存在一個隊列中等到 JS 引擎空閒時立即被執行。
2. JS 引擎線程
- Javascript 引擎,也稱為 JS 內核,負責處理 Javascript 腳本程序。(例如 V8 引擎)
- JS 引擎線程負責解析 Javascript 腳本,運行代碼。
- JS 引擎一直等待著任務隊列中任務的到來,然後加以處理,一個 Tab 頁(renderer 進程)中無論什麼時候都只有一個 JS 線程在運行 JS 程序。
- 注意,GUI 渲染線程與 JS 引擎線程是互斥的,所以如果 JS 執行的時間過長,這樣就會造成頁面的渲染不連貫,導致頁面渲染加載阻塞。
3. 事件觸發線程
- 歸屬於瀏覽器而不是 JS 引擎,用來控制事件循環(可以理解,JS 引擎自己都忙不過來,需要瀏覽器另開線程協助)
- 當 JS 引擎執行代碼塊如 setTimeOut 時(也可來自瀏覽器內核的其他線程,如鼠標點擊、AJAX 異步請求等),會將對應任務添加到事件線程中
- 當對應的事件符合觸發條件被觸發時,該線程會把事件添加到待處理隊列的隊尾,等待 JS 引擎的處理
- 注意,由於 JS 的單線程關係,所以這些待處理隊列中的事件都得排隊等待 JS 引擎處理(當 JS 引擎空閒時才會去執行)
4. 定時觸發器線程
- 傳說中的 setInterval 與 setTimeout 所在線程
- 瀏覽器定時計數器並不是由 JavaScript 引擎計數的,(因為 JavaScript 引擎是單線程的, 如果處於阻塞線程狀態就會影響記計時的準確)
- 因此通過單獨線程來計時並觸發定時(計時完畢後,添加到事件隊列中,等待 JS 引擎空閒後執行)
- 注意,W3C 在 HTML 標準中規定,規定要求 setTimeout 中低於 4ms 的時間間隔算為 4ms。
5. 異步 http 請求線程
- 在 XMLHttpRequest 在連接後是通過瀏覽器新開一個線程請求。
- 將檢測到狀態變更時,如果設置有回調函數,異步線程就產生狀態變更事件,將這個回調再放入事件隊列中。再由 JavaScript 引擎執行。
瀏覽器渲染流程
如果要講從輸入 url 到頁面加載發生了什麼,那怕是沒完沒了了…這裡我們只談談瀏覽器渲染的流程。
- 解析 HTML 文件,構建 DOM 樹,同時瀏覽器主進程負責下載 CSS 文件
- CSS 文件下載完成,解析 CSS 文件成樹形的數據結構,然後結合 DOM 樹合併成 RenderObject 樹
- 佈局 RenderObject 樹 (Layout/reflow),負責 RenderObject 樹中的元素的尺寸,位置等計算
- 繪製 RenderObject 樹 (paint),繪製頁面的像素信息
- 瀏覽器主進程將默認的圖層和複合圖層交給 GPU 進程,GPU 進程再將各個圖層合成(composite),最後顯示出頁面
題解
1. 為什麼 Javascript 要是單線程的 ?
這是因為 Javascript 這門腳本語言誕生的使命所致!JavaScript 為處理頁面中用戶的交互,以及操作 DOM 樹、CSS 樣式樹來給用戶呈現一份動態而豐富的交互體驗和服務器邏輯的交互處理。
如果 JavaScript 是多線程的方式來操作這些 UI DOM,則可能出現 UI 操作的衝突。
如果 Javascript 是多線程的話,在多線程的交互下,處於 UI 中的 DOM 節點就可能成為一個臨界資源,
假設存在兩個線程同時操作一個 DOM,一個負責修改一個負責刪除,那麼這個時候就需要瀏覽器來裁決如何生效哪個線程的執行結果。
當然我們可以通過鎖來解決上面的問題。但為了避免因為引入了鎖而帶來更大的複雜性,Javascript 在最初就選擇了單線程執行。
2. 為什麼 JS 阻塞頁面加載 ?
由於 JavaScript 是可操縱 DOM 的,如果在修改這些元素屬性同時渲染界面(即 JavaScript 線程和 UI 線程同時運行),那麼渲染線程前後獲得的元素數據就可能不一致了。
因此為了防止渲染出現不可預期的結果,瀏覽器設置 GUI 渲染線程與 JavaScript 引擎為互斥的關係。
當 JavaScript 引擎執行時 GUI 線程會被掛起,GUI 更新會被保存在一個隊列中等到引擎線程空閒時立即被執行。
從上面我們可以推理出,由於 GUI 渲染線程與 JavaScript 執行線程是互斥的關係,
當瀏覽器在執行 JavaScript 程序的時候,GUI 渲染線程會被保存在一個隊列中,直到 JS 程序執行完成,才會接著執行。
因此如果 JS 執行的時間過長,這樣就會造成頁面的渲染不連貫,導致頁面渲染加載阻塞的感覺。
3. css 加載會造成阻塞嗎 ?
由上面瀏覽器渲染流程我們可以看出 :
DOM 解析和 CSS 解析是兩個並行的進程,所以 CSS 加載不會阻塞 DOM 的解析。
然而,由於 Render Tree 是依賴於 DOM Tree 和 CSSOM Tree 的,
所以他必須等待到 CSSOM Tree 構建完成,也就是 CSS 資源加載完成(或者 CSS 資源加載失敗)後,才能開始渲染。
因此,CSS 加載會阻塞 Dom 的渲染。
由於 JavaScript 是可操縱 DOM 和 css 樣式 的,如果在修改這些元素屬性同時渲染界面(即 JavaScript 線程和 UI 線程同時運行),那麼渲染線程前後獲得的元素數據就可能不一致了。
因此為了防止渲染出現不可預期的結果,瀏覽器設置 GUI 渲染線程與 JavaScript 引擎為互斥的關係。
因此,樣式表會在後面的 js 執行前先加載執行完畢,所以css 會阻塞後面 js 的執行。
4. DOMContentLoaded 與 load 的區別 ?
- 當 DOMContentLoaded 事件觸發時,僅當 DOM 解析完成後,不包括樣式表,圖片。我們前面提到 CSS 加載會阻塞 Dom 的渲染和後面 js 的執行,js 會阻塞 Dom 解析,所以我們可以得到結論:
當文檔中沒有腳本時,瀏覽器解析完文檔便能觸發 DOMContentLoaded 事件。如果文檔中包含腳本,則腳本會阻塞文檔的解析,而腳本需要等 CSSOM 構建完成才能執行。在任何情況下,DOMContentLoaded 的觸發不需要等待圖片等其他資源加載完成。 - 當 onload 事件觸發時,頁面上所有的 DOM,樣式表,腳本,圖片等資源已經加載完畢。
- DOMContentLoaded -> load。
5. 什麼是 CRP,即關鍵渲染路徑(Critical Rendering Path)? 如何優化 ?
關鍵渲染路徑是瀏覽器將 HTML CSS JavaScript 轉換為在屏幕上呈現的像素內容所經歷的一系列步驟。也就是我們上面說的瀏覽器渲染流程。
為儘快完成首次渲染,我們需要最大限度減小以下三種可變因素:
- 關鍵資源的數量: 可能阻止網頁首次渲染的資源。
- 關鍵路徑長度: 獲取所有關鍵資源所需的往返次數或總時間。
- 關鍵字節: 實現網頁首次渲染所需的總字節數,等同於所有關鍵資源傳送文件大小的總和。
1. 優化 DOM
- 刪除不必要的代碼和註釋包括空格,儘量做到最小化文件。
- 可以利用 GZIP 壓縮文件。
- 結合 HTTP 緩存文件。
2. 優化 CSSOM
縮小、壓縮以及緩存同樣重要,對於 CSSOM 我們前面重點提過了它會阻止頁面呈現,因此我們可以從這方面考慮去優化。
- 減少關鍵 CSS 元素數量
- 當我們聲明樣式表時,請密切關注媒體查詢的類型,它們極大地影響了 CRP 的性能 。
3. 優化 JavaScript
當瀏覽器遇到>阻止解析器繼續操作,直到 CSSOM 構建完畢,JavaScript 才會運行並繼續完成 DOM 構建過程。
- async: 當我們在>
- defer: 與 async 的區別在於,腳本需要等到文檔解析後( DOMContentLoaded 事件前)執行,而 async 允許腳本在文檔解析時位於後臺運行(兩者下載的過程不會阻塞 DOM,但執行會)。
- 當我們的腳本不會修改 DOM 或 CSSOM 時,推薦使用 async 。
- 預加載 —— preload & prefetch 。
- DNS 預解析 —— dns-prefetch 。
總結
- 分析並用 關鍵資源數 關鍵字節數 關鍵路徑長度 來描述我們的 CRP 。
- 最小化關鍵資源數: 消除它們(內聯)、推遲它們的下載(defer)或者使它們異步解析(async)等等 。
- 優化關鍵字節數(縮小、壓縮)來減少下載時間 。
- 優化加載剩餘關鍵資源的順序: 讓關鍵資源(CSS)儘早下載以減少 CRP 長度 。
6. defer 和 async 的區別 ?
當瀏覽器碰到>
<code>1. /<code>
沒有 defer 或 async,瀏覽器會立即加載並執行指定的腳本,“立即”指的是在渲染該>
<code>2. /<code>
有 async,加載和渲染後續文檔元素的過程將和>
<code>3. /<code>
有 defer,加載後續文檔元素的過程將和>
從實用角度來說,首先把所有腳本都丟到
閱讀更多 JAVA後端架構 的文章