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

會程式設計的銀豬發表於2017-02-06

這幾天下了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為例。你需要先下載它的安裝指令碼工具,然後下載原始碼:

fetch chromium --no-history

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

git clone https://chromium.googlesource.com/chromium/src

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

fatal: The remote end hung up unexpectedly
fatal: early EOF
fatal: index-pack failed

可以這樣解決:

git config --global core.compression 0
git clone --depth 1 https://chromium.googlesource.com/chromium/src

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

下載好之後生成build的檔案:

gn gen out/gn --ide=xcode

–ide=xcode是為了能夠使用蘋果的XCode進行視覺化進行除錯。gn命令要下載Chrome的devtools包,文件裡面有說明。

準備就緒之後就可以進行編譯了:

ninja -C out/gn chrome

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

out/gn/Chromium.app/Contents/MacOS/Chromium

執行這個就可以開啟Chromium了:

那麼怎麼在視覺化的XCode裡面進行debug呢?

2. 在XCode裡面Debug

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

out/gn/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的文件可以查到,需要在啟動的時候帶上一個引數:

Chromium --renderer-startup-dialog

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樹是如何建立起來的:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
</head>
<body>
    <div>
        <h1 class="title">demo</h1>
        <input value="hello">
    </div>
</body>
</html>

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

chromium ~/demo.html --renderer-startup-dialog

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

1. 載入機制

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

  FetchRequest fetchRequest(m_request, FetchInitiatorTypeNames::document,
                            mainResourceLoadOptions);
  m_mainResource =
      RawResource::fetchMainResource(fetchRequest, fetcher(), m_substituteData);

把引數裡的m_request列印出來,在這個函式裡面加一行程式碼:

LOG(INFO) << "request url is: " << m_request.url().getString()

並重新編譯Chrome執行,控制檯輸出:

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

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

發請求後,每次收到的資料塊,會通過Blink封裝的IPC程式間通訊,觸發DocumentLoader的dataReceived函式,裡面會去調它commitData函式,開始處理具體業務邏輯:

void DocumentLoader::commitData(const char* bytes, size_t length) {
  ensureWriter(m_response.mimeType());

  if (length)
    m_dataReceived = true;

  m_writer->addData(bytes, length);
}

這個函式關鍵行是最2行和第7行,ensureWriter這個函式會去初始化上面畫的UML圖的解析器HTMLDocumentParser (Parser),並例項化document物件,這些物件都是通過例項m_writer去帶動的。也就是說,writer會去例項化Parser之後,第7行writer傳遞資料給Parser去解析。

檢查一下收到的資料bytes是什麼東西:

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

在ensureWriter函式裡面有個判斷:

void DocumentLoader::ensureWriter(const AtomicString& mimeType,
                                  const KURL& overridingURL) {
  if (m_writer)
    return;

}

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

在上面的addData函式裡面,會啟動一條執行緒執行Parser的任務:

if (!m_haveBackgroundParser)
      startBackgroundParser();

並把資料傳遞給這條執行緒進行解析,Parser一旦收到資料就會序列成tokens,再構建DOM樹。

2. 構建tokens

這裡我們只要關注序列化後的token是什麼東西就好了,為此,寫了一個函式,把tokens的一些關鍵資訊列印出來:

  String getTokenInfo(){
    String tokenInfo = "";
    tokenInfo = "tagName: " + this->m_name + "|type: " + getType() + "|attr:" + getAttributes() + "|text: " + this->m_data;
    return tokenInfo;
  }

列印出來的結果:

tagName: html  |type: DOCTYPE   |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: html  |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: head  |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n    "
tagName: meta  |type: startTag  |attr:charset=utf-8 |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: head  |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: body  |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n    "
tagName: div   |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n        "
tagName: h1    |type: startTag  |attr:class=title   |text: "
tagName:       |type: Character |attr:              |text: demo"
tagName: h1    |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n        "
tagName: input |type: startTag  |attr:value=hello   |text: "
tagName:       |type: Character |attr:              |text: \n    "
tagName: div   |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text:     \n"
tagName: body  |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: html  |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName:       |type: EndOfFile |attr:              |text: "

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

  enum TokenType {
    Uninitialized,
    DOCTYPE,
    StartTag,
    EndTag,
    Comment,
    Character,
    EndOfFile,
  };

