瀏覽器渲染機制

本文示例源代碼請戳 github博客,建議你們動手敲敲代碼。

前言

瀏覽器渲染頁面的過程css

從耗時的角度,瀏覽器請求、加載、渲染一個頁面,時間花在下面五件事情上:html

  1. DNS 查詢
  2. TCP 鏈接
  3. HTTP 請求即響應
  4. 服務器響應
  5. 客戶端渲染

本文討論第五個部分,即瀏覽器對內容的渲染,這一部分(渲染樹構建、佈局及繪製),又能夠分爲下面五個步驟:node

  1. 處理 HTML 標記並構建 DOM 樹。
  2. 處理 CSS 標記並構建 CSSOM 樹
  3. 將 DOM 與 CSSOM 合併成一個渲染樹。
  4. 根據渲染樹來佈局,以計算每一個節點的幾何信息。
  5. 將各個節點繪製到屏幕上。

須要明白,這五個步驟並不必定一次性順序完成。若是 DOM 或 CSSOM 被修改,以上過程須要重複執行,這樣才能計算出哪些像素須要在屏幕上進行從新渲染。實際頁面中,CSS 與 JavaScript 每每會屢次修改 DOM 和 CSSOM。git

一、瀏覽器的線程

在詳細說明以前咱們來看一下瀏覽器線程。這將有助於咱們理解後續內容。github

瀏覽器是多線程的,它們在內核制控下相互配合以保持同步。一個瀏覽器至少實現三個常駐線程:JavaScript 引擎線程,GUI 渲染線程,瀏覽器事件觸發線程。web

  • GUI 渲染線程:負責渲染瀏覽器界面 HTML 元素,當界面須要重繪(Repaint)或因爲某種操做引起迴流(reflow)時,該線程就會執行。在 Javascript 引擎運行腳本期間,GUI 渲染線程都是處於掛起狀態的,也就是說被」凍結」了。
  • JavaScript 引擎線程:主要負責處理 Javascript 腳本程序。
  • 定時器觸發線程:瀏覽器定時計數器並非由 JavaScript 引擎計數的, JavaScript 引擎是單線程的, 若是處於阻塞線程狀態就會影響記計時的準確, 所以瀏覽器經過單獨線程來計時並觸發定時。
  • 事件觸發線程:當一個事件被觸發時該線程會把事件添加到待處理隊列的隊尾,等待 JS 引擎的處理。這些事件包括當前執行的代碼塊如定時任務、瀏覽器內核的其餘線程如鼠標點擊、AJAX 異步請求等。因爲 JS 的單線程關係全部這些事件都得排隊等待 JS 引擎處理。定時塊任何和 ajax 請求等這些異步任務,事件觸發線程只是在到達定時時間或者是 ajax 請求成功後,把回調函數放到事件隊列當中。
  • 異步 HTTP 請求線程:在 XMLHttpRequest 在鏈接後是經過瀏覽器新開一個線程請求, 將檢測到狀態變動時,若是設置有回調函數,異步線程就產生狀態變動事件放到 JavaScript 引擎的處理隊列中等待處理。在發起了一個異步請求時,http 請求線程則負責去請求服務器,有了響應之後,事件觸發線程再把回到函數放到事件隊列當中。

二、構建DOM樹與CSSOM樹

瀏覽器從網絡或硬盤中得到HTML字節數據後會通過一個流程將字節解析爲DOM樹:ajax

  • 編碼: 先將HTML的原始字節數據轉換爲文件指定編碼的字符。
  • 令牌化: 而後瀏覽器會根據HTML規範來將字符串轉換成各類令牌(如<html>、<body>這樣的標籤以及標籤中的字符串和屬性等都會被轉化爲令牌,每一個令牌具備特殊含義和一組規則)。令牌記錄了標籤的開始與結束,經過這個特性能夠輕鬆判斷一個標籤是否爲子標籤(假設有<html><body>兩個標籤,當<html>標籤的令牌還未遇到它的結束令牌</html>就碰見了<body>標籤令牌,那麼<body>就是<html>的子標籤)。
  • 生成對象: 接下來每一個令牌都會被轉換成定義其屬性和規則的對象(這個對象就是節點對象)
  • 構建完畢: DOM樹構建完成,整個對象集合就像是一棵樹形結構。可能有人會疑惑爲何DOM是一個樹形結構,這是由於標籤之間含有複雜的父子關係,樹形結構正好能夠詮釋這個關係(CSSOS同理,層疊樣式也含有父子關係。例如: div p {font-size: 18px},會先尋找全部p標籤並判斷它的父標籤是否爲div以後纔會決定要不要採用這個樣式進行渲染)。

