瀏覽器背後的運行機制

瀏覽器背後的運行機制


從本章開始,我們的性能優化探險也正式進入到了“深水區”——瀏覽器端的性能優化。

平時我們幾乎每天都在和瀏覽器打交道,在一些兼容任務比較繁重的團隊裏,苦逼的前端攻城師們甚至爲了兼容各個瀏覽器而不斷地去測試和調試,還要在腦子中記下各種遇到的 BUG 及解決方案。即便如此,我們好像並沒有去主動地關注和了解下瀏覽器的工作原理。我想如果我們對此做一點了解,在項目過程中就可以有效地避免一些問題,並對頁面性能做出相應的改進。

“知己知彼,百戰不殆”,今天,我們就一起來揭開瀏覽器渲染過程的神祕面紗!

瀏覽器的“心”

瀏覽器的“心”,說的就是瀏覽器的內核。在研究瀏覽器微觀的運行機制之前,我們首先要對瀏覽器內核有一個宏觀的把握。

開篇我提到許多工程師因爲業務需要,免不了需要去處理不同瀏覽器下代碼渲染結果的差異性。這些差異性正是因爲瀏覽器內核的不同而導致的——瀏覽器內核決定了瀏覽器解釋網頁語法的方式。
瀏覽器內核可以分成兩部分:渲染引擎(Layout Engine 或者 Rendering Engine)和 JS 引擎。早期渲染引擎和 JS 引擎並沒有十分明確的區分,但隨着 JS 引擎越來越獨立,內核也成了渲染引擎的代稱(下文我們將沿用這種叫法)。渲染引擎又包括了 HTML 解釋器、CSS 解釋器、佈局、網絡、存儲、圖形、音視頻、圖片解碼器等等零部件。

目前市面上常見的瀏覽器內核可以分爲這四種:Trident(IE)、Gecko(火狐)、Blink(Chrome、Opera)、Webkit(Safari)。

這裏面大家最耳熟能詳的可能就是 Webkit 內核了。很多同學可能會聽說過 Chrome 的內核就是 Webkit,殊不知 Chrome 內核早已迭代爲了 Blink。但是換湯不換藥,Blink 其實也是基於 Webkit 衍生而來的一個分支,因此,Webkit 內核仍然是當下瀏覽器世界真正的霸主。

下面我們就以 Webkit 爲例,對現代瀏覽器的渲染過程進行一個深度的剖析。

開啓瀏覽器渲染“黑盒”


什麼是渲染過程?簡單來說,渲染引擎根據 HTML 文件描述構建相應的數學模型,調用瀏覽器各個零部件,從而將網頁資源代碼轉換爲圖像結果,這個過程就是渲染過程(如下圖)。

從這個流程來看,瀏覽器呈現網頁這個過程,宛如一個黑盒。在這個神祕的黑盒中,有許多功能模塊,內核內部的實現正是這些功能模塊相互配合協同工作進行的。其中我們最需要關注的,就是HTML 解釋器、CSS 解釋器、圖層佈局計算模塊、視圖繪製模塊與JavaScript 引擎這幾大模塊:

  • HTML 解釋器:將 HTML 文檔經過詞法分析輸出 DOM 樹。

  • CSS 解釋器:解析 CSS 文檔, 生成樣式規則。

  • 圖層佈局計算模塊:佈局計算每個對象的精確位置和大小。

  • 視圖繪製模塊:進行具體節點的圖像繪製,將像素渲染到屏幕上。

  • JavaScript 引擎:編譯執行 Javascript 代碼。

瀏覽器渲染過程解析


