1. 程式人生 > 實用技巧 >瀏覽器中的頁面:22 | DOM樹:JavaScript是如何影響DOM樹構建的?

瀏覽器中的頁面:22 | DOM樹:JavaScript是如何影響DOM樹構建的?

前言:該篇說明:請見說明 —— 瀏覽器工作原理與實踐目錄

上一篇文章中,我們通過開發者工具中的網路面板,介紹了網路請求過程的幾種效能指標以及對頁面載入的影響。

而在渲染流水線中,後面的步驟都直接或者間接地依賴於 DOM 結構,所以本文我們就繼續沿著網路資料流路徑來介紹 DOM 樹是怎麼生成的。然後再基於 DOM 樹的解析流程介紹兩塊內容:第一個是在解析過程中遇到 JavaScript 指令碼,DOM 解析器是如何處理的?第二個是 DOM 解析器是如何處理跨站點資源的?

什麼是 DOM

從網路傳給渲染引擎的 HTML 檔案位元組流是無法直接被渲染引擎理解的,所以要將其轉化為渲染引擎能夠理解的內部結構,這個結構就是 DOM。DOM 提供了對 HTML 文件結構化的表述。在渲染引擎中,DOM 有三個層面的作用。

  • 從頁面的視角來看,DOM 是生成頁面的基礎資料結構。
  • 從 JavaScript 指令碼視角來看,DOM 提供給 JavaScript 指令碼操作的介面,通過這套介面,JavaScript 可以對 DOM 結構進行訪問,從而改變文件的結構、樣式和內容。
  • 從安全視角來看,DOM 是一道安全防護線,一些不安全的內容在 DOM 解析階段就被拒之門外了。

簡言之,DOM 是表述 HTML 的內部資料結構,它會將 Web 頁面和 JavaScript 指令碼連線起來,並過濾一些不安全的內容。

DOM 樹如何生成

在渲染引擎內部,有一個叫 HTML 解析器(HTMLParser)的模組,它的職責就是負責將 HTML 位元組流轉換為 DOM 結構。所以這裡我們需要先要搞清楚 HTML 解析器是怎麼工作的。

在開始介紹 HTML 解析器之前,我要先解釋一個大家在留言區問到過好多次的問題:HTML 解析器是等整個 HTML 文件載入完成之後開始解析的,還是隨著 HTML 文件邊載入邊解析的?

在這裡我統一解答下,HTML 解析器並不是等整個文件載入完成之後再解析的,而是網路程序載入了多少資料,HTML 解析器便解析多少資料

那詳細的流程是怎樣的呢?網路程序接收到響應頭之後,會根據響應頭中的 content-type 欄位來判斷檔案的型別,比如 content-type 的值是“text/html”,那麼瀏覽器就會判斷這是一個 HTML 型別的檔案,然後為該請求選擇或者建立一個渲染程序。渲染程序準備好之後,網路程序和渲染程序之間會建立一個共享資料的管道

,網路程序接收到資料後就往這個管道里面放,而渲染程序則從管道的另外一端不斷地讀取資料,並同時將讀取的資料“喂”給 HTML 解析器。你可以把這個管道想象成一個“水管”,網路程序接收到的位元組流像水一樣倒進這個“水管”,而“水管”的另外一端是渲染程序的 HTML 解析器,它會動態接收位元組流,並將其解析為 DOM。

解答完這個問題之後,接下來我們就可以來詳細聊聊 DOM 的具體生成流程了。

前面我們說過程式碼從網路傳輸過來是位元組流的形式,那麼後續位元組流是如何轉換為 DOM 的呢?你可以參考下圖:

位元組流轉換為 DOM

從圖中你可以看出,位元組流轉換為 DOM 需要三個階段。

第一個階段,通過分詞器將位元組流轉換為 Token。

前面《14 | 編譯器和直譯器:V8 是如何執行一段 JavaScript 程式碼的?》文章中我們介紹過,V8 編譯 JavaScript 過程中的第一步是做詞法分析,將 JavaScript 先分解為一個個 Token。解析 HTML 也是一樣的,需要通過分詞器先將位元組流轉換為一個個 Token,分為 Tag Token 和文字 Token。上述 HTML 程式碼通過詞法分析生成的 Token 如下所示:

生成的 Token 示意圖

由圖可以看出,Tag Token 又分 StartTag 和 EndTag,比如就是 StartTag ,就是EndTag,分別對於圖中的藍色和紅色塊,文字 Token 對應的綠色塊。

至於後續的第二個和第三個階段是同步進行的,需要將 Token 解析為 DOM 節點,並將 DOM 節點新增到 DOM 樹中。

HTML 解析器維護了一個 Token 棧結構,該 Token 棧主要用來計算節點之間的父子關係,在第一個階段中生成的 Token 會被按照順序壓到這個棧中。具體的處理規則如下所示:

  • 如果壓入到棧中的是 StartTag Token,HTML 解析器會為該 Token 建立一個 DOM 節點,然後將該節點加入到 DOM 樹中,它的父節點就是棧中相鄰的那個元素生成的節點。
  • 如果分詞器解析出來是文字 Token,那麼會生成一個文字節點,然後將該節點加入到 DOM 樹中,文字 Token 是不需要壓入到棧中,它的父節點就是當前棧頂 Token 所對應的 DOM 節點。
  • 如果分詞器解析出來的是 EndTag 標籤,比如是 EndTag div,HTML 解析器會檢視 Token 棧頂的元素是否是 StarTag div,如果是,就將 StartTag div 從棧中彈出,表示該 div 元素解析完成。

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

為了更加直觀地理解整個過程,下面我們結合一段 HTML 程式碼(如下),來一步步分析 DOM 樹的生成過程。

<html>
<body>
    <div>1</div>
    <div>test</div>
</body>
</html>

這段程式碼以位元組流的形式傳給了 HTML 解析器,經過分詞器處理,解析出來的第一個 Token 是 StartTag html,解析出來的 Token 會被壓入到棧中,並同時建立一個 html 的 DOM 節點,將其加入到 DOM 樹中。

這裡需要補充說明下,HTML 解析器開始工作時,會預設建立了一個根為 document 的空 DOM 結構,同時會將一個 StartTag document 的 Token 壓入棧底。然後經過分詞器解析出來的第一個 StartTag html Token 會被壓入到棧中,並建立一個 html 的 DOM 節點,新增到 document 上,如下圖所示:

解析到 StartTag html 時的狀態

然後按照同樣的流程解析出來 StartTag body 和 StartTag div,其 Token 棧和 DOM 的狀態如下圖所示:

解析到 StartTag div 時的狀態

接下來解析出來的是第一個 div 的文字 Token,渲染引擎會為該 Token 建立一個文字節點,並將該 Token 新增到 DOM 中,它的父節點就是當前 Token 棧頂元素對應的節點,如下圖所示:

解析出第一個文字 Token 時的狀態

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

元素彈出 Token 棧示意圖

按照同樣的規則,一路解析,最終結果如下圖所示:

最終解析結果

通過上面的介紹,相信你已經清楚 DOM 是怎麼生成的了。不過在實際生產環境中,HTML 原始檔中既包含 CSS 和 JavaScript,又包含圖片、音訊、視訊等檔案,所以處理過程遠比上面這個示範 Demo 複雜。不過理解了這個簡單的 Demo 生成過程,我們就可以往下分析更加複雜的場景了。

JavaScript 是如何影響 DOM 生成的

我們再來看看稍微複雜點的 HTML 檔案,如下所示:

<html>
<body>
    <div>1</div>
    <script>
    let div1 = document.getElementsByTagName('div')[0]
    div1.innerText = 'time.geekbang'
    </script>
    <div>test</div>
</body>
</html>

我在兩段 div 中間插入了一段 JavaScript 指令碼,這段指令碼的解析過程就有點不一樣了。<script>標籤之前,所有的解析流程還是和之前介紹的一樣,但是解析到<script>標籤時,渲染引擎判斷這是一段指令碼,此時 HTML 解析器就會暫停 DOM 的解析,因為接下來的 JavaScript 可能要修改當前已經生成的 DOM 結構。

通過前面 DOM 生成流程分析,我們已經知道當解析到 script 指令碼標籤時,其 DOM 樹結構如下所示:

執行指令碼時 DOM 的狀態

這時候 HTML 解析器暫停工作,JavaScript 引擎介入,並執行 script 標籤中的這段指令碼,因為這段 JavaScript 指令碼修改了 DOM 中第一個 div 中的內容,所以執行這段指令碼之後,div 節點內容已經修改為 time.geekbang 了。指令碼執行完成之後,HTML 解析器恢復解析過程,繼續解析後續的內容,直至生成最終的 DOM。

以上過程應該還是比較好理解的,不過除了在頁面中直接內嵌 JavaScript 指令碼之外,我們還通常需要在頁面中引入 JavaScript 檔案,這個解析過程就稍微複雜了些,如下面程式碼:

//foo.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
<html>
<body>
    <div>1</div>
    <script type="text/javascript" src='foo.js'></script>
    <div>test</div>
</body>
</html>

這段程式碼的功能還是和前面那段程式碼是一樣的,不過這裡我把內嵌 JavaScript 指令碼修改成了通過 JavaScript 檔案載入。其整個執行流程還是一樣的,執行到 JavaScript 標籤時,暫停整個 DOM 的解析,執行 JavaScript 程式碼,不過這裡執行 JavaScript 時,需要先下載這段 JavaScript 程式碼。這裡需要重點關注下載環境,因為 JavaScript 檔案的下載過程會阻塞 DOM 解析,而通常下載又是非常耗時的,會受到網路環境、JavaScript 檔案大小等因素的影響。

不過 Chrome 瀏覽器做了很多優化,其中一個主要的優化是預解析操作。當渲染引擎收到位元組流之後,會開啟一個預解析執行緒,用來分析 HTML 檔案中包含的 JavaScript、CSS 等相關檔案,解析到相關檔案之後,預解析執行緒會提前下載這些檔案。

再回到 DOM 解析上,我們知道引入 JavaScript 執行緒會阻塞 DOM,不過也有一些相關的策略來規避,比如使用 CDN 來加速 JavaScript 檔案的載入,壓縮 JavaScript 檔案的體積。另外,如果 JavaScript 檔案中沒有操作 DOM 相關程式碼,就可以將該 JavaScript 指令碼設定為非同步載入,通過 async 或 defer 來標記程式碼,使用方式如下所示:

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

async 和 defer 雖然都是非同步的,不過還有一些差異,使用 async 標誌的指令碼檔案一旦載入完成,會立即執行;而使用了 defer 標記的指令碼檔案,需要在 DOMContentLoaded 事件之前執行。

現在我們知道了 JavaScript 是如何阻塞 DOM 解析的了,那接下來我們再來結合文中程式碼看看另外一種情況:

//theme.css
div {color:blue}
<html>
    <head>
        <style src='theme.css'></style>
    </head>
<body>
    <div>1</div>
    <script>
            let div1 = document.getElementsByTagName('div')[0]
            div1.innerText = 'time.geekbang' //需要DOM
            div1.style.color = 'red'  //需要CSSOM
        </script>
    <div>test</div>
</body>
</html>

該示例中,JavaScript 程式碼出現了 div1.style.color = ‘red' 的語句,它是用來操縱 CSSOM 的,所以在執行 JavaScript 之前,需要先解析 JavaScript 語句之上所有的 CSS 樣式。所以如果程式碼裡引用了外部的 CSS 檔案,那麼在執行 JavaScript 之前,還需要等待外部的 CSS 檔案下載完成,並解析生成 CSSOM 物件之後,才能執行 JavaScript 指令碼。

而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操縱了 CSSOM 的,所以渲染引擎在遇到 JavaScript 指令碼時,不管該指令碼是否操縱了 CSSOM,都會執行 CSS 檔案下載,解析操作,再執行 JavaScript 指令碼。

所以說 JavaScript 指令碼是依賴樣式表的,這又多了一個阻塞過程。至於如何優化,我們在下篇文章中再來深入探討。

通過上面的分析,我們知道了 JavaScript 會阻塞 DOM 生成,而樣式檔案又會阻塞 JavaScript 的執行,所以在實際的工程中需要重點關注 JavaScript 檔案和樣式表文件,使用不當會影響到頁面效能的。

總結

好了,今天就講到這裡,下面我來總結下今天的內容。

首先我們介紹了 DOM 是如何生成的,然後又基於 DOM 的生成過程分析了 JavaScript 是如何影響到 DOM 生成的。因為 CSS 和 JavaScript 都會影響到 DOM 的生成,所以我們又介紹了一些加速生成 DOM 的方案,理解了這些,能讓你更加深刻地理解如何去優化首次頁面渲染。

額外說明一下,渲染引擎還有一個安全檢查模組叫 XSSAuditor,是用來檢測詞法安全的。在分詞器解析出來 Token 之後,它會檢測這些模組是否安全,比如是否引用了外部指令碼,是否符合 CSP 規範,是否存在跨站點請求等。如果出現不符合規範的內容,XSSAuditor 會對該指令碼或者下載任務進行攔截。詳細內容我們會在後面的安全模組介紹,這裡就不贅述了。

思考時間

看下面這樣一段程式碼,你認為開啟這個 HTML 頁面,頁面顯示的內容是什麼?

<html>
<body>
    <div>1</div>
    <script>
    let div1 = document.getElementsByTagName('div')[0]
    div1.innerText = 'time.geekbang'

    let div2 = document.getElementsByTagName('div')[1]
    div2.innerText = 'time.geekbang.com'
    </script>
    <div>test</div>
</body>
</html>

歡迎在留言區與我分享你的想法,也歡迎你在留言區記錄你的思考過程。感謝閱讀,如果你覺得這篇文章對你有幫助的話,也歡迎把它分享給更多的朋友。

問題記錄

一、

