從Chrome源碼看瀏覽器如何構(gòu)建DOM樹
這幾天下了Chrome的源碼,安裝了一個(gè)debug版的Chromium研究了一下,雖然很多地方都一知半解,但是還是有一點(diǎn)收獲,將在這篇文章介紹DOM樹是如何構(gòu)建的,看了本文應(yīng)該可以回答以下問題:
- IE用的是Trident內(nèi)核,Safari用的是Webkit,Chrome用的是Blink,到底什么是內(nèi)核,它們的區(qū)別是什么?
- 如果沒有聲明<!DOCTYPE html>會(huì)造成什么影響?
- 瀏覽器如何處理自定義的標(biāo)簽,如寫一個(gè)<data></data>?
- 查DOM的過程是怎么樣的?
先說一下,怎么安裝一個(gè)可以debug的Chrome
1. 從源碼安裝Chrome
為了可以打斷點(diǎn)debug,必須得從頭編譯(編譯的時(shí)候帶上debug參數(shù))。所以要下載源碼,Chrome把***的代碼更新到了Chromium的工程,是完全開源的,你可以把它整一個(gè)git工程下載下來。Chromium的下載安裝可參考它的文檔, 這里把一些關(guān)鍵點(diǎn)說一下,以Mac為例。你需要先下載它的安裝腳本工具,然后下載源碼:
fetch chromium --no-history
–no-history的作用是不把整個(gè)git工程下載下來,那個(gè)實(shí)在是太大了。或者是直接執(zhí)行g(shù)it clone:
git clone https://chromium.googlesource.com/chromium/src
這個(gè)就是整一個(gè)git工程,下載下來有6.48GB(那時(shí))。博主就是用的這樣的方式,如果下載到***提示出錯(cuò)了:
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,因?yàn)閷?shí)在太大、太耗時(shí)了。
下載好之后生成build的文件:
gn gen out/gn --ide=xcode
–ide=xcode是為了能夠使用蘋果的XCode進(jìn)行可視化進(jìn)行調(diào)試。gn命令要下載Chrome的devtools包,文檔里面有說明。
準(zhǔn)備就緒之后就可以進(jìn)行編譯了:
ninja -C out/gn chrome
在筆者的電腦上編譯了3個(gè)小時(shí),firfox的源碼需要編譯7、8個(gè)小時(shí),所以相對(duì)來說已經(jīng)快了很多,同時(shí)沒報(bào)錯(cuò),一次就過,相當(dāng)順利。編譯組裝好了之后,會(huì)在out/gn目錄生成Chromium的可執(zhí)行文件,具體路徑是在:
out/gn/Chromium.app/Contents/MacOS/Chromium
運(yùn)行這個(gè)就可以打開Chromium了:
那么怎么在可視化的XCode里面進(jìn)行debug呢?
2. 在XCode里面Debug
在上面生成build文件的同時(shí),會(huì)生成XCode的工程文件:sources.xcodeproj,具體路徑是在:
out/gn/sources.xcodeproj
雙擊這個(gè)文件,打開XCode,在上面的菜單欄里面點(diǎn)擊Debug -> AttachToProcess -> Chromium,要先打開Chrome,才能在列表里面看到Chrome的進(jìn)程。然后小試牛刀,打個(gè)斷點(diǎn)試試,看會(huì)不會(huì)跑進(jìn)來:
在左邊的目錄樹,打開chrome/browser/devtools/devtools_protocol.cc這個(gè)文件,然后在這個(gè)文件的ParseCommand函數(shù)里面打一個(gè)斷點(diǎn),按照字面理解這個(gè)函數(shù)應(yīng)該是解析控制臺(tái)的命令。打開Chrome的控制臺(tái),輸入一條命令,例如:new Date(),按回車可以看到斷點(diǎn)生效了:
通過觀察變量值,可以看到剛剛敲進(jìn)去的命令。這就說明了我們安裝成功,并且可以通過可視化的方式進(jìn)行調(diào)試。
但是我們要debug頁面渲染過程,Chrome的blink框架使用多進(jìn)程技術(shù),每打開一個(gè)tab都會(huì)新開一個(gè)進(jìn)程,按上面的方式是debug不了構(gòu)建DOM過程的,從Chromium的文檔可以查到,需要在啟動(dòng)的時(shí)候帶上一個(gè)參數(shù):
Chromium --renderer-startup-dialog
Chrom的啟動(dòng)進(jìn)程就會(huì)緒塞,并且提示它的渲染進(jìn)程ID:
[7339:775:0102/210122.254760:ERROR:child_process.cc(145)] Renderer (7339) paused waiting for debugger to attach. Send SIGUSR1 to unpause.
7339就是它的渲染進(jìn)程id,在XCode里面點(diǎn) Debug -> AttachToProcess By Id or Name -> 填入id -> 確定,attach之后,Chrome進(jìn)程就會(huì)恢復(fù),然后就可以開始調(diào)試渲染頁面的過程了。
在content/renderer/render_view_impl.cc這個(gè)文件的1093行RenderViewImpl::Create函數(shù)里面打個(gè)斷點(diǎn),按照上面的方式,重新啟動(dòng)Chrome,在命令行帶上某個(gè)html文件的路徑,為了打開Chrome的時(shí)候就會(huì)同時(shí)打開這個(gè)文件,方便調(diào)試。執(zhí)行完之后就可以看到斷點(diǎn)生效了。可以說render_view_impl.cc這個(gè)文件是***個(gè)具體開始渲染頁面的文件——它會(huì)初始化頁面的一些默認(rèn)設(shè)置,如字體大小、默認(rèn)的viewport等,響應(yīng)關(guān)閉頁面、OrientationChange等事件,而在它再往上的層主要是一些負(fù)責(zé)通信的類。
3. Chrome建DOM源碼分析
先畫出構(gòu)建DOM的幾個(gè)關(guān)鍵的類的UML圖,如下所示:
***個(gè)類HTMLDocumentParser負(fù)責(zé)解析html文本為tokens,一個(gè)token就是一個(gè)標(biāo)簽文本的序列化,并借助HTMLTreeBuilder對(duì)這些tokens分類處理,根據(jù)不同的標(biāo)簽類型、在文檔不同位置,調(diào)用HTMLConstructionSite不同的函數(shù)構(gòu)建DOM樹。而HTMLConstructionSite借助一個(gè)工廠類對(duì)不同類型的標(biāo)簽創(chuàng)建不同的html元素,并建立起它們的父子兄弟關(guān)系,其中它有一個(gè)m_document的成員變量,這個(gè)變量就是這棵樹的根結(jié)點(diǎn),也是js里面的window.document對(duì)象。
為作說明,用一個(gè)簡單的html文件一步步看這個(gè)DOM樹是如何建立起來的:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<div>
<h1 class="title">demo</h1>
<input value="hello">
</div>
</body>
</html>
然后按照上面第2點(diǎn)提到debug的方法,打開Chromium并開始debug:
chromium ~/demo.html --renderer-startup-dialog
我們先來研究一下Chrome的加載和解析機(jī)制
1. 加載機(jī)制
以發(fā)http請(qǐng)求去加載html文本做為我們分析的***步,在此之前的一些初始化就不考慮了。Chrome是在DocumentLoader這個(gè)類里面的startLoadingMainResource函數(shù)里去加載url返回的數(shù)據(jù),如訪問一個(gè)網(wǎng)站則返回html文本:
FetchRequest fetchRequest(m_request, FetchInitiatorTypeNames::document, mainResourceLoadOptions); m_mainResource = RawResource::fetchMainResource(fetchRequest, fetcher(), m_substituteData);
把參數(shù)里的m_request打印出來,在這個(gè)函數(shù)里面加一行代碼:
LOG(INFO) << "request url is: " << m_request.url().getString()
并重新編譯Chrome運(yùn)行,控制臺(tái)輸出:
[22731:775:0107/224014.494114:INFO:DocumentLoader.cpp(719)] request url is: “file:///Users/yincheng/demo.html”
可以看到,這個(gè)url確實(shí)是我們傳進(jìn)的參數(shù)。
發(fā)請(qǐng)求后,每次收到的數(shù)據(jù)塊,會(huì)通過Blink封裝的IPC進(jìn)程間通信,觸發(fā)DocumentLoader的dataReceived函數(shù),里面會(huì)去調(diào)它c(diǎn)ommitData函數(shù),開始處理具體業(yè)務(wù)邏輯:
void DocumentLoader::commitData(const char* bytes, size_t length) {
ensureWriter(m_response.mimeType());
if (length)
m_dataReceived = true;
m_writer->addData(bytes, length);
}
這個(gè)函數(shù)關(guān)鍵行是最2行和第7行,ensureWriter這個(gè)函數(shù)會(huì)去初始化上面畫的UML圖的解析器HTMLDocumentParser (Parser),并實(shí)例化document對(duì)象,這些對(duì)象都是通過實(shí)例m_writer去帶動(dòng)的。也就是說,writer會(huì)去實(shí)例化Parser之后,第7行writer傳遞數(shù)據(jù)給Parser去解析。
檢查一下收到的數(shù)據(jù)bytes是什么東西:
可以看到bytes就是請(qǐng)求返回的html文本。
在ensureWriter函數(shù)里面有個(gè)判斷:
void DocumentLoader::ensureWriter(const AtomicString& mimeType,
const KURL& overridingURL) {
if (m_writer)
return;
}
如果m_writer已經(jīng)初始化過了,則直接返回。也就是說Parser和document只會(huì)初始化一次。
在上面的addData函數(shù)里面,會(huì)啟動(dòng)一條線程執(zhí)行Parser的任務(wù):
if (!m_haveBackgroundParser)
startBackgroundParser();
并把數(shù)據(jù)傳遞給這條線程進(jìn)行解析,Parser一旦收到數(shù)據(jù)就會(huì)序列成tokens,再構(gòu)建DOM樹。
2. 構(gòu)建tokens
這里我們只要關(guān)注序列化后的token是什么東西就好了,為此,寫了一個(gè)函數(shù),把tokens的一些關(guān)鍵信息打印出來:
String getTokenInfo(){
String tokenInfo = "";
tokenInfo = "tagName: " + this->m_name + "|type: " + getType() + "|attr:" + getAttributes() + "|text: " + this->m_data;
return tokenInfo;
}
打印出來的結(jié)果:
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: "
這些內(nèi)容有標(biāo)簽名、類型、屬性和innerText,標(biāo)簽之間的文本(換行和空白)也會(huì)被當(dāng)作一個(gè)標(biāo)簽處理。Chrome總共定義了7種標(biāo)簽類型:
enum TokenType {
Uninitialized,
DOCTYPE,
StartTag,
EndTag,
Comment,
Character,
EndOfFile,
};
有了一個(gè)根結(jié)點(diǎn)document和一些格式化好的tokens,就可以構(gòu)建dom樹了。
3. 構(gòu)建DOM樹
(1)DOM結(jié)點(diǎn)
在研究這個(gè)過程之前,先來看一下一個(gè)DOM結(jié)點(diǎn)的數(shù)據(jù)結(jié)構(gòu)是怎么樣的。以p標(biāo)簽HTMLParagraphElement為例,畫出它的UML圖,如下所示:
Node是最頂層的父類,它有三個(gè)指針,兩個(gè)指針分別指向它的前一個(gè)結(jié)點(diǎn)和后一個(gè)結(jié)點(diǎn),一個(gè)指針指向它的父結(jié)點(diǎn);
ContainerNode繼承于Node,添加了兩個(gè)指針,一個(gè)指向***個(gè)子元素,另一個(gè)指向***一個(gè)子元素;
Element又添加了獲取dom結(jié)點(diǎn)屬性、clientWidth、scrollTop等函數(shù)
HTMLElement又繼續(xù)添加了Translate等控制,***一級(jí)的子類HTMLParagraphElement只有一個(gè)創(chuàng)建的函數(shù),但是它繼承了所有父類的屬性。
需要提到的是每個(gè)Node都組合了一個(gè)treeScope,這個(gè)treeScope記錄了它屬于哪個(gè)document(一個(gè)頁面可能會(huì)嵌入iframe)。
構(gòu)建DOM最關(guān)鍵的步驟應(yīng)該是建立起每個(gè)結(jié)點(diǎn)的父子兄弟關(guān)系,即上面提到的成員指針的指向。
到這里我們可以先回答上面提出的***個(gè)問題,什么是瀏覽器內(nèi)核
(2)瀏覽器內(nèi)核
瀏覽器內(nèi)核也叫渲染引擎,上面已經(jīng)看到了Chrome是如何實(shí)例化一個(gè)P標(biāo)簽的,而從firefox的源碼里面P標(biāo)簽的依賴關(guān)系是這樣的:
在代碼實(shí)現(xiàn)上和Chrome沒有任何關(guān)系。這就好像W3C出了道題,firefox給了一個(gè)解法,取名為Gecko,Safari也給了自己的答案,取名Webkit,Chrome覺得Safari的解法比較好直接拿過來用,又結(jié)合自身的基礎(chǔ)又封裝了一層,取名Blink。由于W3C出的這道題“開放性”比較大,出的時(shí)間比較晚,導(dǎo)致各家實(shí)現(xiàn)各有花樣。
明白了這點(diǎn)后,繼續(xù)DOM構(gòu)建。下面開始不再說Chrome,叫Webkit或者Blink應(yīng)該更準(zhǔn)確一點(diǎn)
(3)處理開始步驟
Webkit把tokens序列好之后,傳遞給構(gòu)建的線程。在HTMLDocumentParser::processTokenizedChunkFromBackgroundParser的這個(gè)函數(shù)里面會(huì)做一個(gè)循環(huán),把解析好的tokens做一個(gè)遍歷,依次調(diào)constructTreeFromCompactHTMLToken
進(jìn)行處理。
根據(jù)上面的輸出,最開始處理的***個(gè)token是docType的那個(gè):
"tagName: html |type: DOCTYPE |attr: |text: "
在那個(gè)函數(shù)里面,首先Parser會(huì)調(diào)TreeBuilder的函數(shù):
m_treeBuilder->constructTree(&token);
然后在TreeBuilder里面根據(jù)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
}
}
它會(huì)對(duì)不同類型的結(jié)點(diǎn)做相應(yīng)處理,從上往下依次是文本節(jié)點(diǎn)、doctype節(jié)點(diǎn)、開標(biāo)簽、閉標(biāo)簽。doctype這個(gè)結(jié)點(diǎn)比較特殊,單獨(dú)作為一種類型處理
(3)DOCType處理
在Parser處理doctype的函數(shù)里面調(diào)了HTMLConstructionSite的插入doctype的函數(shù):
void HTMLTreeBuilder::processDoctypeToken(AtomicHTMLToken* token) {
m_tree.insertDoctype(token);
setInsertionMode(BeforeHTMLMode);
}
在這個(gè)函數(shù)里面,它會(huì)先創(chuàng)建一個(gè)doctype的結(jié)點(diǎn),再創(chuàng)建插dom的task,并設(shè)置文檔類型:
void HTMLConstructionSite::insertDoctype(AtomicHTMLToken* token) {
//const String& publicId = ...
//const String& systemId = ...
DocumentType* doctype =
DocumentType::create(m_document, token->name(), publicId, systemId); //創(chuàng)建DOCType結(jié)點(diǎn)
attachLater(m_attachmentRoot, doctype); //創(chuàng)建插DOM的task
setCompatibilityModeFromDoctype(token->name(), publicId, systemId); //設(shè)置文檔類型
}
我們來看一下不同的doctype對(duì)文檔類型的設(shè)置有什么影響,如下:
// Check for Quirks Mode.
if (name != "html" ) {
setCompatibilityMode(Document::QuirksMode);
return;
}
如果tagName不是html,那么文檔類型將會(huì)是怪異模式,以下兩種就會(huì)是怪異模式:
<!DOCType svg>
<!DOCType math>
而常用的html4寫法:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
在源碼里面這個(gè)將是有限怪異模式:
// 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為空,則它將是怪異模式。如果既不是怪異模式,也不是有限怪異模式,那么它就是標(biāo)準(zhǔn)模式:
// Otherwise we are No Quirks Mode.
setCompatibilityMode(Document::NoQuirksMode);
常用的html5的寫法就是標(biāo)準(zhǔn)模式,如果連DOCType聲明也沒有呢?那么會(huì)默認(rèn)設(shè)置為怪異模式:
void HTMLConstructionSite::setDefaultCompatibilityMode() { setCompatibilityMode(Document::QuirksMode); }
這些模式有什么區(qū)別,從源碼注釋可窺探一二:
// 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.
大意是說,怪異模式會(huì)模擬IE,同時(shí)CSS解析會(huì)比較寬松,例如數(shù)字單位可以省略,而有限怪異模式和標(biāo)準(zhǔn)模式的唯一區(qū)別在于在于對(duì)inline元素的行高處理不一樣。標(biāo)準(zhǔn)模式將會(huì)讓頁面遵守文檔規(guī)定。
怪異模式下的input和textarea的默認(rèn)盒模型將會(huì)變成border-box:
標(biāo)準(zhǔn)模式下的文檔高度是實(shí)際內(nèi)容的高度:
而在怪異模式下的文檔高度是窗口可視域的高度:
在有限怪異模式下,div里面的圖片下方不會(huì)留空白,如下圖左所示;而在標(biāo)準(zhǔn)模式下div下方會(huì)留點(diǎn)空白,如下圖右所示:
<div><img src="test.jpg" style="height:100px"></div>


