圖解瀏覽器渲染原理及流程

大家好,我是 CUGGZ。

今天來分享一下瀏覽器的渲染原理及流程。

前言

先來看看 Chrome 瀏覽器的多進程架構:

通常,我們打包出來的 HTML、CSS、JavaScript 等文件,經過瀏覽器運行之後就會顯示出頁面,這個過程就是瀏覽器的渲染進程來操作實現的,渲染進程的主要任務就是將靜態資源轉化爲可視化界面:

對於中間的瀏覽器,它就是一個黑盒,下面就來看看這個黑盒是如何將靜態資源轉化爲前端界面的。由於渲染機制比較複雜,所以渲染模塊在執行過程中會被劃分爲很多子階段,輸入的靜態資源經過這些子階段,最後輸出頁面。我們將一個處理流程稱爲渲染流水線,其大致流程如下圖所示:

這裏主要包含五個過程:

對於這五個流程,每一階段都有對應的產物:DOM 樹、CSSOM 樹、渲染樹、盒模型、界面。

下圖爲渲染引擎工作流程中各個步驟所對應的模塊:

從圖中可以看出,渲染引擎主要包含的模塊有:

DOM 樹構建

在說構建 DOM 樹之前,我們需要知道,爲什麼要構建 DOM 樹呢? 這是因爲,瀏覽器無法直接理解和使用 HTML,所以需要將 HTML 轉化爲瀏覽器能夠理解的結構——DOM 樹。

瞭解過數據結構的小夥伴對於樹結構應該不陌生,樹是由結點或頂點和邊組成的且不存在着任何環的一種數據結構。一棵非空的樹包括一個根結點,還有多個附加結點,所有結點構成一個多級分層結構。下面通過一張圖來看看什麼是樹結構:

對於上面的三個結構,前兩個都是樹,他們都只有唯一的根節點,而且不存在環結構。而第三個存在環,所以就不是一個樹結構。

在頁面中,每個 HTML 標籤都會被瀏覽器解析成文檔對象。HTML 本質上就是一個嵌套結構,在解析時會把每個文檔對象用一個樹形結構組織起來,所有的文檔對象都會掛在 document 上,這種組織方式就是 HTML 最基礎的結構——文檔對象模型(DOM),這棵樹的每個文檔對象就叫做 DOM 節點。

在渲染引擎中,DOM 有三個層面的作用:

在渲染引擎內部,HTML 解析器負責將 HTML 字節流轉換爲 DOM 結構,其轉化過程如下:

1. 字符流 → 詞(token)

HTML 結構會首先通過分詞器將字節流拆分爲詞(token)。Token 分爲 Tag Token 和文本 Token。下面來看一個 HTML 代碼是如何被拆分的:

<body>
    <div>
        <p>hello world</p>
    </div>
</body>

對於這段代碼,可以拆成詞:

可以看到,Tag Token 又分 StartTag 和 EndTag,<body><div><p>就是 StartTag ,</body></div></p>就是 EndTag,分別對應圖中的藍色和紅色塊,文本 Token 對應綠色塊。

這裏會通過狀態機將字符拆分成 token,所謂的狀態機就是將每個詞的特徵逐個拆分成獨立的狀態,然後再將所有詞的特徵字符合並起來,形成一個連通的圖結構。那爲什麼要使用狀態機呢?因爲每讀取一個字符,都要做一次決策,這些決策都和當前的狀態有關。

實際上,狀態機的作用就是用來做詞法分析的,將字符流分解爲詞(token)。

2. 詞(token)→ DOM 樹

接下來就需要將 Token 解析爲 DOM 節點,並將 DOM 節點添加到 DOM 樹中。這個過程是通過棧結構來實現的,這個棧主要用來計算節點之間的父子關係,上面步驟中生成的 token 會按順序壓入棧中,該過程的規則如下:

通過分詞器產生的新 Token 就這樣不停地入棧和出棧,整個解析過程就這樣一直持續下去,直到分詞器將所有字節流分詞完成。