CSS不阻塞dom的生成。
CSS不阻塞js的載入,但是會阻塞js的執行。
js會阻塞dom的生成,也就是會阻塞頁面的渲染,那麼css也有可能會阻塞頁面的渲染。
如果把CSS放在文件的最後面載入執行,CSS不會阻塞DOM的生成,也不會阻塞JS,但是瀏覽器在解析完DOM後,要花費額外時間來解析CSS,而不是在解析DOM的時候,並行解析CSS。
並且瀏覽器會先渲染出一個沒有樣式的頁面,等CSS載入完後會再渲染成一個有樣式的頁面,頁面會出現明顯的閃動的現象。
所以應該把CSS放在文件的頭部,儘可能的提前載入CSS;把JS放在文件的尾部,這樣JS也不會阻塞頁面的渲染。CSS會和JS並行解析,CSS解析也儘可能的不去阻塞JS的執行,從而使頁面儘快的渲染完成。

老師文章不是說瀏覽器有預檢查機制嗎,會提前下載檔案呀

performance 試了下 外部 css 貌似並不會阻塞內聯 script 指令碼的執行

應該說是CSS和HTML並行解析吧

並不是哦 可以開啟 performance 面板看一下 Parse Stylesheet 和 Parse HTML 都是分開在渲染主執行緒執行的

確實是這樣,那為什麼說css不阻塞dom的解析?

把JS放在文件的尾部,js執行的時候也是會阻塞頁面的渲染,你可以寫個demo測試下

js無論放在哪裡,執行的時候一定會阻塞頁面的渲染。我理解,放到文件尾部,是為了不阻塞在這個指令碼之前的元素的渲染

二、

會顯示time.geekbang和test,JavaScript程式碼執行的時候第二個div還沒有生成DOM節點,所以是獲取不到div2的,頁面會報錯Uncaught TypeError: Cannot set property 'innerText' of undefined。

另外複習了下async和defer:

async:指令碼並行載入,載入完成之後立即執行,執行時機不確定,仍有可能阻塞HTML解析,執行時機在load事件派發之前

defer:指令碼並行載入,等待HTML解析完成之後,按照載入順序執行指令碼,執行時機在DOMContentLoaded事件派發之前

Async和defer的應用都是避免解析阻塞。區別是async非同步下載完畢之後立即執行,defer會在解析完畢,dom建立之後,但是DOMContentLoaded 事件觸發之前按照script在document中出場的順序依次執行。

兩種執行時機有點疑問?load不是資源載入完就觸發嗎?為什麼會跟js開始執行時機有順序關係?defer也一樣的疑問

三、

開始看文章的時候就在想如果js獲取的dom還沒有解析出來,會如何處理,結果思考題就是這個。

會兩行顯示,一行是time.geekbang 另外一行是test。原因是script指令碼執行的時候獲取想不到第二個div,所以不會對後來的div有影響。

今日總結:
1. 首先介紹了什麼是DOM(表述渲染引擎內部資料結構,它將Web頁面和JavaScript指令碼連線起來,並過濾不安全內容)、DOM樹如何生成(網路程序和渲染程序建立一個流式管道,HTML解析器直接解析,不需要等待text/html型別的介面 接受完畢再進行解析),第一步:通過分詞器將位元組流轉換為Token;第二步:將Token解析為DOM節點;第三步:將DOM節點新增到DOM樹中。
2. JavaScript是如何影響DOM生成的?暫停html解析,下載解析執行完畢js之後再進行html解析(如果這期間使用到了cssDom,需要等待相應css過程)。預解析執行緒的優化(提前載入相應js css檔案)
3. 渲染引擎還有一個安全檢查模組XSSAuditor用來檢測詞法安全的

四、網路程序載入了多少資料,HTML 解析器便解析多少資料。這裡有一個問題,如果是邊載入邊解析,那麼一個標籤還在網路傳輸過程中,瀏覽器還沒有接受到script這個詞段,那麼瀏覽器又是怎麼預載入的呢?

五、老師請問一下:主執行緒在parseHtml時,是不是沒辦法執行執行paint等操作、那這時候頁面又是如何繪製出來的呀?

老師回覆:如果正在執行ParserHTML,那麼頁面只會顯示之前繪製好的內容,舉個極端點的例子,比如ParserHTML佔用了主執行緒10秒,那麼這10秒內,頁面都不會發生新的繪製操作,也就是頁面卡頓了10秒!

六、您好,網路程序接收到響應頭之後,會根據請求頭中的 content-type 欄位來判斷檔案的型別,比如 content-type 的值是“text/html”!
這個地方應該是根據響應頭判斷檔案型別吧?

老師回覆: 嗯 是響應頭,我改過來

七、我看MDN寫的是defer在DOMContentLoaded 前執行

老師回覆: 你是對的,我寫錯了。