天天看點

DOM樹:JavaScript是如何影響DOM樹建構的

在上一篇文章中,我們通過開發者工具中的網絡面闆,介紹了網絡請求過程的幾種性能名額以及對頁面加載的影響。

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

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

從頁面的視角來看,DOM 是生成頁面的基礎資料結構。

從 JavaScript 腳本視角來看,DOM 提供給 JavaScript 腳本操作的接口,通過這套接口,JavaScript 可以對 DOM 結構進行通路,進而改變文檔的結構、樣式和内容。

從安全視角來看,DOM 是一道安全防護線,一些不安全的内容在 DOM 解析階段就被拒之門外了。

簡言之,DOM 是表述 HTML 的内部資料結構,它會将 Web 頁面和 JavaScript 腳本連接配接起來,并過濾一些不安全的内容。

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

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

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

那詳細的流程是怎樣的呢?網絡程序接收到響應頭之後,會根據響應頭中的 content-type 字段來判斷檔案的類型,比如 content-type 的值是“text/html”,那麼浏覽器就會判斷這是一個 HTML 類型的檔案,然後為該請求選擇或者建立一個渲染程序。渲染程序準備好之後,網絡程序和渲染程序之間會建立一個共享資料的管道,網絡程序接收到資料後就往這個管道裡面放,而渲染程序則從管道的另外一端不斷地讀取資料,并同時将讀取的資料“喂”給 HTML 解析器。你可以把這個管道想象成一個“水管”,網絡程序接收到的位元組流像水一樣倒進這個“水管”,而“水管”的另外一端是渲染程序的 HTML 解析器,它會動态接收位元組流,并将其解析為 DOM。

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

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

DOM樹:JavaScript是如何影響DOM樹建構的

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

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

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

DOM樹:JavaScript是如何影響DOM樹建構的

由圖可以看出,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 解析器,經過分詞器處理,解析出來的第一個 Token 是 StartTag html,解析出來的 Token 會被壓入到棧中,并同時建立一個 html 的 DOM 節點,将其加入到 DOM 樹中。

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

DOM樹:JavaScript是如何影響DOM樹建構的

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

DOM樹:JavaScript是如何影響DOM樹建構的

接下來解析出來的是第一個 div 的文本 Token,渲染引擎會為該 Token 建立一個文本節點,并将該 Token 添加到 DOM 中,它的父節點就是目前 Token 棧頂元素對應的節點,如下圖所示:

DOM樹:JavaScript是如何影響DOM樹建構的

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

DOM樹:JavaScript是如何影響DOM樹建構的

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

DOM樹:JavaScript是如何影響DOM樹建構的

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

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

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

通過前面 DOM 生成流程分析,我們已經知道當解析到 script 腳本标簽時,其 DOM 樹結構如下所示:

DOM樹:JavaScript是如何影響DOM樹建構的

這時候 HTML 解析器暫停工作,JavaScript 引擎介入,并執行 script 标簽中的這段腳本,因為這段 JavaScript 腳本修改了 DOM 中第一個 div 中的内容,是以執行這段腳本之後,div 節點内容已經修改為 time.geekbang 了。腳本執行完成之後,HTML 解析器恢複解析過程,繼續解析後續的内容,直至生成最終的 DOM。

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

這段代碼的功能還是和前面那段代碼是一樣的,不過這裡我把内嵌 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 來标記代碼,使用方式如下所示:

async 和 defer 雖然都是異步的,不過還有一些差異,使用 async 标志的腳本檔案一旦加載完成,會立即執行;而使用了 defer 标記的腳本檔案,需要在 DOMContentLoaded 事件之前執行。

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

該示例中,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 會對該腳本或者下載下傳任務進行攔截。詳細内容我們會在後面的安全子產品介紹,這裡就不贅述了

繼續閱讀