下面來看看這的 Token 棧是如何工作的,有如下 HTML 結構:

<html>
    <body>
        <div>hello juejin</div>
        <p>hello world</p>
    </body>
</html>

開始時,HTML 解析器會創建一個根爲 document 的空的 DOM 結構,同時將 StartTag document 的 Token 壓入棧中,然後再將解析出來的第一個 StartTag html 壓入棧中,並創建一個 html 的 DOM 節點,添加到 document 上,這時 Token 棧和 DOM 樹 如下:

接下來 body 和 div 標籤也會和上面的過程一樣,進行入棧操作:

隨後就會解析到 div 標籤中的文本 Token,渲染引擎會爲該 Token 創建一個文本節點,並將該 Token 添加到 DOM 中,它的父節點就是當前 Token 棧頂元素對應的節點:

接下來就是第一個 EndTag div,這時 HTML 解析器會判斷當前棧頂元素是否是 StartTag div,如果是,則從棧頂彈出 StartTag div,如下圖所示:

再之後的過程就和上面類似了,最終的結果如下:

CSSOM 樹構建

上面已經基本瞭解了 DOM 的構建過程,但是這個 DOM 結構只包含節點,並不包含任何的樣式信息。下面就來看看,瀏覽器是如何把 CSS 樣式應用到 DOM 節點上的。

同樣,瀏覽器也是無法直接理解 CSS 代碼的,需要將其瀏覽器可以理解的 CSSOM 樹。實際上。瀏覽器在構建 DOM 樹的同時,如果樣式也加載完成了,那麼 CSSOM 樹也會同步構建。CSSOM 樹和 DOM 樹類似,它主要有兩個作用:

不過,CSSOM 樹和 DOM 樹是獨立的兩個數據結構,它們並沒有一一對應關係。DOM 樹描述的是 HTML 標籤的層級關係,CSSOM 樹描述的是選擇器之間的層級關係。可以在瀏覽器的控制檯,通過document.styleSheets命令來查看 CSSOM 樹:

那 CSS 樣式的來源有哪些呢?

CSS 樣式的來源主要有三種:

在將 CSS 轉化爲樹形對象之前,還需要將樣式表中的屬性值進行標準化處理,比如,當遇到以下 CSS 樣式:

body { font-size: 2em }{color:blue;}
div {font-weight: bold}
div p {color:green;}
div {color:red; }

可以看到上面 CSS 中有很多屬性值,比如 2em、blue、red、bold 等,這些數值並不能被瀏覽器直接理解。所以,需要將所有值轉化爲瀏覽器渲染引擎容易理解的、標準化的計算值,這個過程就是屬性值標準化。經過標準化的過程,上面的代碼會變成這樣:

body { font-size: 32px }{color: rgb(0, 0, 255);}
div {font-weight: 700}
div p {color: (0, 128, 0);}
div {color: (255, 0, 0); }

可以看到,2em 被解析成了 32px,blue 被解析成了 rgb(255, 0, 0),bold 被解析成 700。現在樣式的屬性已被標準化了,接下來就需要計算 DOM 樹中每個節點的樣式屬性了,這就涉及到 CSS 的繼承規則和層疊規則。

(1)樣式繼承

在 CSS 中存在樣式的繼承機制,CSS 繼承就是每個 DOM 節點都包含有父節點的樣式。比如在 HTML 上設置 “font-size:20px;”,那麼頁面裏基本所有的標籤都可以繼承到這個屬性了。

在 CSS 中,有繼承性的屬性主要有以下幾種:

  1. 字體系列屬性
  1. 文本系列屬性
  1. 元素可見性
  1. 列表佈局屬性
  1. 光標屬性

(2)樣式層疊

樣式計算過程中的第二個規則是樣式層疊。層疊是 CSS 的一個基本特徵,它是一個定義了 如何合併來自多個源的屬性值的算法。它在 CSS 處於核心地位,CSS 的全稱 “層疊樣式表” 正是強調了這一點。這裏不再多說。