有了對零部件的瞭解打底,我們就可以一起來走一遍瀏覽器的渲染流程了。在瀏覽器裏,每一個頁面的首次渲染都經歷瞭如下階段(圖中箭頭不代表串行,有一些操作是並行進行的,下文會說明):

  • 解析 HTML
    在這一步瀏覽器執行了所有的加載解析邏輯,在解析 HTML 的過程中發出了頁面渲染所需的各種外部資源請求。

  • 計算樣式
    瀏覽器將識別並加載所有的 CSS 樣式信息與 DOM 樹合併,最終生成頁面 render 樹(:after :before 這樣的僞元素會在這個環節被構建到 DOM 樹中)。

  • 計算圖層佈局
    頁面中所有元素的相對位置信息,大小等信息均在這一步得到計算。

  • 繪製圖層
    在這一步中瀏覽器會根據我們的 DOM 代碼結果,把每一個頁面圖層轉換爲像素,並對所有的媒體文件進行解碼。

  • 整合圖層,得到頁面
    最後一步瀏覽器會合併合各個圖層,將數據由 CPU 輸出給 GPU 最終繪製在屏幕上。(複雜的視圖層會給這個階段的 GPU 計算帶來一些壓力,在實際應用中爲了優化動畫性能,我們有時會手動區分不同的圖層)。

幾棵重要的“樹”


上面的內容沒有理解透徹?彆着急,我們一起來捋一捋這個過程中的重點——樹!

爲了使渲染過程更明晰一些,我們需要給這些”樹“們一個特寫:

  • DOM 樹:解析 HTML 以創建的是 DOM 樹(DOM tree ):渲染引擎開始解析 HTML 文檔,轉換樹中的標籤到 DOM 節點,它被稱爲“內容樹”。

  • CSSOM 樹:解析 CSS(包括外部 CSS 文件和樣式元素)創建的是 CSSOM 樹。CSSOM 的解析過程與 DOM 的解析過程是並行的。

  • 渲染樹:CSSOM 與 DOM 結合,之後我們得到的就是渲染樹(Render tree )。

  • 佈局渲染樹:從根節點遞歸調用,計算每一個元素的大小、位置等,給每個節點所應該出現在屏幕上的精確座標,我們便得到了基於渲染樹的佈局渲染樹(Layout of the render tree)。

  • 繪製渲染樹: 遍歷渲染樹,每個節點將使用 UI 後端層來繪製。整個過程叫做繪製渲染樹(Painting the render tree)。

基於這些“樹”,我們再梳理一番:

渲染過程說白了,首先是基於 HTML 構建一個 DOM 樹,這棵 DOM 樹與 CSS 解釋器解析出的 CSSOM 相結合,就有了佈局渲染樹。最後瀏覽器以佈局渲染樹爲藍本,去計算佈局並繪製圖像,我們頁面的初次渲染就大功告成了。

之後每當一個新元素加入到這個 DOM 樹當中,瀏覽器便會通過 CSS 引擎查遍 CSS 樣式表,找到符合該元素的樣式規則應用到這個元素上,然後再重新去繪製它。

有心的同學可能已經在思考了,查表是個花時間的活,我怎麼讓瀏覽器的查詢工作又快又好地實現呢?OK,講了這麼多原理,我們終於引出了我們的第一個可轉化爲代碼的優化點——CSS 樣式表規則的優化!

不做無用功:基於渲染流程的 CSS 優化建議


在給出 CSS 選擇器方面的優化建議之前,先告訴大家一個小知識:CSS 引擎查找樣式表,對每條規則都按從右到左的順序去匹配。 看如下規則:

#myList  li {}

這樣的寫法其實很常見。大家平時習慣了從左到右閱讀的文字閱讀方式,會本能地以爲瀏覽器也是從左到右匹配 CSS 選擇器的,因此會推測這個選擇器並不會費多少力氣:#myList 是一個 id 選擇器,它對應的元素只有一個,查找起來應該很快。定位到了 myList 元素,等於是縮小了範圍後再去查找它後代中的 li 元素,沒毛病。

事實上,CSS 選擇符是從右到左進行匹配的。我們這個看似“沒毛病”的選擇器,實際開銷相當高:瀏覽器必須遍歷頁面上每個 li 元素,並且每次都要去確認這個 li 元素的父元素 id 是不是 myList,你說坑不坑!

說到坑,不知道大家還記不記得這個經典的通配符:

* {}

入門 CSS 的時候,不少同學拿通配符清除默認樣式(我曾經也是通配符用戶的一員)。但這個傢伙很恐怖,它會匹配所有元素,所以瀏覽器必須去遍歷每一個元素!大家低頭看看自己頁面裏的元素個數,是不是心涼了——這得計算多少次呀!

這樣一看,一個小小的 CSS 選擇器,也有不少的門道!好的 CSS 選擇器書寫習慣,可以爲我們帶來非常可觀的性能提升。根據上面的分析,我們至少可以總結出如下性能提升的方案:

  • 避免使用通配符,只對需要用到的元素進行選擇。

  • 關注可以通過繼承實現的屬性,避免重複匹配重複定義。

  • 少用標籤選擇器。如果可以,用類選擇器替代,舉個🌰:

false:

#myList li{}

true:

.myList_li {}
  • 不要畫蛇添足,id 和 class 選擇器不應該被多餘的標籤選擇器拖後腿。舉個🌰:

false:

.myList#title

true:

#title
  • 減少嵌套。後代選擇器的開銷是最高的,因此我們應該儘量將選擇器的深度降到最低(最高不要超過三層),儘可能使用類來關聯每一個標籤元素。

搞定了 CSS 選擇器,萬里長征纔剛剛開始的第一步。但現在你已經理解了瀏覽器的工作過程,接下來的征程對你來說並不再是什麼難題~

告別阻塞:CSS 與 JS 的加載順序優化


說完了過程,我們來說一說特性。

HTML、CSS 和 JS,都具有阻塞渲染的特性。

HTML 阻塞,天經地義——沒有 HTML,何來 DOM?沒有 DOM,渲染和優化,都是空談。

那麼 CSS 和 JS 的阻塞又是怎麼回事呢?

CSS 的阻塞

在剛剛的過程中,我們提到 DOM 和 CSSOM 合力才能構建渲染樹。這一點會給性能造成嚴重影響:默認情況下,CSS 是阻塞的資源。瀏覽器在構建 CSSOM 的過程中,不會渲染任何已處理的內容。即便 DOM 已經解析完畢了,只要 CSSOM 不 OK,那麼渲染這個事情就不 OK(這主要是爲了避免沒有 CSS 的 HTML 頁面醜陋地“裸奔”在用戶眼前)。

我們知道,只有當我們開始解析 HTML 後、解析到 link 標籤或者 style 標籤時,CSS 才登場,CSSOM 的構建纔開始。很多時候,DOM 不得不等待 CSSOM。因此我們可以這樣總結:

CSS 是阻塞渲染的資源。需要將它儘早、儘快地下載到客戶端,以便縮短首次渲染的時間。

事實上,現在很多團隊都已經做到了儘早(將 CSS 放在 head 標籤裏)和儘快(啓用 CDN 實現靜態資源加載速度的優化)。這個“把 CSS 往前放”的動作,對很多同學來說已經內化爲一種編碼習慣。那麼現在我們還應該知道,這個“習慣”不是空穴來風,它是由 CSS 的特性決定的。

JS 的阻塞

不知道大家注意到沒有,前面我們說過程的時候,花了很多筆墨去說 HTML、說 CSS。相比之下,JS 的出鏡率也太低了點。
這當然不是因爲 JS 不重要。而是因爲,在首次渲染過程中,JS 並不是一個非登場不可的角色——沒有 JS,CSSOM 和 DOM 照樣可以組成渲染樹,頁面依然會呈現——即使它死氣沉沉、毫無交互。

JS 的作用在於修改,它幫助我們修改網頁的方方面面:內容、樣式以及它如何響應用戶交互。這“方方面面”的修改,本質上都是對 DOM 和 CSSDOM 進行修改。因此 JS 的執行會阻止 CSSOM,在我們不作顯式聲明的情況下,它也會阻塞 DOM。

我們通過一個🌰來理解一下這個機制:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>JS阻塞測試</title>
  <style>
    #container {
      background-color: yellow;
      width: 100px;
      height: 100px;
    }
  </style>
  <script>
    // 嘗試獲取container元素
    var container = document.getElementById("container")
    console.log('container', container)
  </script>
