從Chrome源碼看瀏覽器如何構建DOM樹


最近下了Chrome的源碼,安裝了一個debug版的Chromium研究了一下,雖然很多地方都一知半解,但是還是有一點收獲,將在這篇文章介紹DOM樹是如何構建的,看了本文應該可以回答以下問題:

  1. IE用的是Trident內核,Safari用的是Webkit,Chrome用的是Blink,到底什么是內核,它們的區別是什么?
  2. 如果沒有聲明<!DOCTYPE html>會造成什么影響?
  3. 瀏覽器如何處理自定義的標簽,如寫一個<data></data>?
  4. 查DOM的過程是怎么樣的?

先說一下,怎么安裝一個可以debug的Chrome

1. 從源碼安裝Chrome

為了可以打斷點debug,必須得從頭編譯(編譯的時候帶上debug參數)。所以要下載源碼,Chrome把最新的代碼更新到了Chromium的工程,是完全開源的,你可以把它整一個git工程下載下來。Chromium的下載安裝可參考它的文檔, 這里把一些關鍵點說一下,以Mac為例。你需要先下載它的安裝腳本工具,然后下載源碼:

–no-history的作用是不把整個git工程下載下來,那個實在是太大了。或者是直接執行git clone:

這個就是整一個git工程,下載下來有6.48GB(那時)。博主就是用的這樣的方式,如果下載到最后提示出錯了:

可以這樣解決:

就不用重頭開始clone,因為實在太大、太耗時了。

下載好之后生成build的文件:

–ide=xcode是為了能夠使用蘋果的XCode進行可視化進行調試。gn命令要下載Chrome的devtools包,文檔里面有說明。

裝備就緒之后就可以進行編譯了:

在筆者的電腦上編譯了3個小時,firfox的源碼需要編譯7、8個小時,所以相對來說已經快了很多,同時沒報錯,一次就過,相當順利。編譯組裝好了之后,會在out/gn目錄生成Chromium的可執行文件,具體路徑是在:

運行這個就可以打開Chromium了:

那么怎么在可視化的XCode里面進行debug呢?

2. 在XCode里面進行Debug

在上面生成build文件的同時,會生成XCode的工程文件:sources.xcodeproj,具體路徑是在:

雙擊這個文件,打開XCode,在上面的菜單欄里面點擊Debug -> AttachToProcess -> Chromium,要先打開Chrome,才能在列表里面看到Chrome的進程。然后小試牛刀,打個斷點試試,看會不會跑進來:

在左邊的目錄樹,打開chrome/browser/devtools/devtools_protocol.cc這個文件,然后在這個文件的ParseCommand函數里面打一個斷點,按照字面理解這個函數應該是解析控制台的命令。打開Chrome的控制台,輸入一條命令,例如:new Date(),按回車可以看到斷點生效了:

通過觀察變量值,可以看到剛剛敲進去的命令。這就說明了我們安裝成功,並且可以通過可視化的方式進行調試。

但是我們要debug頁面渲染過程,Chrome的blink框架使用多進程技術,每打開一個tab都會新開一個進程,按上面的方式是debug不了構建DOM過程的,從Chromium的文檔可以查到,需要在啟動的時候帶上一個參數:

Chrom的啟動進程就會緒塞,並且提示它的渲染進程ID:

[7339:775:0102/210122.254760:ERROR:child_process.cc(145)] Renderer (7339) paused waiting for debugger to attach. Send SIGUSR1 to unpause.

7339就是它的渲染進程id,在XCode里面點 Debug -> AttachToProcess By Id or Name -> 填入id -> 確定,attach之后,Chrome進程就會恢復,然后就可以開始調試渲染頁面的過程了。

content/renderer/render_view_impl.cc這個文件的1093行RenderViewImpl::Create函數里面打個斷點,按照上面的方式,重新啟動Chrome,在命令行帶上某個html文件的路徑,為了打開Chrome的時候就會同時打開這個文件,方便調試。執行完之后就可以看到斷點生效了。可以說render_view_impl.cc這個文件是第一個具體開始渲染頁面的文件——它會初始化頁面的一些默認設置,如字體大小、默認的viewport等,響應關閉頁面、OrientationChange等事件,而在它再往上的層主要是一些負責通信的類。