總之,樣式計算階段的目的是爲了計算出 DOM 節點中每個元素的具體樣式,在計算過程 中需要遵守 CSS 的繼承和層疊兩個規則。這個階段最終輸出的內容是每個 DOM 節點的樣 式,並被保存在 ComputedStyle 的結構內。

對於以下代碼:

<html>
 <head>
  <link href="./style.css">
        <style>
            .juejin {
                width: 100px;
                height: 50px;
                background: red;
            }

            .content {
                font-size: 25px;
                line-height: 25px;
                margin: 10px;
            }
        </style>
 </head>
    <body>
        <div class="juejin">
         <div>CUGGZ</div>
        </div>
        <p style="color: blue" class="content">
            <span>hello world</span>
            <p style="display: none;">瀏覽器</p>
        </p>
    </body>
</html>

最終生成的 CSSOM 樹大致如下:

渲染樹構建

在 DOM 樹和 CSSOM 樹都渲染完成之後,就會進入渲染樹的構建階段。渲染樹就是 DOM 樹和 CSSOM 樹的結合,會得到一個可以知道每個節點會應用什麼樣式的數據結構。這個結合的過程就是遍歷整個 DOM 樹,然後在 CSSOM 樹裏查詢到匹配的樣式。

在不同瀏覽器裏,構建渲染樹的過程不太一樣:

那爲什麼要構建渲染樹呢?在上面的示例中可以看到,DOM 樹可能包含一些不可見的元素,比如 head 標籤,使用 display:none; 屬性的元素等。所以在顯示頁面之前,還要額外地構建一棵只包含可見元素的渲染樹

下面來看看構建渲染樹的過程:

可以看到,DOM 樹中不可見的節點都沒有包含到渲染樹中。爲了構建渲染樹,瀏覽器上大致做了如下工作:遍歷 DOM 樹中所有可見節點,並把這些節點加到佈局中,而不可見的節點會被佈局樹忽略掉,如 head 標籤下面的全部內容,再比如 p.p 這個元素,因爲它的屬性包含 dispaly:none,所以這個元素也沒有被包含進渲染樹中。如果給元素設置了visibility: hidden屬性,那這個元素會出現在渲染樹中,因爲具有這個樣式的元素是需要佔位的,只不過不需要顯示出來。

這裏在查找的過程中,出於效率的考慮,會從 CSSOM 樹的葉子節點開始查找,對應在 CSS 選擇器上也就是從選擇器的最右側向左查找。所以,不建議使用標籤選擇器和通配符選擇器來定義元素樣式。

除此之外,同一個 DOM 節點可能會匹配到多個 CSSOM 節點,而最終的效果由哪個 CSS 規則來確定,就是樣式優先級的問題了。當一個 DOM 元素受到多條樣式控制時,樣式的優先級順序如下:內聯樣式 > ID 選擇器 > 類選擇器 > 標籤選擇器 > 通用選擇器 > 繼承樣式 > 瀏覽器默認樣式

CSS 常見選擇器的優先級如下:

Fb1RVf

對於選擇器的優先級

注意:

頁面佈局

經過上面的步驟,就生成了一棵渲染樹,這棵樹就是展示頁面的關鍵。到現在爲止,已經有了需要渲染的所有節點之間的結構關係及其樣式信息。下面就需要進行頁面的佈局。

通過計算渲染樹上每個節點的樣式,就能得出來每個元素所佔空間的大小和位置。當有了所有元素的大小和位置後,就可以在瀏覽器的頁面區域裏去繪製元素的邊框了。這個過程就是佈局。這個過程中,瀏覽器對渲染樹進行遍歷,將元素間嵌套關係以盒模型的形式寫入文檔流:

盒模型在佈局過程中會計算出元素確切的大小和定位。計算完畢後,相應的信息被寫回渲染樹上,就形成了佈局渲染樹。同時,每一個元素盒子也都攜帶着自身的樣式信息,作爲後續繪製的依據。