有了一個根結點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的那個:

"tagName: html  |type: DOCTYPE   |attr:              |text: "

在那個函式裡面,首先Parser會調TreeBuilder的函式:

m_treeBuilder->constructTree(&token);

然後在TreeBuilder裡面根據token的型別做不同的處理:

void HTMLTreeBuilder::processToken(AtomicHTMLToken* token) {
  if (token->type() == HTMLToken::Character) {
    processCharacter(token);
    return;
  }

  switch (token->type()) {
    case HTMLToken::DOCTYPE:
      processDoctypeToken(token);
      break;
    case HTMLToken::StartTag:
      processStartTag(token);
      break;
    case HTMLToken::EndTag:
      processEndTag(token);
      break;
    //othercode
  }
}

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

(3)DOCType處理

在Parser處理doctype的函式裡面調了HTMLConstructionSite的插入doctype的函式:

void HTMLTreeBuilder::processDoctypeToken(AtomicHTMLToken* token) {
    m_tree.insertDoctype(token);
    setInsertionMode(BeforeHTMLMode);
}

在這個函式裡面,它會先建立一個doctype的結點,再建立插dom的task,並設定文件型別:

void HTMLConstructionSite::insertDoctype(AtomicHTMLToken* token) {
  //const String& publicId = ...
  //const String& systemId = ...
  DocumentType* doctype =
      DocumentType::create(m_document, token->name(), publicId, systemId); //建立DOCType結點
  attachLater(m_attachmentRoot, doctype);  //建立插DOM的task
  setCompatibilityModeFromDoctype(token->name(), publicId, systemId); //設定文件型別
}

我們來看一下不同的doctype對文件型別的設定有什麼影響,如下:

  // Check for Quirks Mode.
  if (name != "html" ) {
    setCompatibilityMode(Document::QuirksMode);
    return;
  }

如果tagName不是html,那麼文件型別將會是怪異模式,以下兩種就會是怪異模式:

<!DOCType svg>
<!DOCType math>

而常用的html4寫法:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">

在原始碼裡面這個將是有限怪異模式:

  // Check for Limited Quirks Mode.
  if (!systemId.isEmpty() &&
       publicId.startsWith("-//W3C//DTD HTML 4.01 Transitional//",
                           TextCaseASCIIInsensitive))) {
    setCompatibilityMode(Document::LimitedQuirksMode);
    return;
  }

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

 // Otherwise we are No Quirks Mode.
  setCompatibilityMode(Document::NoQuirksMode);

常用的html5的寫法就是標準模式,如果連DOCType宣告也沒有呢?那麼會預設設定為怪異模式:

void HTMLConstructionSite::setDefaultCompatibilityMode() {
  setCompatibilityMode(Document::QuirksMode);
}

這些模式有什麼區別,從原始碼註釋可窺探一二:

  // There are three possible compatibility modes:
  // Quirks - quirks mode emulates WinIE and NS4. CSS parsing is also relaxed in
  // this mode, e.g., unit types can be omitted from numbers.
  // Limited Quirks - This mode is identical to no-quirks mode except for its
  // treatment of line-height in the inline box model.
  // No Quirks - no quirks apply. Web pages will obey the specifications to the
  // letter.

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

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

標準模式下的文件高度是實際內容的高度:

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

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

<div><img src="test.jpg" style="height:100px"></div>

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

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

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

(4)開標籤處理

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

HTMLConstructionSite::HTMLConstructionSite(
    Document& document)
    : m_document(&document),
      m_attachmentRoot(document)) {
}

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

void HTMLConstructionSite::insertHTMLHtmlStartTagBeforeHTML(AtomicHTMLToken* token) {
  HTMLHtmlElement* element = HTMLHtmlElement::create(*m_document);
  attachLater(m_attachmentRoot, element);
  m_openElements.pushHTMLHtmlElement(HTMLStackItem::create(element, token));
  executeQueuedTasks();
}