3. Chrome建DOM源碼分析

先畫出構建DOM的幾個關鍵的類的UML圖,如下所示:

第一個類HTMLDocumentParser負責解析html文本為tokens,一個token就是一個標簽文本的序列化,並借助HTMLTreeBuilder對這些tokens分類處理,根據不同的標簽類型、在文檔不同位置,調用HTMLConstructionSite不同的函數構建DOM樹。而HTMLConstructionSite借助一個工廠類對不同類型的標簽創建不同的html元素,並建立起它們的父子兄弟關系,其中它有一個m_document的成員變量,這個變量就是這棵樹的根結點,也是js里面的window.document對象。

為作說明,用一個簡單的html文件一步步看這個DOM樹是如何建立起來的:

然后按照上面第2點提到debug的方法,打開Chromium並開始debug:

我們先來研究一下Chrome的加載和解析機制

1. 加載機制

以發http請求去加載html文本做為我們分析的第一步,在此之前的一些初始化就不考慮了。Chrome是在DocumentLoader這個類里面的startLoadingMainResource函數里去加載url返回的數據,如訪問一個網站則返回html文本:

把m_request打印出來,在這個函數里面加一行代碼:

並重新編譯Chrome運行,控制台輸出:

[22731:775:0107/224014.494114:INFO:DocumentLoader.cpp(719)] request url is: “file:///Users/yincheng/demo.html”

可以看到,這個url確實是我們傳進的參數。

發請求后,每次收到的數據塊,會通過Blink封裝的IPC進程間通信,觸發DocumentLoader的dataReceived函數,里面會去調它commitData函數,開始處理具體業務邏輯:

這個函數關鍵行是最2行和第7行,ensureWriter這個函數會去初始化上面畫的UML圖的解析器HTMLDocumentParser (Parser),並實例化document對象,這些實例都通過實例m_writer去帶動的。也就是說,writer會去實例化Parser,然后第7行writer傳遞數據給Parser去解析。

檢查一下收到的數據bytes是什么東西:

可以看到bytes就是請求返回的html文本。

在ensureWriter函數里面有個判斷:

如果m_writer已經初始化過了,則直接返回。也就是說Parser和document只會初始化一次。

在上面的addData函數里面,會啟動一條線程執行Parser的任務:

並把數據傳遞給這條線程進行解析,Parser一旦收到數據就會序列成tokens,再構建DOM樹。

2. 構建tokens

這里我們只要關注序列化后的token是什么東西就好了,為此,寫了一個函數,把tokens的一些關鍵信息打印出來:

打印出來的結果:

這些內容有標簽名、類型、屬性和innerText,標簽之間的文本(換行和空白)也會被當作一個標簽處理。Chrome總共定義了7種標簽類型:

有了一個根結點document和一些格式化好的tokens,就可以構建dom樹了。

3. 構建DOM樹

(1)DOM結點

在研究這個過程之前,先來看一下一個DOM結點的數據結構是怎么樣的。以p標簽HTMLParagraphElement為例,畫出它的UML圖,如下所示:

Node是最頂層的父類,它有三個指針,兩個指針分別指向它的前一個結點和后一個結點,一個指針指向它的父結點;

ContainerNode繼承於Node,添加了兩個指針,一個指向第一個子元素,另一個指向最后一個子元素;

Element又添加了獲取dom結點屬性、clientWidth、scrollTop等函數

HTMLElement又繼續添加了Translate等控制,最后一級的子類HTMLParagraphElement只有一個創建的函數,但是它繼承了所有父類的屬性。

需要提到的是每個Node都組合了一個treeScope,這個treeScope記錄了它屬於哪個document(一個頁面可能會嵌入iframe)。

構建DOM最關鍵的步驟應該是建立起每個結點的父子兄弟關系,即上面提到的成員指針的指向。

到這里我們可以先回答上面提出的第一個問題,什么是瀏覽器內核

(2)瀏覽器內核

瀏覽器內核也叫渲染引擎,上面已經看到了Chrome是如何實例化一個P標簽的,而從firefox的源碼里面P標簽的依賴關系是這樣的:

在代碼實現上和Chrome沒有任何關系。這就好像W3C出了道題,firefox給了一個解法,取名為Gecko,Safari也給了自己的答案,取名Webkit,Chrome覺得Safari的解法比較好直接拿過來用,又結合自身的基礎又封裝了一層,取名Blink。由於W3C出的這道題“開放性”比較大,出的時間比較晚,導致各家實現各有花樣。

明白了這點后,繼續DOM構建。下面開始不再說Chrome,叫Webkit或者Blink應該更准確一點

(3)處理開始步驟

Webkit把tokens序列好之后,傳遞給構建的線程。在HTMLDocumentParser::processTokenizedChunkFromBackgroundParser的這個函數里面會做一個循環,把解析好的tokens做一個遍歷,依次調constructTreeFromCompactHTMLToken進行處理。

根據上面的輸出,最開始處理的第一個token是docType的那個:

在那個函數里面,首先Parser會調TreeBuilder的函數:

然后在TreeBuilder里面根據token的類型做不同的處理:

它會對不同類型的結點做相應處理,從上往下依次是文本節點、doctype節點、開標簽、閉標簽。doctype這個結點比較特殊,單獨作為一種類型處理

(3)DOCType處理

在Parser處理doctype的函數里面調了HTMLConstructionSite的插入doctype的函數:

在這個函數里面,它會先創建一個doctype的結點,再創建插dom的task,並設置文檔類型:

我們來看一下不同的doctype對文檔類型的設置有什么影響,如下:

如果tagName不是html,那么文檔類型將會是怪異模式,以下兩種就會是怪異模式:

而常用的html4寫法:

在源碼里面這個將是有限怪異模式:

上面的systemId就是”http://www.w3.org/TR/html4/loose.dtd”,它不是空的,所以判斷成立。而如果systemId為空,則它將是怪異模式。如果既不是怪異模式,也不是有限怪異模式,那么它就是標准模式:

常用的html5的寫法就是標准模式,如果連DOCType聲明也沒有呢?那么會默認設置為怪異模式:

這些模式有什么區別,從源碼注釋可窺探一二:

大意是說,怪異模式會模擬IE,同時CSS解析會比較寬松,例如數字單位可以省略,而有限怪異模式和標准模式的唯一區別在於在於對inline元素的行高處理不一樣。標准模式將會讓頁面遵守文檔規定。

怪異模式下的input和textarea的默認盒模型將會變成border-box:

標准模式下的文檔高度是實際內容的高度:


而在怪異模式下的文檔高度是窗口可視域的高度:

在有限怪異模式下,div里面的圖片下方不會留空白,如下圖左所示;而在標准模式下td下方會留點空白,如下圖右所示:

 

 

 

 

 

這個空白是div的行高撐起來的,當把div的行高設置成0的時候,就沒有下面的空白了。在怪異模和有限怪異模式下,為了計算行內子元素的最小高度,一個塊級元素的行高必須被忽略。

這里的敘述雖然跟解讀源碼沒有直接的關系(我們還沒解讀到CSS處理),但是很有必要提一下。

接下來我們開始正式說明DOM構建

(4)開標簽處理

下一個遇到的開標簽是<html>標簽,處理這個標簽的任務應該是實例化一個HTMLHtmlElement元素,然后把它的父元素指向document。Webkit源碼里面使用了一個m_attachmentRoot的變量記錄attach的根結點,初始化HTMLConstructionSite也會初始化這個變量,值為document:

所以html結點的父結點就是document,實際的操作過程是這樣的:

第二行先創建一個html結點,第三行把它加到一個任務隊列里面,傳遞兩個參數,第一個參數是父結點,第二個參數是當前結點,第五行執行隊列里面的任務。代碼第四行會把它壓到一個棧里面,這個棧存放了未遇到閉標簽的所有開標簽。

第三行attachLater是如何建立一個task的:

代碼邏輯比較簡單,比較有趣的是發現DOM樹有一個最大的深度:maximumHTMLParserDOMTreeDepth,超過這個最大深度就會把它子元素當作父無素的同級節點,這個最大值是多少呢?512:

我們重點關注executeQueuedTasks干了些什么,它會根據task的類型執行不同的操作,由於本次是insert的,它會去執行一個插入的函數:

在插入里面它會先去檢查父元素是否支持子元素,如果不支持,則直接返回,就像video標簽不支持子元素。然后再去調具體的插入:

上面代碼第二行,設置子元素的父結點,也就是會把html結點的父結點指向document,然后如果沒有lastChild,會將這個子元素作為firstChild,由於上面已經有一個docype的子結點了,所以已經有lastChild了,因此會把這個子元素的previousSibling指向老的lastChild,老的lastChild的nexSibling指向它。最后倒數第二行再把子元素設置為當前ContainerNode(即document)的lastChild。這樣就建立起了html結點的父子兄弟關系。

可以看到,借助上一次的m_lastChild建立起了兄弟關系

這個時候你可能會有一個問題,為什么要用一個task隊列存放將要插入的結點呢,而不是直接插入呢?一個原因放到task里面方便統一處理,並且有些task可能不能立即執行,要先存起來。不過在我們這個案例里面都是存完后下一步就執行了。

 

當遇到head標簽的token時,也是先創建一個head結點,然后再創建一個task,插到隊列里面:

attachLater傳參的第一個參數為父結點,這個currentNode為開標簽棧里面的最頂的元素:

我們剛剛把html元素壓了進去,則棧頂元素為html元素,所以head的父結點就為html。所以每當遇到一個開標簽時,就把它壓起來,下一次再遇到一個開標簽時,它的父元素就是上一個開標簽。

所以,初步可以看到,借助一個棧建立起了父子關系

而當遇到一個閉標簽呢?

(5)處理閉標簽

當遇到一個閉標簽時,會把棧里面的元素一直pop出來,直到pop到第一個和它標簽名字一樣的:

我們第一個遇到的是閉標簽是head標簽,它會把開的head標簽pop出來,棧里面就剩下html元素了,所以當再遇到body時,html元素就是body的父元素了。

這個是棧的一個典型應用。

以下面的html為例來研究壓棧和出棧的過程:

把push和pop打印出來是這樣的:

這個過程確實和上面的描述一致,遇到一個閉標簽就把一次的開標簽pop出來。

並且可以發現遇到body閉標簽后,並不會把body給pop出來,因為如果body閉標簽后面又再寫了標簽的話,就會自動當成body的子元素。

假設上面的b標簽的閉標簽忘記寫了,又會發生什么:

打印出來的結果是這樣的:

同樣地,在上面第3行,遇到P閉標簽時,會把所有的開標簽pop出來,直到遇到P標簽。不同的是后續的過程中會不斷地插入b標簽,最后渲染的頁面結構:

因為b等帶有格式化的標簽會特殊處理,遇到一個開標簽時會它們放到一個列表里面:

遇到一個閉標簽時,又會從這個列表里面刪掉。每處理一個新標簽時就會進行檢查和這個列表和棧里的開標簽是否對應,如果不對應則會reconstruct:重新插入一個開標簽。因此b就不斷地被重新插入,直到遇到下一個b的閉標簽為止。

如果上面少寫的是一個span,那么渲染之后的結果是正常的:

而對於文本節點是實例化了Text的對象,這里不再展開討論。

(6)自定義標簽的處理

在瀏覽器里面可以看到,自定義標簽默認不會有任何的樣式,並且它默認是一個行內元素:

初步觀察它和span標簽的表現是一樣的:

在blink的源碼里面,不認識的標簽默認會被實例化成一個HTMLUnknownElement,這個類對外提供了一個create函數,這和HTMLSpanElement是一樣的,只有一個create函數,並且大家都是繼承於HTMLElement。並且創建span標簽的時候和unknown一樣,並沒有做特殊處理,直接調的create。所以從本質上來說,可以把自定義的標簽當作一個span看待。然后你可以再設置display: block改成塊級元素之類的。

但是你可以用js定義一個自定義標簽,定義它的屬性等,Webkit會去讀它的定義:

例如給自定義標簽創建一個原生屬性:

上面定義了一個country,為了可以直接獲取這個屬性:

注冊一個自定義標簽:

這個HighSchoolElement繼承於HTMLElement:

就可以直接取到contry這個屬性,而不用通過getAttribute的函數,並且可以在屬性發生變化時更新元素的渲染,改變color等。詳見Custom Elements – W3C.

通過這種方式創建的,它就不是一個HTMLUnknownElement了。blink通過V8引擎把js的構造函數轉化成C++的函數,實例化一個HTMLElement的對象。

最后再來看查DOM的過程

4. 查DOM過程

(1)按ID查找

在頁面添加一個script:

Chrome的V8引擎把js代碼層層轉化,最后會調:

而這個函數又會調TreeScope的getElementById的函數,TreeScope存儲了一個m_map的哈希map,這個map以標簽id字符串作為key值,Element為value值,我們可以把這個map打印出來:

html結構是這樣的:

打印出來的結果為:

可以看到, 這個m_map把頁面所有有id的標簽都存了進來。由於map的查找時間復雜度為O(1),所以使用ID選擇器可以說是最快的。

再來看一下類選擇器:

(2)類選擇器

js如下:

在執行第一行的時候,Webkit返回了一個ClassCollection的列表:

而這個列表並不是去查DOM獲取的,它只是記錄了className作為標志。這與我們的認知是一致的,這種HTMLCollection的數據結構都是在使用的時候才去查DOM,所以在上面第二行去獲取它的length,就會觸發它的查DOM,在nodeCount這個函數里面執行:

第一行先獲取符合collection條件的第一個結點,然后不斷獲取下一個符合條件的結點,直到null,並把它存到一個cachedList里面,下次再獲取這個collection的東西時便不用再重復查DOM,只要cached仍然是有效的:

怎么樣找到有效的節點呢:

第一行先獲取第一個節點,如果它沒有match,則繼續next,直到找到符合條件或者空為止。我們的重點在於,它是怎么遍歷的,如何next獲取下一個節點,核心代碼:

第一行先判斷當前節點有沒有子元素,如果有的話返回它的第一個子元素,如果當前節點沒有子元素,並且這個節點就是開始找的根元素(document.getElement,則為document),則說明沒有下一個元素了,直接返回0/null。如果這個節點不是根元素了(例如已經到了子元素這一層了),那么看它有沒有相鄰元素,如果有則返回下一下相鄰元素,如果相鄰無素也沒有了,因為它是一個葉子結點(沒有子元素),說明它已經到了最深的一層,並且是當前層的最后一個葉子結點,那就返回它的父元素的下個相鄰節點。可以看出這是一個深度優先的查找

(3)querySelector

a)先來看下selector為一個id時發生了什么:

它會調ContainerNode的querySelecotr函數:

先把輸入的selector字符串序列化成一個selectorQuery,然后再queryFirst,通過打斷點可以發現,它最后會調的TreeScope的getElementById:

b)如果selector為一個class:

它會從document開始遍歷:

我們重點查看它是怎么遍歷,即第一行的for循環。表面上看它好像把所有的元素取出來然后做個循環,其實不然,它是重載++操作符:

只要我們看下next是怎么操作的就可以得知它是怎么遍歷,而這個next跟上面的講解class時是一樣的。不一樣的是match條件判斷是:有className,並且className列表里面包含這個class,如上面代碼第二行。

c)復雜選擇器

例如寫兩個class:

最終也會轉成一個遍歷,只是判斷是否match的條件不一樣:

怎么判斷是否match比較復雜,這里不再展開討論。

同時在源碼可以看到,如果是怪異模式,會調一個executeSlow的查詢,並且判斷match條件也不一樣。不過遍歷是一樣的。

 

查看源碼確實是一件很費時費力的工作,但是通過一番探索,能夠了解瀏覽器的一些內在機制,至少已經可以回答上面提出來的幾個問題。同時知道了Webkit/Blink借助一個棧,結合開閉標簽,一步步構建DOM樹,並對DOCType的標簽、自定義標簽的處理有了一定的了解。最后又討論了查DOM的幾種情況,明白了查找的過程。

通過上面的分析,對頁面渲染的第一步構建DOM應該會有一個基礎的了解。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM