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

最近下了Chrome的源碼,安裝了一個debug版的Chromium研究了一下,雖然不少地方都只知其一;不知其二,可是仍是有一點收穫,將在這篇文章介紹DOM樹是如何構建的,看了本文應該能夠回答如下問題:html

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

先說一下,怎麼安裝一個能夠debug的Chromehtml5

1. 從源碼安裝Chrome

爲了能夠打斷點debug,必須得從頭編譯(編譯的時候帶上debug參數)。因此要下載源碼,Chrome把最新的代碼更新到了Chromium的工程,是徹底開源的,你能夠把它整一個git工程下載下來。Chromium的下載安裝可參考它的文檔, 這裏把一些關鍵點說一下,以Mac爲例。你須要先下載它的安裝腳本工具,而後下載源碼:node

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

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

能夠這樣解決:chrome

就不用重頭開始clone,由於實在太大、太耗時了。xcode

下載好以後生成build的文件:瀏覽器

–ide=xcode是爲了可以使用蘋果的XCode進行可視化進行調試。gn命令要下載Chrome的devtools包,文檔裏面有說明。數據結構

裝備就緒以後就能夠進行編譯了:app

在筆者的電腦上編譯了3個小時,firfox的源碼須要編譯七、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應該會有一個基礎的瞭解。

相關文章
相關標籤/搜索