頁面繪製

1. 構建圖層

經過佈局,每個元素的位置和大小就有了,那下面是不是就該開始繪製頁面了?答案是否定的,因爲頁面上可能有很多複雜的場景,比如 3D 變化、頁面滾動、使用 z-index 進行 z 軸的排序等。所以,爲了實現這些效果,渲染引擎還需要爲特定的節點生成專用的圖層,並生成一棵對應的圖層樹。

那什麼是圖層呢?我們可以在 Chrome 瀏覽器的開發者工具中,選擇 Layers 標籤(如果沒有,可以在更多工具中查找),就可以看到頁面的分層情況,以掘金首頁爲例,其分層情況如下:

可以看到,渲染引擎給頁面分了很多圖層,這些圖層會按照一定順序疊加在一起,就形成了最終的頁面。這裏,將頁面分解成多個圖層的操作就成爲分層, 最後將這些圖層合併到一層的操作就成爲合成, 分層和合成通常是一起使用的。Chrome 引入了分層和合成的機制就是爲了提升每幀的渲染效率。

通常情況下,並不是渲染樹上的每個節點都包含一個圖層,如果一個節點沒有對應的圖層,那這個節點就會屬於其父節點的圖層。那什麼樣的節點才能讓瀏覽器引擎爲其創建一個新的圖層呢?需要滿足以下其中一個條件:

(1)擁有層疊上下文屬性的元素

我們看到的頁面通常是二維的平面,而層疊上下文能夠讓頁面具有三維的概念。這些 HTML 元素按照自身屬性的優先級分佈在垂直於這個二維平面的 z 軸上。下面是盒模型的層疊規則:

對於上圖,由上到下分別是:

注意: 當定位元素 z-index:auto,生成盒在當前層疊上下文中的層級爲 0,不會建立新的層疊上下文,除非是根元素。

(2)需要裁剪的元素

什麼是裁剪呢?假如有一個固定寬高的 div 盒子,而裏面的文字較多超過了盒子的高度,這時就會產生裁剪,瀏覽器渲染引擎會把裁剪文字內容的一部分用於顯示在 div 區域。當出現裁剪時,瀏覽器的渲染引擎就會爲文字部分單獨創建一個圖層,如果出現滾動條,那麼滾動條也會被提升爲單獨的圖層。

2. 繪製圖層

在完成圖層樹的構建之後,渲染引擎會對圖層樹中的每個圖層進行繪製,下面就來看看渲染引擎是怎麼實現圖層繪製的。

渲染引擎在繪製圖層時,會把一個圖層的繪製分成很多繪製指令,然後把這些指令按照順序組成一個待繪製的列表:

可以看到,繪製列表中的指令就是一系列的繪製操作。通常情況下,繪製一個元素需要執行多條繪製指令,因爲每個元素的背景、邊框等屬性都需要單獨的指令進行繪製。所以在圖層繪製階段,輸出的內容就是繪製列表。

在 Chrome 瀏覽器的開發者工具中,通過 Layer 標籤可以看到圖層的繪製列表和繪製過程:

繪製列表只是用來記錄繪製順序和繪製指令的列表,而繪製操作是由渲染引擎中的合成線程來完成的。當圖層繪製列表準備好之後,主線程會把該繪製列表提交給合成線程。

注意:合成操作是在合成線程上完成的,所以,在執行合成操作時並不會影響到主線程的執行。

很多情況下,圖層可能很大,比如一篇長文章,需要滾動很久才能到底,但是用戶只能看到視口的內容,所以沒必要把整個圖層都繪製出來。因此,合成線程會將圖層劃分爲圖塊,這些圖塊的大小通常是 256x256 或者 512x512。合成線程會優先將視口附近的圖塊生成位圖。實際生成位圖的操作是在光柵化階段來執行的,所謂的光柵化就是按照繪製列表中的指令生成圖片。

當所有的圖塊都被光柵化之後,合成線程就會生成一個繪製圖塊的命令,瀏覽器相關進程收到這個指令之後,就會將其頁面內容繪製在內存中,最後將內存顯示在屏幕上,這樣就完成了頁面的繪製。

至此,整個渲染流程就完成了,其過程總結如下:

  1. 將 HTML 內容構建成 DOM 樹;

  2. 將 CSS 內容構建成 CSSOM 樹;

  3. 將 DOM 樹和 CSSOM 樹合成渲染樹;

  4. 根據渲染樹進行頁面元素的佈局;

  5. 對渲染樹進行分層操作,並生成分層樹;

  6. 爲每個圖層生成繪製列表,並提交到合成線程;

  7. 合成線程將圖層分成不同的圖塊,並通過柵格化將圖塊轉化爲位圖;

  8. 合成線程給瀏覽器進程發送繪製圖塊指令;

  9. 瀏覽器進程會生成頁面,並顯示在屏幕上。

擴展

1. 重排和重繪

說完瀏覽器引擎的渲染流程,再來看兩個重要的概念:重排(Reflow)和重繪(Repaint)。

我們知道,渲染樹是動態構建的,所以,DOM 節點和 CSS 節點的改動都可能會造成渲染樹的重新構建。渲染樹的改動就會造成頁面的重排或者重繪。下面就來看看這兩個概念,以及它們觸發的條件和減少觸發的操作。

(1)重排

當我們的操作引發了 DOM 樹中幾何尺寸的變化(改變元素的大小、位置、佈局方式等),這時渲染樹裏有改動的節點和它影響的節點都要重新計算。這個過程就叫做重排,也稱爲迴流。在改動發生時,要重新經歷頁面渲染的整個流程,所以開銷是很大的。

以下操作都會導致頁面重排:

在觸發重排時,由於瀏覽器渲染頁面是基於流式佈局的,所以當觸發迴流時,會導致周圍的 DOM 元素重新排列,它的影響範圍有兩種:

(2)重繪

當對 DOM 的修改導致了樣式的變化、但未影響其幾何屬性(比如修改顏色、背景色)時,瀏覽器不需重新計算元素的幾何屬性、直接爲該元素繪製新的樣式(會跳過重排環節),這個過程叫做重繪。簡單來說,重繪是由對元素繪製屬性的修改引發的。

當我們修改元素繪製屬性時,頁面佈局階段不會執行,因爲並沒有引起幾何位置的變換,所以就直接進入了繪製階段,然後執行之後的一系列子階段。相較於重排操作,重繪省去了佈局和分層階段,所以執行效率會比重排操作要高一些。

下面這些屬性會導致迴流:

注意:當觸發重排時,一定會觸發重繪,但是重繪不一定會引發重排。

相對來說,重排操作的消耗會比較大,所以在操作中儘量少的造成頁面的重排。爲了減少重排,可以通過以下方式進行優化:

瀏覽器針對頁面的迴流與重繪,進行了自身的優化——渲染隊列, 瀏覽器會將所有的迴流、重繪的操作放在一個隊列中,當隊列中的操作到了一定的數量或者到了一定的時間間隔,瀏覽器就會對隊列進行批處理。這樣就會讓多次的迴流、重繪變成一次迴流重繪。

2. JavaScript 對 DOM 的影響

當解析器解析 HTML 時,如果遇到了 script 標籤,判斷這是腳本,就會暫停 DOM 的解析,因爲接下來的 JavaScript 腳本可能會修改當前已經生成的 DOM 結構。

來看一段代碼:

<html>
    <body>
        <div>hello juejin</div>
        <script>
            document.getElementsByTagName('div')[0].innerText = 'juejin yyds'
        </script>
        <p>hello world</p>
    </body>
</html>

這裏,當解析完 div 標籤後,就會解析 script 標籤,這時的 DOM 結構如下:

這時,HTML 解析器就會暫停工作,JavaScript 引擎就會開始工作,並執行 script 標籤中的腳本內容。由於這段腳本修改了第一個 div 的內容,所以執行完這個腳本之後,div 中的文本就變成了 “juejin yyds”,當腳本執行完成之後,HTML 解析器就會恢復解析過程,繼續解析後面的內容,直至生成最終的 DOM。

上面我們說的 JavaScript 腳本是通過 script 標籤直接嵌入到 HTML 中的。當在頁面中引入 JavaScript 腳本時,情況就會變得複雜。比如:

<html>
    <body>
        <div>hello juejin</div>
        <script type="text/javascript" src='./index.js'></script>
        <p>hello world</p>
    </body>
</html>

其實這裏的執行流程和上面時一樣的,當遇到 script 標籤時,HTML 解析器都會暫停解析並去執行腳本文件。不過這裏執行 JavaScript 腳本時,需要先下載腳本。腳本的下載過程會阻塞 DOM 的解析,而通常下載又是非常耗時的,會受到網絡環境、JavaScript 腳本文件大小等因素的影響。

經過上面的分析可知,JavaScript 線程會阻塞 DOM 的解析,我們可以通過 CDN、壓縮腳本等方式來加速 JavaScript 腳本的加載。如果腳本文件中沒有操作 DOM 的相關代碼,就可以將 JavaScript 腳本設置爲異步加載,可以給 script 標籤添加 async 或 defer 屬性來實現腳本的異步加載。兩者的使用方式如下:

<script async type="text/javascript" src='./index.js'></script>
<script defer type="text/javascript" src='./index.js'></script>

下圖可以直觀的看出異步加載和直接加載的區別:

其中藍色代表腳本下載,紅色代表腳本執行,綠色代表 HTML 解析,灰色表示 HTML 解析暫停。

當初始 HTML 文檔已完全加載和解析時,將觸發 DOMContentLoaded 事件,而不需要等待樣式表,圖像和子框架頁面加載。該事件可以用來檢測 HTML 頁面是否完全加載完畢。

defer 和 async 屬性都是去異步加載外部的 JS 腳本文件,它們都不會阻塞頁面的解析,其區別如下:

再來看另外一種情況:

<html>
    <head>
   <style src='./style.css'></style>
    </head>
    <body>
        <div>hello juejin</div>
        <script>
            const ele = document.getElementsByTagName('div')[0];
            ele.innerText = 'juejin yyds';    // 操作DOM
            ele.style.color = 'skyblue';      // 操作CSSOM
        </script>
        <p>hello world</p>
    </body>
</html>

上面的代碼中,第 9 行是操作 DOM 的,而第 10 行是操作 CSSOM 的,所以在執行 JavaScript 腳本之前,還需要先解析 JavaScript 語句之上所有的 CSS 樣式。所以如果代碼裏引用了外部的 CSS 文件,那麼在執行 JavaScript 之前,還需要 等待外部的 CSS 文件下載完成,並解析生成 CSSOM 對象之後,才能執行 JavaScript 腳本。而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操縱了 CSSOM 的,所以渲染引擎在遇到 JavaScript 腳本時,不管該腳本是否操縱了 CSSOM,都會執行 CSS 文件下載,解析操作,再執行 JavaScript 腳本。

所以,JavaScript 會阻塞 DOM 生成,而樣式文件又會阻塞 JavaScript 的執行,我們在開發時需要格外注意這一點。

最後再來看一種情況,示例代碼如下:

<html>
    <head>
        <style src='./style.css'></style>
    </head>
    <body>
        <div>hello juejin</div>
        <script type="text/javascript" src='./index.js'></script>
        <p>hello world</p>
    </body>
</html>

這段 HTML 代碼中包含了 CSS 外部引用和 JavaScript 外部文件,在接收到 HTML 數據之後的預解析過程中,HTML 預解析器識別出來了有 CSS 文件和 JavaScript 文件需要下載,就會同時發起兩個文件的下載請求。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/9GMk0kFTHyTW_UrL0svNpQ