第二行先建立一個html結點,第三行把它加到一個任務佇列裡面,傳遞兩個引數,第一個引數是父結點,第二個引數是當前結點,第五行執行佇列裡面的任務。程式碼第四行會把它壓到一個棧裡面,這個棧存放了未遇到閉標籤的所有開標籤。

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

void HTMLConstructionSite::attachLater(ContainerNode* parent,
                                       Node* child,
                                       bool selfClosing) {
  HTMLConstructionSiteTask task(HTMLConstructionSiteTask::Insert);
  task.parent = parent;
  task.child = child;
  task.selfClosing = selfClosing;

  // Add as a sibling of the parent if we have reached the maximum depth
  // allowed.
  if (m_openElements.stackDepth() > maximumHTMLParserDOMTreeDepth &&
      task.parent->parentNode())
    task.parent = task.parent->parentNode();

  queueTask(task);
}

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

static const unsigned maximumHTMLParserDOMTreeDepth = 512;

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

void ContainerNode::parserAppendChild(Node* newChild) {
  if (!checkParserAcceptChild(*newChild))
    return;
    AdoptAndAppendChild()(*this, *newChild, nullptr);
  }
  notifyNodeInserted(*newChild, ChildrenChangeSourceParser);
}

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

void ContainerNode::appendChildCommon(Node& child) {
  child.setParentOrShadowHostNode(this);
  if (m_lastChild) {
    child.setPreviousSibling(m_lastChild);
    m_lastChild->setNextSibling(&child);
  } else {
    setFirstChild(&child);
  }
  setLastChild(&child);
}

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

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

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

當遇到head標籤的token時,也是先建立一個head結點,然後再建立一個task,插到佇列裡面:

void HTMLConstructionSite::insertHTMLHeadElement(AtomicHTMLToken* token) {
  m_head = HTMLStackItem::create(createHTMLElement(token), token);
  attachLater(currentNode(), m_head->element());
  m_openElements.pushHTMLHeadElement(m_head);
}

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

ContainerNode* currentNode() const { 
    return m_openElements.topNode(); 
}

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

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

而當遇到一個閉標籤呢?

(5)處理閉標籤

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

m_tree.openElements()->popUntilPopped(token->name());

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

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

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

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"></meta>
</head>
<body>
    <div>
        <p><b>hello</b></p>
        <p>demo</p>
    </div>
</body>
</html>

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

 push "HTML" m_stackDepth = 1
 push "HEAD" m_stackDepth = 2
 pop "HEAD" m_stackDepth = 1
 push "BODY" m_stackDepth = 2
 push "DIV" m_stackDepth = 3
 push "P" m_stackDepth = 4
 push "B" m_stackDepth = 5
 pop "B" m_stackDepth = 4
 pop "P" m_stackDepth = 3
 push "P" m_stackDepth = 4
 pop "P" m_stackDepth = 3
 pop "DIV" m_stackDepth = 2
 "tagName: body  |type: EndTag    |attr:              |text: "
 "tagName: html  |type: EndTag    |attr:              |text: "

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

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

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

<p><b>hello</p>

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

push "P" m_stackDepth = 4
push "B" m_stackDepth = 5
"tagName: p     |type: EndTag    |attr:              |text: "
pop "B" m_stackDepth = 4
pop "P" m_stackDepth = 3
push "B" m_stackDepth = 4
push "P" m_stackDepth = 5
pop "P" m_stackDepth = 4
pop "B" m_stackDepth = 3
pop "DIV" m_stackDepth = 2
push "B" m_stackDepth = 3

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

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

 // a, b, big, code, em, font, i, nobr, s, small, strike, strong, tt, and u.
  m_activeFormattingElements.append(currentElementRecord()->stackItem());

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

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

而對於文字節點是例項化了Text的物件,這裡不再展開討論。

(6)自定義標籤的處理

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

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

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

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

// "4. Let definition be the result of looking up a custom element ..." etc.
  CustomElementDefinition* definition =
      m_isParsingFragment ? nullptr
                          : lookUpCustomElementDefinition(document, token);

例如給自定義標籤建立一個原生屬性:

<high-school country="China">NO. 2 high school</high-school>

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

console.log(document.getElementsByTagName("high-school")[0].country);

註冊一個自定義標籤:

window.customElements.define("high-school", HighSchoolElement);