</head>
<body>
  <div id="container"></div>
  <script>
    // 嘗試獲取container元素
    var container = document.getElementById("container")
    console.log('container', container)
    // 輸出container元素此刻的背景色
    console.log('container bgColor', getComputedStyle(container).backgroundColor)
  </script>
  <style>
    #container {
      background-color: blue;
    }
  </style>
</body>
</html>

三個 console 的結果分別爲:

注:本例僅使用了內聯 JS 做測試。感興趣的同學可以把這部分 JS 當做外部文件引入看看效果——它們的表現一致。

第一次嘗試獲取 id 爲 container 的 DOM 失敗,這說明 JS 執行時阻塞了 DOM,後續的 DOM 無法構建;第二次才成功,這說明腳本塊只能找到在它前面構建好的元素。這兩者結合起來,“阻塞 DOM”得到了驗證。再看第三個 console,嘗試獲取 CSS 樣式,獲取到的是在 JS 代碼執行前的背景色(yellow),而非後續設定的新樣式(blue),說明 CSSOM 也被阻塞了。那麼在阻塞的背後,到底發生了什麼呢?

我們前面說過,JS 引擎是獨立於渲染引擎存在的。我們的 JS 代碼在文檔的何處插入,就在何處執行。當 HTML 解析器遇到一個 script 標籤時,它會暫停渲染過程,將控制權交給 JS 引擎。JS 引擎對內聯的 JS 代碼會直接執行,對外部 JS 文件還要先獲取到腳本、再進行執行。等 JS 引擎運行完畢,瀏覽器又會把控制權還給渲染引擎,繼續 CSSOM 和 DOM 的構建。 因此與其說是 JS 把 CSS 和 HTML 阻塞了,不如說是 JS 引擎搶走了渲染引擎的控制權。

現在理解了阻塞的表現與原理,我們開始思考一個問題。瀏覽器之所以讓 JS 阻塞其它的活動,是因爲它不知道 JS 會做什麼改變,擔心如果不阻止後續的操作,會造成混亂。但是我們是寫 JS 的人,我們知道 JS 會做什麼改變。假如我們可以確認一個 JS 文件的執行時機並不一定非要是此時此刻,我們就可以通過對它使用 defer 和 async 來避免不必要的阻塞,這裏我們就引出了外部 JS 的三種加載方式。

JS的三種加載方式

  • 正常模式:
<script src="index.js"></script>

這種情況下 JS 會阻塞瀏覽器,瀏覽器必須等待 index.js 加載和執行完畢才能去做其它事情。

  • async 模式:
<script async src="index.js"></script>

async 模式下,JS 不會阻塞瀏覽器做任何其它的事情。它的加載是異步的,當它加載結束,JS 腳本會立即執行。

  • defer 模式:
<script defer src="index.js"></script>

defer 模式下,JS 的加載是異步的,執行是被推遲的。等整個文檔解析完成、DOMContentLoaded 事件即將被觸發時,被標記了 defer 的 JS 文件纔會開始依次執行。

從應用的角度來說,一般當我們的腳本與 DOM 元素和其它腳本之間的依賴關係不強時,我們會選用 async;當腳本依賴於 DOM 元素和其它腳本的執行結果時,我們會選用 defer。

通過審時度勢地向 script 標籤添加 async/defer,我們就可以告訴瀏覽器在等待腳本可用期間不阻止其它的工作,這樣可以顯著提升性能。

小結


我們知道,當 JS 登場時,往往意味着對 DOM 的操作。DOM 操作所導致的性能開銷的“昂貴”,大家可能早就有所耳聞,雅虎軍規裏很重要的一條就是“儘量減少 DOM 訪問”。

其它前端性能優化:

前端技術架構體系(沒有鏈接的後續跟進):

其它相關

歡迎各位看官的批評和指正,共同學習和成長
希望該文章對您有幫助,你的 支持和鼓勵會是我持續的動力

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章