這個(gè)空白是div的行高撐起來的,當(dāng)把div的行高設(shè)置成0的時(shí)候,就沒有下面的空白了。在怪異模和有限怪異模式下,為了計(jì)算行內(nèi)子元素的最小高度,一個(gè)塊級(jí)元素的行高必須被忽略。
這里的敘述雖然跟解讀源碼沒有直接的關(guān)系(我們還沒解讀到CSS處理),但是很有必要提一下。
接下來我們開始正式說明DOM構(gòu)建
(4)開標(biāo)簽處理
下一個(gè)遇到的開標(biāo)簽是<html>標(biāo)簽,處理這個(gè)標(biāo)簽的任務(wù)應(yīng)該是實(shí)例化一個(gè)HTMLHtmlElement元素,然后把它的父元素指向document。Webkit源碼里面使用了一個(gè)m_attachmentRoot的變量記錄attach的根結(jié)點(diǎn),初始化HTMLConstructionSite也會(huì)初始化這個(gè)變量,值為document:
HTMLConstructionSite::HTMLConstructionSite(
Document& document)
: m_document(&document),
m_attachmentRoot(document)) {
}
所以html結(jié)點(diǎn)的父結(jié)點(diǎn)就是document,實(shí)際的操作過程是這樣的:
void HTMLConstructionSite::insertHTMLHtmlStartTagBeforeHTML(AtomicHTMLToken* token) { HTMLHtmlElement* element = HTMLHtmlElement::create(*m_document); attachLater(m_attachmentRoot, element); m_openElements.pushHTMLHtmlElement(HTMLStackItem::create(element, token)); executeQueuedTasks(); }
第二行先創(chuàng)建一個(gè)html結(jié)點(diǎn),第三行把它加到一個(gè)任務(wù)隊(duì)列里面,傳遞兩個(gè)參數(shù),***個(gè)參數(shù)是父結(jié)點(diǎn),第二個(gè)參數(shù)是當(dāng)前結(jié)點(diǎn),第五行執(zhí)行隊(duì)列里面的任務(wù)。代碼第四行會(huì)把它壓到一個(gè)棧里面,這個(gè)棧存放了未遇到閉標(biāo)簽的所有開標(biāo)簽。
第三行attachLater是如何建立一個(gè)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);
}
代碼邏輯比較簡單,比較有趣的是發(fā)現(xiàn)DOM樹有一個(gè)***的深度:maximumHTMLParserDOMTreeDepth,超過這個(gè)***深度就會(huì)把它子元素當(dāng)作父無素的同級(jí)節(jié)點(diǎn),這個(gè)***值是多少呢?512:
static const unsigned maximumHTMLParserDOMTreeDepth = 512;
我們重點(diǎn)關(guān)注executeQueuedTasks干了些什么,它會(huì)根據(jù)task的類型執(zhí)行不同的操作,由于本次是insert的,它會(huì)去執(zhí)行一個(gè)插入的函數(shù):
void ContainerNode::parserAppendChild(Node* newChild) {
if (!checkParserAcceptChild(*newChild))
return;
AdoptAndAppendChild()(*this, *newChild, nullptr);
}
notifyNodeInserted(*newChild, ChildrenChangeSourceParser);
}
在插入里面它會(huì)先去檢查父元素是否支持子元素,如果不支持,則直接返回,就像video標(biāo)簽不支持子元素。然后再去調(diào)具體的插入:
void ContainerNode::appendChildCommon(Node& child) {
child.setParentOrShadowHostNode(this);
if (m_lastChild) {
child.setPreviousSibling(m_lastChild);
m_lastChild->setNextSibling(&child);
} else {
setFirstChild(&child);
}
setLastChild(&child);
}
上面代碼第二行,設(shè)置子元素的父結(jié)點(diǎn),也就是會(huì)把html結(jié)點(diǎn)的父結(jié)點(diǎn)指向document,然后如果沒有l(wèi)astChild,會(huì)將這個(gè)子元素作為firstChild,由于上面已經(jīng)有一個(gè)docype的子結(jié)點(diǎn)了,所以已經(jīng)有l(wèi)astChild了,因此會(huì)把這個(gè)子元素的previousSibling指向老的lastChild,老的lastChild的nexSibling指向它。***倒數(shù)第二行再把子元素設(shè)置為當(dāng)前ContainerNode(即document)的lastChild。這樣就建立起了html結(jié)點(diǎn)的父子兄弟關(guān)系。
可以看到,借助上一次的m_lastChild建立起了兄弟關(guān)系。
這個(gè)時(shí)候你可能會(huì)有一個(gè)問題,為什么要用一個(gè)task隊(duì)列存放將要插入的結(jié)點(diǎn)呢,而不是直接插入呢?一個(gè)原因是放到task里面方便統(tǒng)一處理,并且有些task可能不能立即執(zhí)行,要先存起來。不過在我們這個(gè)案例里面都是存完后下一步就執(zhí)行了。
當(dāng)遇到head標(biāo)簽的token時(shí),也是先創(chuàng)建一個(gè)head結(jié)點(diǎn),然后再創(chuàng)建一個(gè)task,插到隊(duì)列里面:
void HTMLConstructionSite::insertHTMLHeadElement(AtomicHTMLToken* token) { m_head = HTMLStackItem::create(createHTMLElement(token), token); attachLater(currentNode(), m_head->element()); m_openElements.pushHTMLHeadElement(m_head); }
attachLater傳參的***個(gè)參數(shù)為父結(jié)點(diǎn),這個(gè)currentNode為開標(biāo)簽棧里面的最頂?shù)脑兀?/p>
ContainerNode* currentNode() const {
return m_openElements.topNode();
}
我們剛剛把html元素壓了進(jìn)去,則棧頂元素為html元素,所以head的父結(jié)點(diǎn)就為html。所以每當(dāng)遇到一個(gè)開標(biāo)簽時(shí),就把它壓起來,下一次再遇到一個(gè)開標(biāo)簽時(shí),它的父元素就是上一個(gè)開標(biāo)簽。
所以,初步可以看到,借助一個(gè)棧建立起了父子關(guān)系。
而當(dāng)遇到一個(gè)閉標(biāo)簽?zāi)兀?/p>
(5)處理閉標(biāo)簽
當(dāng)遇到一個(gè)閉標(biāo)簽時(shí),會(huì)把棧里面的元素一直pop出來,直到pop到***個(gè)和它標(biāo)簽名字一樣的:
m_tree.openElements()->popUntilPopped(token->name());
我們***個(gè)遇到的是閉標(biāo)簽是head標(biāo)簽,它會(huì)把開的head標(biāo)簽pop出來,棧里面就剩下html元素了,所以當(dāng)再遇到body時(shí),html元素就是body的父元素了。
這個(gè)是棧的一個(gè)典型應(yīng)用。
以下面的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: "
這個(gè)過程確實(shí)和上面的描述一致,遇到一個(gè)閉標(biāo)簽就把一次的開標(biāo)簽pop出來。
并且可以發(fā)現(xiàn)遇到body閉標(biāo)簽后,并不會(huì)把body給pop出來,因?yàn)槿绻鸼ody閉標(biāo)簽后面又再寫了標(biāo)簽的話,就會(huì)自動(dòng)當(dāng)成body的子元素。
假設(shè)上面的b標(biāo)簽的閉標(biāo)簽忘記寫了,又會(huì)發(fā)生什么:
<p><b>hello</p>
打印出來的結(jié)果是這樣的:
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閉標(biāo)簽時(shí),會(huì)把所有的開標(biāo)簽pop出來,直到遇到P標(biāo)簽。不同的是后續(xù)的過程中會(huì)不斷地插入b標(biāo)簽,***渲染的頁面結(jié)構(gòu):
因?yàn)閎等帶有格式化的標(biāo)簽會(huì)特殊處理,遇到一個(gè)開標(biāo)簽時(shí)會(huì)它們放到一個(gè)列表里面:
// a, b, big, code, em, font, i, nobr, s, small, strike, strong, tt, and u.
m_activeFormattingElements.append(currentElementRecord()->stackItem());
遇到一個(gè)閉標(biāo)簽時(shí),又會(huì)從這個(gè)列表里面刪掉。每處理一個(gè)新標(biāo)簽時(shí)就會(huì)進(jìn)行檢查和這個(gè)列表和棧里的開標(biāo)簽是否對(duì)應(yīng),如果不對(duì)應(yīng)則會(huì)reconstruct:重新插入一個(gè)開標(biāo)簽。因此b就不斷地被重新插入,直到遇到下一個(gè)b的閉標(biāo)簽為止。
如果上面少寫的是一個(gè)span,那么渲染之后的結(jié)果是正常的:
而對(duì)于文本節(jié)點(diǎn)是實(shí)例化了Text的對(duì)象,這里不再展開討論。
(6)自定義標(biāo)簽的處理
在瀏覽器里面可以看到,自定義標(biāo)簽?zāi)J(rèn)不會(huì)有任何的樣式,并且它默認(rèn)是一個(gè)行內(nèi)元素:
初步觀察它和span標(biāo)簽的表現(xiàn)是一樣的:
在blink的源碼里面,不認(rèn)識(shí)的標(biāo)簽?zāi)J(rèn)會(huì)被實(shí)例化成一個(gè)HTMLUnknownElement,這個(gè)類對(duì)外提供了一個(gè)create函數(shù),這和HTMLSpanElement是一樣的,只有一個(gè)create函數(shù),并且大家都是繼承于HTMLElement。并且創(chuàng)建span標(biāo)簽的時(shí)候和unknown一樣,并沒有做特殊處理,直接調(diào)的create。所以從本質(zhì)上來說,可以把自定義的標(biāo)簽當(dāng)作一個(gè)span看待。然后你可以再設(shè)置display: block改成塊級(jí)元素之類的。
但是你可以用js定義一個(gè)自定義標(biāo)簽,定義它的屬性等,Webkit會(huì)去讀它的定義:
// "4. Let definition be the result of looking up a custom element ..." etc.
CustomElementDefinition* definition =
m_isParsingFragment ? nullptr
: lookUpCustomElementDefinition(document, token);
例如給自定義標(biāo)簽創(chuàng)建一個(gè)原生屬性:
<high-school country="China">NO. 2 high school</high-school>
上面定義了一個(gè)country,為了可以直接獲取這個(gè)屬性:
console.log(document.getElementsByTagName("high-school")[0].country);
注冊(cè)一個(gè)自定義標(biāo)簽:
window.customElements.define("high-school", HighSchoolElement);
這個(gè)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這個(gè)屬性,而不用通過getAttribute的函數(shù),并且可以在屬性發(fā)生變化時(shí)更新元素的渲染,改變color等。詳見Custom Elements – W3C.
通過這種方式創(chuàng)建的,它就不是一個(gè)HTMLUnknownElement了。blink通過V8引擎把js的構(gòu)造函數(shù)轉(zhuǎn)化成C++的函數(shù),實(shí)例化一個(gè)HTMLElement的對(duì)象。
***再來看查DOM的過程
4. 查DOM過程
(1)按ID查找
在頁面添加一個(gè)script:
<script>document.getElementById("text")</script>
Chrome的V8引擎把js代碼層層轉(zhuǎn)化,***會(huì)調(diào):
DocumentV8Internal::getElementByIdMethodForMainWorld(info);
而這個(gè)函數(shù)又會(huì)調(diào)TreeScope的getElementById的函數(shù),TreeScope存儲(chǔ)了一個(gè)m_map的哈希map,這個(gè)map以標(biāo)簽id字符串作為key值,Element為value值,我們可以把這個(gè)map打印出來:
Map::iterator it = m_map.begin();
while(it != m_map.end()){
LOG(INFO) << it->key << " " << it->value->element->tagName();
++it;
}
html結(jié)構(gòu)是這樣的:
<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>
打印出來的結(jié)果為:
"id-age" "P"
"id-sex" "P"
"id-name" "P"
"id-yin" "DIV"
可以看到, 這個(gè)m_map把頁面所有有id的標(biāo)簽都存了進(jìn)來。由于map的查找時(shí)間復(fù)雜度為O(1),所以使用ID選擇器可以說是最快的。
再來看一下類選擇器:
(2)類選擇器
js如下:
var users = document.getElementsByClassName("user");
users.length;
在執(zhí)行***行的時(shí)候,Webkit返回了一個(gè)ClassCollection的列表:
return new ClassCollection(rootNode, classNames);
而這個(gè)列表并不是去查DOM獲取的,它只是記錄了className作為標(biāo)志。這與我們的認(rèn)知是一致的,這種HTMLCollection的數(shù)據(jù)結(jié)構(gòu)都是在使用的時(shí)候才去查DOM,所以在上面第二行去獲取它的length,就會(huì)觸發(fā)它的查DOM,在nodeCount這個(gè)函數(shù)里面執(zhí)行:
NodeType* currentNode = collection.traverseToFirst();
unsigned currentIndex = 0;
while (currentNode) {
m_cachedList.push_back(currentNode);
currentNode = collection.traverseForwardToOffset(
currentIndex + 1, *currentNode, currentIndex);
}
***行先獲取符合collection條件的***個(gè)結(jié)點(diǎn),然后不斷獲取下一個(gè)符合條件的結(jié)點(diǎn),直到null,并把它存到一個(gè)cachedList里面,下次再獲取這個(gè)collection的東西時(shí)便不用再重復(fù)查DOM,只要cached仍然是有效的:
if (this->isCachedNodeCountValid())
return this->cachedNodeCount();
怎么樣找到有效的節(jié)點(diǎn)呢:
ElementType* element = Traversal<ElementType>::firstWithin(current);
while (element && !isMatch(*element))
element = Traversal<ElementType>::next(*element, ¤t, isMatch);
return element;
***行先獲取***個(gè)節(jié)點(diǎn),如果它沒有match,則繼續(xù)next,直到找到符合條件或者空為止。我們的重點(diǎn)在于,它是怎么遍歷的,如何next獲取下一個(gè)節(jié)點(diǎn),核心代碼:
if (current.hasChildren())
return current.firstChild();
if (current == stayWithin)
return 0;
if (current.nextSibling())
return current.nextSibling();
return nextAncestorSibling(current, stayWithin);
***行先判斷當(dāng)前節(jié)點(diǎn)有沒有子元素,如果有的話返回它的***個(gè)子元素,如果當(dāng)前節(jié)點(diǎn)沒有子元素,并且這個(gè)節(jié)點(diǎn)就是開始找的根元素(用document.getElement*,則為document),則說明沒有下一個(gè)元素了,直接返回0/null。如果這個(gè)節(jié)點(diǎn)不是根元素了(例如已經(jīng)到了子元素這一層),那么看它有沒有相鄰元素,如果有則返回下一個(gè)相鄰元素,如果相鄰無素也沒有了,由于它是一個(gè)葉子結(jié)點(diǎn)(沒有子元素),說明它已經(jīng)到了最深的一層,并且是當(dāng)前層的***一個(gè)葉子結(jié)點(diǎn),那么就返回它的父元素的下一個(gè)相鄰節(jié)點(diǎn),如果這個(gè)也沒有了,則返回null,查找結(jié)束??梢钥闯鲞@是一個(gè)深度優(yōu)先的查找。
(3)querySelector
a)先來看下selector為一個(gè)id時(shí)發(fā)生了什么:
document.querySelector("#id-name");
它會(huì)調(diào)ContainerNode的querySelecotr函數(shù):
SelectorQuery* selectorQuery = document().selectorQueryCache().add(
selectors, document(), exceptionState);
return selectorQuery->queryFirst(*this);
先把輸入的selector字符串序列化成一個(gè)selectorQuery,然后再queryFirst,通過打斷點(diǎn)可以發(fā)現(xiàn),它***會(huì)調(diào)的TreeScope的getElementById:
rootNode.treeScope().getElementById(idToMatch);
b)如果selector為一個(gè)class:
document.querySelector(".user");
它會(huì)從document開始遍歷:
for (Element& element : ElementTraversal::descendantsOf(rootNode)) {
if (element.hasClass() && element.classNames().contains(className)) {
SelectorQueryTrait::appendElement(output, element);
if (SelectorQueryTrait::shouldOnlyMatchFirstElement)
return;
}
}
我們重點(diǎn)查看它是怎么遍歷,即***行的for循環(huán)。表面上看它好像把所有的元素取出來然后做個(gè)循環(huán),其實(shí)不然,它是重載++操作符:
void operator++() { m_current = TraversalNext::next(*m_current, m_root); }
只要我們看下next是怎么操作的就可以得知它是怎么遍歷,而這個(gè)next跟上面的講解class時(shí)是調(diào)的同一個(gè)next。不一樣的是match條件判斷是:有className,并且className列表里面包含這個(gè)class,如上面代碼第二行。
c)復(fù)雜選擇器
例如寫兩個(gè)class:
document.querySelector(".user .important");
最終也會(huì)轉(zhuǎn)成一個(gè)遍歷,只是判斷是否match的條件不一樣:
for (Element& element : ElementTraversal::descendantsOf(*traverseRoot)) {
if (selectorMatches(selector, element, rootNode)) {
SelectorQueryTrait::appendElement(output, element);
if (SelectorQueryTrait::shouldOnlyMatchFirstElement)
return;
}
}
怎么判斷是否match比較復(fù)雜,這里不再展開討論。
同時(shí)在源碼可以看到,如果是怪異模式,會(huì)調(diào)一個(gè)executeSlow的查詢,并且判斷match條件也不一樣。不過遍歷是一樣的。
查看源碼確實(shí)是一件很費(fèi)時(shí)費(fèi)力的工作,但是通過一番探索,能夠了解瀏覽器的一些內(nèi)在機(jī)制,至少已經(jīng)可以回答上面提出來的幾個(gè)問題。同時(shí)知道了Webkit/Blink借助一個(gè)棧,結(jié)合開閉標(biāo)簽,一步步構(gòu)建DOM樹,并對(duì)DOCType的標(biāo)簽、自定義標(biāo)簽的處理有了一定的了解。***又討論了查DOM的幾種情況,明白了查找的過程。
通過上面的分析,對(duì)頁面渲染的***步構(gòu)建DOM應(yīng)該會(huì)有一個(gè)基礎(chǔ)的了解。