這個HighSchoolElement繼承於HTMLElement:

class HighSchoolElement extends HTMLElement{
    constructor(){
        super();
        this._country = null;
    }
    get country(){
        return this._country;
    }
    set country(country){
        this.setAttribute("country", _country);
    }
    static get observedAttributes() { 
        return ["country"]; 
    }
    attributeChangedCallback(name, oldValue, newValue) {
        this._country = newValue;
        this._updateRender(name, oldValue, newValue);
    }
    _updateRender(name, oldValue, newValue){
        console.log(name + " change from " + oldValue + " " + newValue);
    }
}

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

通過這種方式建立的,它就不是一個HTMLUnknownElement了。blink通過V8引擎把js的建構函式轉化成C++的函式,例項化一個HTMLElement的物件。

最後再來看查DOM的過程

4. 查DOM過程

(1)按ID查詢

在頁面新增一個script:

<script>document.getElementById("text")</script>

Chrome的V8引擎把js程式碼層層轉化,最後會調:

DocumentV8Internal::getElementByIdMethodForMainWorld(info);

而這個函式又會調TreeScope的getElementById的函式,TreeScope儲存了一個m_map的雜湊map,這個map以標籤id字串作為key值,Element為value值,我們可以把這個map列印出來:

Map::iterator it = m_map.begin();
while(it != m_map.end()){
    LOG(INFO) << it->key << " " << it->value->element->tagName();
    ++it;
}

html結構是這樣的:

<div class="user" id="id-yin">
    <p id="id-name" class="important">yin</p>
    <p id="id-age">20</p>
    <p id="id-sex">mail</p>
</div>

列印出來的結果為:

"id-age" "P"
"id-sex" "P"
"id-name" "P"
"id-yin" "DIV"

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

再來看一下類選擇器:

(2)類選擇器

js如下:

var users = document.getElementsByClassName("user"); 
users.length;

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

return new ClassCollection(rootNode, classNames);

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

  NodeType* currentNode = collection.traverseToFirst();
  unsigned currentIndex = 0;
  while (currentNode) {
    m_cachedList.push_back(currentNode);
    currentNode = collection.traverseForwardToOffset(
        currentIndex + 1, *currentNode, currentIndex);
  }

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

  if (this->isCachedNodeCountValid())
    return this->cachedNodeCount();

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

  ElementType* element = Traversal<ElementType>::firstWithin(current);
  while (element && !isMatch(*element))
    element = Traversal<ElementType>::next(*element, &current, isMatch);
  return element;

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

  if (current.hasChildren())
    return current.firstChild();
  if (current == stayWithin)
    return 0;
  if (current.nextSibling())
    return current.nextSibling();
  return nextAncestorSibling(current, stayWithin);

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

(3)querySelector

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

document.querySelector("#id-name");

它會調ContainerNode的querySelecotr函式:

SelectorQuery* selectorQuery = document().selectorQueryCache().add(
      selectors, document(), exceptionState);

return selectorQuery->queryFirst(*this);

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

rootNode.treeScope().getElementById(idToMatch);

b)如果selector為一個class:

document.querySelector(".user");

它會從document開始遍歷:

  for (Element& element : ElementTraversal::descendantsOf(rootNode)) {
    if (element.hasClass() && element.classNames().contains(className)) {
      SelectorQueryTrait::appendElement(output, element);
      if (SelectorQueryTrait::shouldOnlyMatchFirstElement)
        return;
    }
  }

我們重點檢視它是怎麼遍歷,即第一行的for迴圈。表面上看它好像把所有的元素取出來然後做個迴圈,其實不然,它是過載++操作符:

void operator++() { m_current = TraversalNext::next(*m_current, m_root); }

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

c)複雜選擇器

例如寫兩個class:

document.querySelector(".user .important");

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

  for (Element& element : ElementTraversal::descendantsOf(*traverseRoot)) {
    if (selectorMatches(selector, element, rootNode)) {
      SelectorQueryTrait::appendElement(output, element);
      if (SelectorQueryTrait::shouldOnlyMatchFirstElement)
        return;
    }
  }

怎麼判斷是否match比較複雜,這裡不再展開討論。

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

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

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

相關文章