整個DOM樹的構建過程其實就是: 字節 -> 字符 -> 令牌 -> 節點對象 -> 對象模型,
下面將經過一個示例HTML代碼與配圖更形象地解釋這個過程。chrome

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

DOM樹構建過程

當上述HTML代碼碰見<link>標籤時,瀏覽器會發送請求得到該標籤中標記的CSS文件(使用內聯CSS能夠省略請求的步驟提升速度,但沒有必要爲了這點速度而丟失了模塊化與可維護性),style.css中的內容以下:segmentfault

body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }

瀏覽器得到外部CSS文件的數據後,就會像構建DOM樹同樣開始構建CSSOM樹,這個過程沒有什麼特別的差異。
CSSOM樹瀏覽器

三、構建渲染樹

在構建了DOM樹和CSSOM樹以後,瀏覽器只是擁有了兩個互相獨立的對象集合,DOM樹描述了文檔的結構與內容,CSSOM樹則描述了對文檔應用的樣式規則,想要渲染出頁面,就須要將DOM樹與CSSOM樹結合在一塊兒,這就是渲染樹。
渲染樹

  • 瀏覽器會先從DOM樹的根節點開始遍歷每一個可見節點(不可見的節點天然就不必渲染到頁面了,不可見的節點還包括被CSS設置了display: none屬性的節點,值得注意的是visibility: hidden屬性並不算是不可見屬性,它的語義是隱藏元素,但元素仍然佔據着佈局空間,因此它會被渲染成一個空框)
  • 對每一個可見節點,找到其適配的CSS樣式規則並應用。
  • 渲染樹構建完成,每一個節點都是可見節點而且都含有其內容和對應規則的樣式。

四、佈局與繪製

CSS採用了一種叫作盒子模型的思惟模型來表示每一個節點與其餘元素之間的距離,盒子模型包括外邊距(Margin),內邊距(Padding),邊框(Border),內容(Content)。頁面中的每一個標籤其實都是一個個盒子

盒子模型
佈局階段會從渲染樹的根節點開始遍歷,而後肯定每一個節點對象在頁面上的確切大小與位置,佈局階段的輸出是一個盒子模型,它會精確地捕獲每一個元素在屏幕內的確切位置與大小,全部相對的測量值也都會被轉換爲屏幕內的絕對像素值。

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Critial Path: Hello world!</title>
  </head>
  <body>
    <div style="width: 50%">
      <div style="width: 50%">Hello world!</div>
    </div>
  </body>
</html>

圖片描述

當Layout佈局事件完成後,瀏覽器會當即發出Paint Setup與Paint事件,開始將渲染樹繪製成像素,繪製所需的時間跟CSS樣式的複雜度成正比,繪製完成後,用戶就能夠看到頁面的最終呈現效果了。

咱們對一個網頁發送請求並得到渲染後的頁面可能也就通過了1~2秒,但瀏覽器其實已經作了上述所講的很是多的工做,總結一下瀏覽器關鍵渲染路徑的整個過程:

  • 處理HTML標記數據並生成DOM樹。
  • 處理CSS標記數據並生成CSSOM樹。
  • 將DOM樹與CSSOM樹合併在一塊兒生成渲染樹。
  • 遍歷渲染樹開始佈局,計算每一個節點的位置信息。
  • 將每一個節點繪製到屏幕。

五、外部資源是如何請求的

爲了直觀的觀察瀏覽器加載和渲染的細節,本地用nodejs搭建一個簡單的HTTP Server。
index.js

const http = require('http');
const fs = require('fs');
const hostname = '127.0.0.1';
const port = 8080;
http.createServer((req, res) => {
  if (req.url == '/a.js') {
    fs.readFile('a.js', 'utf-8', function (err, data) {
      res.writeHead(200, {'Content-Type': 'text/plain'});
      setTimeout(function () {
        res.write(data);
        res.end()
      }, 5000)
    })
  } else if (req.url == '/b.js') {
    fs.readFile('b.js', 'utf-8', function (err, data) {
      res.writeHead(200, {'Content-Type': 'text/plain'});
      res.write(data);
      res.end()
    })
  } else if (req.url == '/style.css') {
    fs.readFile('style.css', 'utf-8', function (err, data) {
      res.writeHead(200, {'Content-Type': 'text/css'});
        res.write(data);
        res.end()
    })
  } else if (req.url == '/index.html') {
    fs.readFile('index.html', 'utf-8', function (err, data) {
      res.writeHead(200, {'Content-Type': 'text/html'});
      res.write(data);
      res.end()
    })
  }
}).listen(port, hostname, () => {
  console.log('Server running at ' + hostname + ':' + port);
});

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache,no-store, must-revalidate"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>瀏覽器渲染</title>
    <link rel="stylesheet" href="http://127.0.0.1:8080/style.css">
    <script src='http://127.0.0.1:8080/a.js'></script>
</head>
<body>
<p id='header'>1111111</p>
<script src='http://127.0.0.1:8080/b.js'></script>
<p>222222</p>
<p>3333333</p>
</body>
</html>

style.css

#header{
    color: red;
}

a.js、b.js暫時爲空
能夠看到,服務端將對a.js的請求延遲5秒返回。Server啓動後,在chrome瀏覽器中打開http://127.0.0.1:8080/index.html
咱們打開chrome的調試面板
圖片描述
第一次解析html的時候,外部資源好像是一塊兒請求的,說資源是預解析加載的,就是說style.css和b.js是a.js形成阻塞的時候才發起的請求,圖中也是能夠解釋得通,由於第一次Parse HTML的時候就遇到阻塞,而後預解析就去發起請求,因此看起來是一塊兒請求的。

六、HTML 是否解析一部分就顯示一部分

咱們修改一下html代碼

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache,no-store, must-revalidate"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>瀏覽器渲染</title>
    <link rel="stylesheet" href="http://127.0.0.1:8080/style.css">
</head>
<body>
<p id='header'>1111111</p>
<script src='http://127.0.0.1:8080/a.js'></script>
<script src='http://127.0.0.1:8080/b.js'></script>
<p>222222</p>
<p>3333333</p>
</body>
</html>

圖片描述
由於a.js的延遲,解析到a.js所在的script標籤的時候,a.js尚未下載完成,阻塞並中止解析,以前解析的已經繪製顯示出來了。當a.js下載完成並執行完以後繼續後面的解析。固然,瀏覽器不是解析一個標籤就繪製顯示一次,當遇到阻塞或者比較耗時的操做的時候纔會先繪製一部分解析好的。

七、js文件的位置對HTML解析有什麼影響

7.1 js文件在頭部加載。

修改index.html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache,no-store, must-revalidate"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>瀏覽器渲染</title>
    <link rel="stylesheet" href="http://127.0.0.1:8080/style.css">
    <script src='http://127.0.0.1:8080/a.js'></script>
    <script src='http://127.0.0.1:8080/b.js'></script>
</head>
<body>
<p id='header'>1111111</p>
<p>222222</p>
<p>3333333</p>
</body>
</html>

圖片描述
由於a.js的阻塞使得解析中止,a.js下載完成以前,頁面沒法顯示任何東西。

7.二、js文件在中間加載。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache,no-store, must-revalidate"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>瀏覽器渲染</title>
    <link rel="stylesheet" href="http://127.0.0.1:8080/style.css">
</head>
<body>
<p id='header'>1111111</p>
<script src='http://127.0.0.1:8080/a.js'></script>
<script src='http://127.0.0.1:8080/b.js'></script>
<p>222222</p>
<p>3333333</p>
</body>
</html>

圖片描述
解析到js文件時出現阻塞。阻塞後面的解析,致使後面的不能很快的顯示。

7.三、js文件在尾部加載。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache,no-store, must-revalidate"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>瀏覽器渲染</title>
    <link rel="stylesheet" href="http://127.0.0.1:8080/style.css">
</head>
<body>
<p id='header'>1111111</p>
<p>222222</p>
<p>3333333</p>
<script src='http://127.0.0.1:8080/a.js'></script>
<script src='http://127.0.0.1:8080/b.js'></script>
</body>
</html>

解析到a.js部分的時候,頁面要顯示的東西已經解析完了,a.js不會影響頁面的呈現速度。

由上面咱們能夠總結一下

  • 直接引入的 JS 會阻塞頁面的渲染(GUI 線程和 JS 線程互斥)
  • JS 不阻塞資源的加載
  • JS 順序執行,阻塞後續 JS 邏輯的執行

下面咱們來看下異步js

7.四、async和defer的做用是什麼?有什麼區別?

接下來咱們對比下 defer 和 async 屬性的區別:
圖片描述
其中藍色線表明JavaScript加載;紅色線表明JavaScript執行;綠色線表明 HTML 解析。

  • 狀況1<script src="script.js"></script>

沒有 defer 或 async,瀏覽器會當即加載並執行指定的腳本,也就是說不等待後續載入的文檔元素,讀到就加載並執行。

  • 狀況2<script async src="script.js"></script> (異步下載)

async 屬性表示異步執行引入的 JavaScript,與 defer 的區別在於,若是已經加載好,就會開始執行——不管此刻是 HTML 解析階段仍是 DOMContentLoaded 觸發以後。須要注意的是,這種方式加載的 JavaScript 依然會阻塞 load 事件。換句話說,async-script 可能在 DOMContentLoaded 觸發以前或以後執行,但必定在 load 觸發以前執行。

  • 狀況3 <script defer src="script.js"></script>(延遲執行)

defer 屬性表示延遲執行引入的 JavaScript,即這段 JavaScript 加載時 HTML 並未中止解析,這兩個過程是並行的。整個 document 解析完畢且 defer-script 也加載完成以後(這兩件事情的順序無關),會執行全部由 defer-script 加載的 JavaScript 代碼,而後觸發 DOMContentLoaded 事件。

defer 與相比普通 script,有兩點區別:

  • 載入 JavaScript 文件時不阻塞 HTML 的解析,執行階段被放到 HTML 標籤解析完成以後。
  • 在加載多個JS腳本的時候,async是無順序的加載,而defer是有順序的加載。

八、css文件的影響

服務端將style.css的相應也設置延遲。

fs.readFile('style.css', 'utf-8', function (err, data) {
  res.writeHead(200, {'Content-Type': 'text/css'});
  setTimeout(function () {
    res.write(data);
    res.end()
  }, 5000)
})
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache,no-store, must-revalidate"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>瀏覽器渲染</title>
    <link rel="stylesheet" href="http://127.0.0.1:8080/style.css">
</head>
<body>
<p id='header'>1111111</p>
<p>222222</p>
<p>3333333</p>
<script src='http://127.0.0.1:8080/a.js' async></script>
<script src='http://127.0.0.1:8080/b.js' async></script>
</body>
</html>

能夠看出來,css文件不會阻塞HTML解析,可是會阻塞渲染,致使css文件未下載完成以前已經解析好html也沒法先顯示出來。

咱們把css調整到尾部

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache,no-store, must-revalidate"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>瀏覽器渲染</title>
</head>
<body>
<p id='header'>1111111</p>
<p>222222</p>
<p>3333333</p>
<link rel="stylesheet" href="http://127.0.0.1:8080/style.css">
<script src='http://127.0.0.1:8080/a.js' async></script>
<script src='http://127.0.0.1:8080/b.js' async></script>
</body>
</html>

這是頁面能夠渲染了,可是沒有樣式。直到css加載完成

以上咱們能夠簡單總結。

  • CSS 放在 head 中會阻塞頁面的渲染(頁面的渲染會等到 css 加載完成)
  • CSS 阻塞 JS 的執行 (由於 GUI 線程和 JS 線程是互斥的,由於有可能 JS 會操做 CSS)
  • CSS 不阻塞外部腳本的加載(不阻塞 JS 的加載,但阻塞 JS 的執行,由於瀏覽器都會有預先掃描器)

參考
瀏覽器渲染過程與性能優化
聊聊瀏覽器的渲染機制
你不知道的瀏覽器頁面渲染機制

相關文章
相關標籤/搜索