WebKit Inside: DOM樹的構建

chaoguo1234發表於2022-02-21

當客戶端App主程式建立WKWebView物件時,會建立另外兩個子程式:渲染程式與網路程式。主程式WKWebView發起請求時,先將請求轉發給渲染程式,渲染程式再轉發給網路程式,網路程式請求伺服器。如果請求的是一個網頁,網路程式會將伺服器的響應資料HTML檔案字元流吐給渲染程式。渲染程式拿到HTML檔案字元流,首先要進行解析,將HTML檔案字元流轉換成DOM樹,然後在DOM樹的基礎上,進行渲染操作,也就是佈局、繪製。最後渲染程式將渲染資料吐給主程式WKWebView,WKWebView根據渲染資料建立對應的View展現檢視。整個流程如下圖所示:

WebKit Inside: DOM樹的構建

 

什麼是DOM樹

渲染程式獲取到HTML檔案字元流,會將HTML檔案字元流轉換成DOM樹。下圖中左側是一個HTML檔案,右邊就是轉換而成的DOM樹。

WebKit Inside: DOM樹的構建

 

可以看到DOM樹的根節點是HTMLDocument,代表整個文件。根節點下面的子節點與HTML檔案中的標籤是一一對應的,比如HTML中的<head>標籤就對應DOM樹中的head節點。同時HTML檔案中的文字,也成為DOM樹中的一個節點,比如文字'Hello, World!',在DOM樹中就成為div節點的子節點。

在DOM樹中每一個節點都是具有一定方法與屬性的物件,這些物件由對應的類建立出來。比如HTMLDocument節點,它對應的類是class HTMLDocument,下面是HTMLDocument的部分原始碼:

1 class HTMLDocument : public Document { // 繼承自Document
2    ...
3     WEBCORE_EXPORT int width();
4     WEBCORE_EXPORT int height();
5     ...
6  }

從原始碼中可以看到,HTMLDocument繼承自類Document,Document類的部分原始碼如下:

 1 class Document
 2     : public ContainerNode  // Document繼承自ContainerNode,ContainerNode繼承自Node
 3     , public TreeScope
 4     , public ScriptExecutionContext
 5     , public FontSelectorClient
 6     , public FrameDestructionObserver
 7     , public Supplementable<Document>
 8     , public Logger::Observer
 9     , public CanvasObserver {
10     WEBCORE_EXPORT ExceptionOr<Ref<Element>> createElementForBindings(const AtomString& tagName);  // 建立Element的方法
11     WEBCORE_EXPORT Ref<Text> createTextNode(const String& data); // 建立文字節點的方法
12     WEBCORE_EXPORT Ref<Comment> createComment(const String& data); // 建立註釋的方法
13     WEBCORE_EXPORT Ref<Element> createElement(const QualifiedName&, bool createdByParser); // 建立Element方法
14     ....
15  }

上面原始碼可以看到Document繼承自Node,而且還可以看到前端十分熟悉的createElement、createTextNode等方法,JavaScript對這些方法的呼叫,最後都轉換為對應C++方法的呼叫。

類Document有這些方法,並不是沒有原因的,而是W3C組織給出的標準規定的,這個標準就是DOM(Document Object Model,文件物件模型)。DOM定義了DOM樹中每個節點需要實現的介面和屬性,下面是HTMLDocument、Document、HTMLDivElment的部分IDL(Interactive Data Language,介面描述語言,與具體平臺和語言無關)描述,完整的IDL可以參看W3C

 1 interface HTMLDocument : Document {   // HTMLDocument 
 2     getter (WindowProxy or Element or HTMLCollection) (DOMString name);
 3 };
 4 
 5 
 6 interface Document : Node { // Document
 7    [NewObject, ImplementedAs=createElementForBindings] Element createElement(DOMString localName); // createElement
 8    [NewObject] Text createTextNode(DOMString data); // createTextNode
 9    ...
10  }
11  
12  
13  interface HTMLDivElement : HTMLElement { // HTMLDivElement
14     [CEReactions=NotNeeded, Reflect] attribute DOMString align;
15 };

在DOM樹中,每一個節點都繼承自類Node,同時Node還有一個子類Element,有的節點直接繼承自類Node,比如文字節點,而有的節點繼承自類Element,比如div節點。因此針對上面圖中的DOM樹,執行下面的JavaScript語句返回的結果是不一樣的:

1 document.childNodes; // 返回子Node集合,返回DocumentType與HTML節點,都繼承自Node
2 document.children; // 返回子Element集合,只返回HTML節點,DocumentType不繼承自Element

下圖給出部分節點的繼承關係圖:

WebKit Inside: DOM樹的構建

 

 

DOM樹的構建

DOM樹的構建流程可以分位4個步驟: 解碼、分詞、建立節點、新增節點

1 解碼

渲染程式從網路程式接收過來的是HTML位元組流,而下一步分詞是以字元為單位進行的。由於各種編碼規範的存在,比如ISO-8859-1、UTF-8等,一個字元常常可能對應一個或者多個編碼後的位元組,解碼的目的就是將HTML位元組流轉換成HTML字元流,或者換句話說,就是將原始的HTML位元組流轉換成字串。

WebKit Inside: DOM樹的構建

2 解碼類圖

WebKit Inside: DOM樹的構建

從類圖上看,類HTMLDocumentParser處於解碼的核心位置,由這個類呼叫解碼器將HTML位元組流解碼成字元流,儲存到類HTMLInputStream中。

3 解碼流程

WebKit Inside: DOM樹的構建

整個解碼流程當中,最關健的是如何找到正確的編碼方式。只有找到了正確的編碼方式,才能使用對應的解碼器進行解碼。解碼發生的地方如下面原始碼所示,這個方法在上圖第3個棧幀被呼叫:

 1 // HTMLDocumentParser是DecodedDataDocumentParser的子類
 2 void DecodedDataDocumentParser::appendBytes(DocumentWriter& writer, const uint8_t* data, size_t length)
 3 {
 4     if (!length)
 5         return;
 6 
 7     String decoded = writer.decoder().decode(data, length); // 真正解碼發生在這裡
 8     if (decoded.isEmpty())
 9         return;
10 
11     writer.reportDataReceived();
12     append(decoded.releaseImpl());
13 }

上面程式碼第7行writer.decoder()返回一個TextResourceDecoder物件,解碼操作由TextResourceDecoder::decode方法完成。下面逐步檢視TextResourceDecoder::decode方法的原始碼:

 1 // 只保留了最重要的部分
 2  2 String TextResourceDecoder::decode(const char* data, size_t length)
 3  3 {
 4  4    ...
 5  5 
 6  6    // 如果是HTML檔案,就從head標籤中尋找字符集
 7  7     if ((m_contentType == HTML || m_contentType == XML) && !m_checkedForHeadCharset) // HTML and XML
 8  8         if (!checkForHeadCharset(data, length, movedDataToBuffer))
 9  9             return emptyString();
10 10     
11 11      ...
12 12    
13 13     // m_encoding儲存者從HTML檔案中找到的編碼名稱
14 14     if (!m_codec)
15 15         m_codec = newTextCodec(m_encoding);  // 建立具體的編碼器
16 16 
17 17     ...
18 18 
19 19    // 解碼並返回
20 20    String result = m_codec->decode(m_buffer.data() + lengthOfBOM, m_buffer.size() - lengthOfBOM, false, m_contentType == XML && !m_useLenientXMLDecoding, m_sawError);
21 21     m_buffer.clear(); // 清空儲存的原始未解碼的HTML位元組流
22 22     return result;
23 23 }

從原始碼中可以看到,TextResourceDecoder首先從HTML的<head>標籤中去找編碼方式,因為<head>標籤可以包含<meta>標籤,<meta>標籤可以設定HTML檔案的字符集:

1 <head>
2         <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <!-- 字符集指定-->
3         <title>DOM Tree</title>
4         <script>window.name = 'Lucy';</script>
5  </head>

如果能找到對應的字符集,TextResourceDeocder將其儲存在成員變數m_encoding當中,並且根據對應的編碼建立真正的解碼器儲存在成員變數m_codec中,最終使用m_codec對位元組流進行解碼,並且返回解碼後的字串。如果帶有字符集的<meta>標籤沒有找到,TextResourceDeocder的m_encoding有預設值windows-1252(等同於ISO-8859-1)。

下面看一下TextResourceDecoder尋找<meta>標籤中字符集的流程,也就是上面原始碼中第8行對checkForHeadCharset函式的呼叫:

 1 // 只保留了關健程式碼
 2 bool TextResourceDecoder::checkForHeadCharset(const char* data, size_t len, bool& movedDataToBuffer)
 3 {
 4     ...
 5 
 6     // This is not completely efficient, since the function might go
 7     // through the HTML head several times.
 8 
 9     size_t oldSize = m_buffer.size();
10     m_buffer.grow(oldSize + len);
11     memcpy(m_buffer.data() + oldSize, data, len); // 將位元組流資料拷貝到自己的快取m_buffer裡面
12 
13     movedDataToBuffer = true;
14 
15     // Continue with checking for an HTML meta tag if we were already doing so.
16     if (m_charsetParser)
17         return checkForMetaCharset(data, len);  // 如果已經存在了meta標籤解析器,直接開始解析
18    
19      ....
20 
21     m_charsetParser = makeUnique<HTMLMetaCharsetParser>(); // 建立meta標籤解析器
22     return checkForMetaCharset(data, len);
23 }

上面原始碼中第11行,類TextResourceDecoder內部儲存了需要解碼的HTML位元組流,這一步驟很重要,後面會講到。先看第17行、21行、22行,這3行主要是使用<meta>標籤解析器解析字符集,使用了懶載入的方式。下面看下checkForMetaCharset這個函式的實現:

 1 bool TextResourceDecoder::checkForMetaCharset(const char* data, size_t length)
 2 {
 3     if (!m_charsetParser->checkForMetaCharset(data, length))  // 解析meta標籤字符集
 4         return false;
 5 
 6     setEncoding(m_charsetParser->encoding(), EncodingFromMetaTag); // 找到後設定字元編碼名稱
 7     m_charsetParser = nullptr;
 8     m_checkedForHeadCharset = true;
 9     return true;
10 }

上面原始碼第3行可以看到,整個解析<meta>標籤的任務在類HTMLMetaCharsetParser::checkForMetaCharset中完成。

 1 // 只保留了關健程式碼
 2 bool HTMLMetaCharsetParser::checkForMetaCharset(const char* data, size_t length)
 3 {
 4     if (m_doneChecking) // 標誌位,避免重複解析
 5         return true;
 6 
 7 
 8     // We still don't have an encoding, and are in the head.
 9     // The following tags are allowed in <head>:
10     // SCRIPT|STYLE|META|LINK|OBJECT|TITLE|BASE
11     //
12     // We stop scanning when a tag that is not permitted in <head>
13     // is seen, rather when </head> is seen, because that more closely
14     // matches behavior in other browsers; more details in
15     // <http://bugs.webkit.org/show_bug.cgi?id=3590>.
16     //
17     // Additionally, we ignore things that looks like tags in <title>, <script>
18     // and <noscript>; see <http://bugs.webkit.org/show_bug.cgi?id=4560>,
19     // <http://bugs.webkit.org/show_bug.cgi?id=12165> and
20     // <http://bugs.webkit.org/show_bug.cgi?id=12389>.
21     //
22     // Since many sites have charset declarations after <body> or other tags
23     // that are disallowed in <head>, we don't bail out until we've checked at
24     // least bytesToCheckUnconditionally bytes of input.
25 
26     constexpr int bytesToCheckUnconditionally = 1024;  // 如果解析了1024個字元還未找到帶有字符集的<meta>標籤,整個解析也算完成,此時沒有解析到正確的字符集,就使用預設編碼windows-1252(等同於ISO-8859-1)
27 
28     bool ignoredSawErrorFlag;
29     m_input.append(m_codec->decode(data, length, false, false, ignoredSawErrorFlag)); // 對位元組流進行解碼
30 
31     while (auto token = m_tokenizer.nextToken(m_input)) { // m_tokenizer進行分詞操作,找meta標籤也需要進行分詞,分詞操作後面講
32         bool isEnd = token->type() == HTMLToken::EndTag;
33         if (isEnd || token->type() == HTMLToken::StartTag) {
34             AtomString tagName(token->name());
35             if (!isEnd) {
36                 m_tokenizer.updateStateFor(tagName);
37                 if (tagName == metaTag && processMeta(*token)) { // 找到meta標籤進行處理
38                     m_doneChecking = true;
39                     return true; // 如果找到了帶有編碼的meta標籤,直接返回
40                 }
41             }
42 
43             if (tagName != scriptTag && tagName != noscriptTag
44                 && tagName != styleTag && tagName != linkTag
45                 && tagName != metaTag && tagName != objectTag
46                 && tagName != titleTag && tagName != baseTag
47                 && (isEnd || tagName != htmlTag)
48                 && (isEnd || tagName != headTag)) {
49                 m_inHeadSection = false;
50             }
51         }
52 
53         if (!m_inHeadSection && m_input.numberOfCharactersConsumed() >= bytesToCheckUnconditionally) { // 如果分詞已經進入了<body>標籤範圍,同時分詞數量已經超過了1024,也算成功
54             m_doneChecking = true;
55             return true;
56         }
57     }
58 
59     return false;
60 }

上面原始碼第29行,類HTMLMetaCharsetParser也有一個解碼器m_codec,解碼器是在HTMLMetaCharsetParser物件建立時生成,這個解碼器的真實型別是TextCodecLatin1(Latin1編碼也就是ISO-8859-1,等同於windows-1252編碼)。之所以可以直接使用TextCodecLatin1解碼器,是因為<meta>標籤如果設定正確,都是英文字元,完全可以使用TextCodecLatin1進行解析出來。這樣就避免了為了找到<meta>標籤,需要對位元組流進行解碼,而要解碼就必須要找到<meta>標籤這種雞生蛋、蛋生雞的問題。

程式碼第37行對找到的<meta>標籤進行處理,這個函式比較簡單,主要是解析<meta>標籤當中的屬性,然後檢視這些屬性名中有沒有charset。

 1 bool HTMLMetaCharsetParser::processMeta(HTMLToken& token)
 2 {
 3     AttributeList attributes;
 4     for (auto& attribute : token.attributes()) { // 獲取meta標籤屬性
 5         String attributeName = StringImpl::create8BitIfPossible(attribute.name);
 6         String attributeValue = StringImpl::create8BitIfPossible(attribute.value);
 7         attributes.append(std::make_pair(attributeName, attributeValue));
 8     }
 9 
10     m_encoding = encodingFromMetaAttributes(attributes); // 從屬性中找字符集設定屬性charset
11     return m_encoding.isValid();
12 }

上面分析TextResourceDecoder::checkForHeadCharset函式時,講過第11行TextResourceDecoder類儲存HTML位元組流的操作很重要。原因是可能整個HTML位元組流裡面可能確實沒有設定charset的<meta>標籤,此時TextResourceDecoder::checkForHeadCharset函式就要返回false,導致TextResourceDecoder::decode函式返回空字串,也就是不進行任何解碼。是不是這樣呢?真實的情況是,在接收HTML位元組流整個過程中由於確實沒有找到帶有charset屬性的<meta>標籤,那麼整個接收期間都不會解碼。但是完整的HTML位元組流會被儲存在TextResourceDecoder的成員變數m_buffer裡面,當整個HTML位元組流接收結束的時,會有如下呼叫棧:

WebKit Inside: DOM樹的構建

 從呼叫棧可以看到,當HTML位元組流接收完成,最終會呼叫TextResourceDecoder::flush方法,這個方法會將TextResourceDecoder中有m_buffer儲存的HTML位元組流進行解碼,由於在接收HTML位元組流期間未成功找到編碼方式,因此m_buffer裡面儲存的就是所有待解碼的HTML位元組流,然後在這裡使用預設的編碼windows-1252對全部位元組流進行解碼。因此,如果HTML位元組流中包含漢字,那麼如果不指定字符集,最終頁面就會出現亂碼。解碼完成後,會將解碼之後的字元流儲存到HTMLDocumentParser中。

1 void DecodedDataDocumentParser::flush(DocumentWriter& writer)
2 {
3     String remainingData = writer.decoder().flush();
4     if (remainingData.isEmpty())
5         return;
6 
7     writer.reportDataReceived();
8     append(remainingData.releaseImpl()); // 解碼後的字元流儲存到HTMLDocumentParser
9 }

 

4 解碼總結

整個解碼過程可以分位兩種情形: 第一種情形是HTML位元組流可以解析出帶有charset屬性的<meta>標籤,這樣就可以獲取相應的編碼方式,那麼每接收到一個HML位元組流,都可以使用相應的編碼方式進行解碼,將解碼後的字元流新增到HTMLInputStream當中;第二種是HTML位元組流不能解析帶有charset屬性的<meta>標籤,這樣每接收到一個HTML位元組流,都快取到TextResourceDecoder的m_buffer快取,等完整的HTML位元組流接收完畢,就會使用預設的編碼windows-1252進行解碼。

WebKit Inside: DOM樹的構建

WebKit Inside: DOM樹的構建

 

分詞

接收到的HTML位元組流經過解碼,成為儲存在HTMLInputStream中的字元流。分詞的過程就是從HTMLInputStream中依次取出每一個字元,然後判斷字元是否是特殊的HTML字元'<'、'/'、'>'、'='等。根據這些特殊字元的分割,就能解析出HTML標籤名以及屬性列表,類HTMLToken就是儲存分詞出來的結果。

1 分詞類圖

WebKit Inside: DOM樹的構建

從類圖中可以看到,分詞最重要的是類HTMLTokenizer和類HTMLToken。下面是類HTMLToken的主要資訊:

 1 // 只保留了主要資訊
 2  2 class HTMLToken {
 3  3 public:
 4  4     enum Type { // Token的型別
 5  5         Uninitialized, // Token初始化時的型別
 6  6         DOCTYPE, // 代表Token是DOCType標籤
 7  7         StartTag, // 代表Token是一個開始標籤
 8  8         EndTag, // 代表Token是一個結束標籤
 9  9         Comment, // 代表Token是一個註釋
10 10         Character, // 代表Token是文字
11 11         EndOfFile, // 代表Token是檔案結尾
12 12     };
13 13 
14 14     struct Attribute { // 儲存屬性的資料結構
15 15         Vector<UChar, 32> name; // 屬性名
16 16         Vector<UChar, 64> value; // 屬性值
17 17 
18 18         // Used by HTMLSourceTracker.
19 19         unsigned startOffset;
20 20         unsigned endOffset;
21 21     };
22 22 
23 23     typedef Vector<Attribute, 10> AttributeList; // 屬性列表
24 24     typedef Vector<UChar, 256> DataVector; // 儲存Token名
25 25 
26 26  ...
27 27 
28 28 private:
29 29     Type m_type;
30 30     DataVector m_data;
31 31     // For StartTag and EndTag
32 32     bool m_selfClosing; // Token是注入<img>一樣自結束標籤
33 33     AttributeList m_attributes;
34 34     Attribute* m_currentAttribute; // 當前正在解析的屬性
35 35 };

 

2 分詞流程

WebKit Inside: DOM樹的構建

 

上面分詞流程中HTMLDocumentParser::pumpTokenizerLoop方法是最重要的,從方法名字可以看出這個方法裡面包含迴圈邏輯:

 1 // 只保留關健程式碼
 2 bool HTMLDocumentParser::pumpTokenizerLoop(SynchronousMode mode, bool parsingFragment, PumpSession& session)
 3 {
 4     do { // 分詞迴圈體開始
 5         ...
 6 
 7         if (UNLIKELY(mode == AllowYield && m_parserScheduler->shouldYieldBeforeToken(session))) // 避免長時間處於分詞迴圈中,這裡根據條件暫時退出迴圈
 8             return true;
 9 
10         if (!parsingFragment)
11             m_sourceTracker.startToken(m_input.current(), m_tokenizer);
12 
13         auto token = m_tokenizer.nextToken(m_input.current()); // 進行分詞操作,取出一個token
14         if (!token)
15             return false; // 分詞沒有產生token,就跳出迴圈
16 
17         if (!parsingFragment)
18             m_sourceTracker.endToken(m_input.current(), m_tokenizer);
19 
20         constructTreeFromHTMLToken(token); // 根據token構建DOM樹
21     } while (!isStopped()); 
22 
23     return false;
24 }

上面程式碼中第7行會有一個yield退出操作,這是為了避免長時間處於分詞迴圈,佔用主執行緒。當退出條件為真時,會從分詞迴圈中返回,返回值為true。下面是退出判斷程式碼:

 1 // 只保留關健程式碼
 2 bool HTMLParserScheduler::shouldYieldBeforeToken(PumpSession& session)
 3     {
 4         ...
 5 
 6         // numberOfTokensBeforeCheckingForYield是靜態變數,定義為4096
 7         // session.processedTokensOnLastCheck表示從上一次退出為止,以及處理過的token個數
 8         // session.didSeeScript表示在分詞過程中是否出現過script標籤
 9         if (UNLIKELY(session.processedTokens > session.processedTokensOnLastCheck + numberOfTokensBeforeCheckingForYield || session.didSeeScript))
10             return checkForYield(session);
11 
12         ++session.processedTokens;
13         return false;
14     }
15 
16 
17     bool HTMLParserScheduler::checkForYield(PumpSession& session)
18     {
19         session.processedTokensOnLastCheck = session.processedTokens;
20         session.didSeeScript = false;
21 
22         Seconds elapsedTime = MonotonicTime::now() - session.startTime;
23         return elapsedTime > m_parserTimeLimit; // m_parserTimeLimit的值預設是500ms,從分詞開始超過500ms就要先yield
24     }

如果命中了上面的yield退出條件,那麼什麼時候再次進入分詞呢?下面的程式碼展示了再次進入分詞的過程:

 1 // 保留關鍵程式碼

2 void HTMLDocumentParser::pumpTokenizer(SynchronousMode mode) 3 { 4 ... 5 6 if (shouldResume) // 從pumpTokenizerLoop中yield退出時返回值為true 7 m_parserScheduler->scheduleForResume(); 8 9 } 10 11 12 13 void HTMLParserScheduler::scheduleForResume() 14 { 15 ASSERT(!m_suspended); 16 m_continueNextChunkTimer.startOneShot(0_s); // 觸發timer(0s後觸發),觸發後的響應函式為HTMLParserScheduler::continueNextChunkTimerFired 17 } 18 19 20 // 保留關健程式碼 21 void HTMLParserScheduler::continueNextChunkTimerFired() 22 { 23 ... 24 25 m_parser.resumeParsingAfterYield(); // 重新Resume分詞過程 26 } 27 28 29 void HTMLDocumentParser::resumeParsingAfterYield() 30 { 31 // pumpTokenizer can cause this parser to be detached from the Document, 32 // but we need to ensure it isn't deleted yet. 33 Ref<HTMLDocumentParser> protectedThis(*this); 34 35 // We should never be here unless we can pump immediately. 36 // Call pumpTokenizer() directly so that ASSERTS will fire if we're wrong. 37 pumpTokenizer(AllowYield); // 重新進入分詞過程,該函式會呼叫pumpTokenizerLoop 38 endIfDelayed(); 39 }

從上面程式碼可以看出,再次進入分詞過程是通過觸發一個Timer來實現的,雖然這個Timer在0s後觸發,但是並不意味著Timer的響應函式會立刻執行。如果在此之前主執行緒已經有其他任務到達了執行時機,會有被執行的機會。

繼續看HTMLDocumentParser::pumpTokenizerLoop函式的第13行,這一行進行分詞操作,從解碼後的字元流中分出一個token。實現分詞的程式碼位於HTMLTokenizer::processToken:

 1 // 只保留關鍵程式碼
 2 bool HTMLTokenizer::processToken(SegmentedString& source)
 3 {
 4    
 5     ...
 6 
 7     if (!m_preprocessor.peek(source, isNullCharacterSkippingState(m_state))) // 取出source內部指向的字元,賦給m_nextInputCharacter
 8         return haveBufferedCharacterToken();
 9     UChar character = m_preprocessor.nextInputCharacter(); // 獲取character
10 
11     // https://html.spec.whatwg.org/#tokenization
12     switch (m_state) { // 進行狀態轉換,m_state初始值為DataState
13     ...
14     }
15 
16     return false;
17 }

這個方法由於內部要做很多狀態轉換,總共有1200多行,後面會有4個例子來解釋狀態轉換的邏輯。

首先來看InputStreamPreprocessor::peek方法:

 1  // Returns whether we succeeded in peeking at the next character.
 2  // The only way we can fail to peek is if there are no more
 3  // characters in |source| (after collapsing \r\n, etc).
 4  ALWAYS_INLINE bool InputStreamPreprocessor::peek(SegmentedString& source, bool skipNullCharacters = false)
 5  {
 6      if (UNLIKELY(source.isEmpty()))
 7          return false;
 8  
 9      m_nextInputCharacter = source.currentCharacter(); // 獲取字元流source內部指向的當前字元
10  
11      // Every branch in this function is expensive, so we have a
12      // fast-reject branch for characters that don't require special
13      // handling. Please run the parser benchmark whenever you touch
14      // this function. It's very hot.
15      constexpr UChar specialCharacterMask = '\n' | '\r' | '\0';
16      if (LIKELY(m_nextInputCharacter & ~specialCharacterMask)) {
17          m_skipNextNewLine = false;
18          return true;
19      }
20  
21      return processNextInputCharacter(source, skipNullCharacters); // 跳過空字元,將\r\n換行符合併成\n
22  }
23 
24 
25 bool InputStreamPreprocessor::processNextInputCharacter(SegmentedString& source, bool skipNullCharacters)
26     {
27     ProcessAgain:
28         ASSERT(m_nextInputCharacter == source.currentCharacter());
29 
30         // 針對\r\n換行符,下面if語句處理\r字元並且設定m_skipNextNewLine=true,後面處理\n就直接忽略
31         if (m_nextInputCharacter == '\n' && m_skipNextNewLine) {
32             m_skipNextNewLine = false;
33             source.advancePastNewline(); // 向前移動字元
34             if (source.isEmpty())
35                 return false;
36             m_nextInputCharacter = source.currentCharacter();
37         }
38 
39         // 如果是\r\n連續的換行符,那麼第一次遇到\r字元,將\r字元替換成\n字元,同時設定標誌m_skipNextNewLine=true
40         if (m_nextInputCharacter == '\r') { 
41             m_nextInputCharacter = '\n';
42             m_skipNextNewLine = true;
43             return true;
44         }
45         m_skipNextNewLine = false;
46         if (m_nextInputCharacter || isAtEndOfFile(source))
47             return true;
48 
49         // 跳過空字元
50         if (skipNullCharacters && !m_tokenizer.neverSkipNullCharacters()) {
51             source.advancePastNonNewline();
52             if (source.isEmpty())
53                 return false;
54             m_nextInputCharacter = source.currentCharacter();
55             goto ProcessAgain; // 跳轉到開頭
56         }
57         m_nextInputCharacter = replacementCharacter;
58         return true;
59     }

由於peek方法會跳過空字元,同時合併\r\n字元為\n字元,所以一個字元流source如果包含了空格或者\r\n換行符,實際上處理起來如下圖所示:

WebKit Inside: DOM樹的構建

 

HTMLTokenizer::processToken內部定義了一個狀態機,下面以四種情形來進行解釋。

第一種 <!DCOTYPE>標籤

  1 BEGIN_STATE(DataState) // 剛開始解析是DataState狀態
  2         if (character == '&')
  3             ADVANCE_PAST_NON_NEWLINE_TO(CharacterReferenceInDataState);
  4         if (character == '<') {// 整個字元流一開始是'<',那麼表示是一個標籤的開始
  5             if (haveBufferedCharacterToken())
  6                 RETURN_IN_CURRENT_STATE(true);
  7             ADVANCE_PAST_NON_NEWLINE_TO(TagOpenState); // 跳轉到TagOpenState狀態,並取去下一個字元是'!"
  8         }
  9         if (character == kEndOfFileMarker)
 10             return emitEndOfFile(source);
 11         bufferCharacter(character);
 12         ADVANCE_TO(DataState);
 13 END_STATE()
 14 
 15 // ADVANCE_PAST_NON_NEWLINE_TO定義
 16 #define ADVANCE_PAST_NON_NEWLINE_TO(newState)                   \
 17     do {                                                        \
 18         if (!m_preprocessor.advancePastNonNewline(source, isNullCharacterSkippingState(newState))) { \ // 如果往下移動取不到下一個字元
 19             m_state = newState;                                 \ // 儲存狀態
 20             return haveBufferedCharacterToken();                \ // 返回
 21         }                                                       \
 22         character = m_preprocessor.nextInputCharacter();        \ // 先取出下一個字元
 23         goto newState;                                          \ // 跳轉到指定狀態
 24     } while (false)
 25 
 26 
 27 BEGIN_STATE(TagOpenState)
 28         if (character == '!') // 滿足此條件
 29             ADVANCE_PAST_NON_NEWLINE_TO(MarkupDeclarationOpenState); // 同理,跳轉到MarkupDeclarationOpenState狀態,並且取出下一個字元'D'
 30         if (character == '/')
 31             ADVANCE_PAST_NON_NEWLINE_TO(EndTagOpenState);
 32         if (isASCIIAlpha(character)) {
 33             m_token.beginStartTag(convertASCIIAlphaToLower(character));
 34             ADVANCE_PAST_NON_NEWLINE_TO(TagNameState);
 35         }
 36         if (character == '?') {
 37             parseError();
 38             // The spec consumes the current character before switching
 39             // to the bogus comment state, but it's easier to implement
 40             // if we reconsume the current character.
 41             RECONSUME_IN(BogusCommentState);
 42         }
 43         parseError();
 44         bufferASCIICharacter('<');
 45         RECONSUME_IN(DataState);
 46 END_STATE()
 47 
 48 BEGIN_STATE(MarkupDeclarationOpenState)
 49         if (character == '-') {
 50             auto result = source.advancePast("--");
 51             if (result == SegmentedString::DidMatch) {
 52                 m_token.beginComment();
 53                 SWITCH_TO(CommentStartState);
 54             }
 55             if (result == SegmentedString::NotEnoughCharacters)
 56                 RETURN_IN_CURRENT_STATE(haveBufferedCharacterToken());
 57         } else if (isASCIIAlphaCaselessEqual(character, 'd')) { // 由於character == 'D',滿足此條件
 58             auto result = source.advancePastLettersIgnoringASCIICase("doctype"); // 看解碼後的字元流中是否有完整的"doctype"
 59             if (result == SegmentedString::DidMatch)
 60                 SWITCH_TO(DOCTYPEState); // 如果匹配,則跳轉到DOCTYPEState,同時取出當前指向的字元,由於上面source字元流已經移動了"doctype",因此此時取出的字元為'>'
 61             if (result == SegmentedString::NotEnoughCharacters) // 如果不匹配
 62                 RETURN_IN_CURRENT_STATE(haveBufferedCharacterToken()); // 儲存狀態,直接返回
 63         } else if (character == '[' && shouldAllowCDATA()) {
 64             auto result = source.advancePast("[CDATA[");
 65             if (result == SegmentedString::DidMatch)
 66                 SWITCH_TO(CDATASectionState);
 67             if (result == SegmentedString::NotEnoughCharacters)
 68                 RETURN_IN_CURRENT_STATE(haveBufferedCharacterToken());
 69         }
 70         parseError();
 71         RECONSUME_IN(BogusCommentState);
 72     END_STATE()
 73 
 74 
 75 #define SWITCH_TO(newState)                                     \
 76     do {                                                        \
 77         if (!m_preprocessor.peek(source, isNullCharacterSkippingState(newState))) { \
 78             m_state = newState;                                 \
 79             return haveBufferedCharacterToken();                \
 80         }                                                       \
 81         character = m_preprocessor.nextInputCharacter();        \ // 取出下一個字元
 82         goto newState;                                          \ // 跳轉到指定的state
 83     } while (false)
 84 
 85 
 86 #define RETURN_IN_CURRENT_STATE(expression)                     \
 87     do {                                                        \
 88         m_state = currentState;                                 \ // 儲存當前狀態
 89         return expression;                                      \
 90     } while (false)
 91 
 92 
 93 BEGIN_STATE(DOCTYPEState)
 94     if (isTokenizerWhitespace(character))
 95         ADVANCE_TO(BeforeDOCTYPENameState);
 96     if (character == kEndOfFileMarker) {
 97         parseError();
 98         m_token.beginDOCTYPE();
 99         m_token.setForceQuirks();
100         return emitAndReconsumeInDataState();
101     }
102     parseError();
103     RECONSUME_IN(BeforeDOCTYPENameState);
104 END_STATE()
105 
106 
107 #define RECONSUME_IN(newState)                                  \
108     do {                                                        \ // 直接跳轉到指定state
109         goto newState;                                          \
110     } while (false)
111 
112 
113  BEGIN_STATE(BeforeDOCTYPENameState)
114         if (isTokenizerWhitespace(character))
115             ADVANCE_TO(BeforeDOCTYPENameState);
116         if (character == '>') { // character == '>',匹配此處,到此DOCTYPE標籤匹配完畢
117             parseError();
118             m_token.beginDOCTYPE();
119             m_token.setForceQuirks();
120             return emitAndResumeInDataState(source);
121         }
122         if (character == kEndOfFileMarker) {
123             parseError();
124             m_token.beginDOCTYPE();
125             m_token.setForceQuirks();
126             return emitAndReconsumeInDataState();
127         }
128         m_token.beginDOCTYPE(toASCIILower(character));
129         ADVANCE_PAST_NON_NEWLINE_TO(DOCTYPENameState);
130     END_STATE()
131 
132 
133 
134 
135 inline bool HTMLTokenizer::emitAndResumeInDataState(SegmentedString& source)
136 {
137     saveEndTagNameIfNeeded();
138     m_state = DataState; // 重置狀態為初始狀態DataState
139     source.advancePastNonNewline(); // 移動到下一個字元
140     return true;
141 }

DOCTYPE Token經歷了6個狀態最終被解析出來,整個過程如下圖所示:

WebKit Inside: DOM樹的構建

當Token解析完畢之後,分詞狀態又被重置為DataState,同時需要注意的時,此時字元流source內部指向的是下一個字元'<'。

上面程式碼第61行在用字元流source匹配字串"doctype"時,可能出現匹配不上的情形。為什麼會這樣呢?這是因為整個DOM樹的構建流程,並不是先要解碼完成,解碼完成之後獲取到完整的字元流才進行分詞。從前面解碼可以知道,解碼可能是一邊接收位元組流,一邊進行解碼的,因此分詞也是這樣,只要能解碼出一段字元流,就會立即進行分詞。整個流程會出現如下圖所示:

WebKit Inside: DOM樹的構建

由於這個原因,用來分詞的字元流可能是不完整的。對於出現不完整情形的DOCTYPE分詞過程如下圖所示:

WebKit Inside: DOM樹的構建

上面介紹瞭解碼、分詞、解碼、分詞處理DOCTYPE標籤的情形,可以看到從邏輯上這種情形與完整解碼再分詞是一樣的。後續介紹的時都會只針對完整解碼再分詞的情形,對於一邊解碼一邊分詞的情形,只需要正確的認識source字元流內部指標的移動,並不難分析。

 

第二種 html標籤

html標籤的分詞過程和DOCTYPE類似,其相關程式碼如下:

 1 BEGIN_STATE(TagOpenState)
 2     if (character == '!')
 3         ADVANCE_PAST_NON_NEWLINE_TO(MarkupDeclarationOpenState);
 4     if (character == '/')
 5         ADVANCE_PAST_NON_NEWLINE_TO(EndTagOpenState);
 6     if (isASCIIAlpha(character)) { // 在開標籤狀態下,當前字元為'h'
 7         m_token.beginStartTag(convertASCIIAlphaToLower(character)); // 將'h'新增到Token名中
 8         ADVANCE_PAST_NON_NEWLINE_TO(TagNameState); // 跳轉到TagNameState,並移動到下一個字元't'
 9     }
10     if (character == '?') {
11         parseError();
12         // The spec consumes the current character before switching
13         // to the bogus comment state, but it's easier to implement
14         // if we reconsume the current character.
15         RECONSUME_IN(BogusCommentState);
16     }
17     parseError();
18     bufferASCIICharacter('<');
19     RECONSUME_IN(DataState);
20 END_STATE()
21 
22 
23 BEGIN_STATE(TagNameState)
24     if (isTokenizerWhitespace(character))
25         ADVANCE_TO(BeforeAttributeNameState);
26     if (character == '/')
27         ADVANCE_PAST_NON_NEWLINE_TO(SelfClosingStartTagState);
28     if (character == '>') // 在這個狀態下遇到起始標籤終止字元
29         return emitAndResumeInDataState(source); // 當前分詞結束,重置分詞狀態為DataState
30     if (m_options.usePreHTML5ParserQuirks && character == '<')
31         return emitAndReconsumeInDataState();
32     if (character == kEndOfFileMarker) {
33         parseError();
34         RECONSUME_IN(DataState);
35     }
36     m_token.appendToName(toASCIILower(character)); // 將當前字元新增到Token名
37     ADVANCE_PAST_NON_NEWLINE_TO(TagNameState); // 繼續跳轉到當前狀態,並移動到下一個字元
38 END_STATE()

 

WebKit Inside: DOM樹的構建

 

第三種 帶有屬性的標籤div

HTML標籤可以帶有屬性,屬性由屬性名和屬性值組成,屬性之間以及屬性與標籤名之間用空格分隔:

1  <!-- div標籤有兩個屬性,屬性名為class和align,它們的值都帶有引號 -->
2  <div class="news" align="center">Hello,World!</div>
3  
4  
5  <!-- 屬性值也可以不帶引號 -->
6  <div class=news align=center>Hello,World!</div>

整個div標籤的解析中,標籤名div的解析流程和上面的html標籤解析一樣,當在解析標籤名的過程中,碰到了空白字元,說明要開始解析屬性了,下面是相關程式碼:

  1 BEGIN_STATE(TagNameState)
  2     if (isTokenizerWhitespace(character)) // 在解析TagName時遇到空白字元,標誌屬性開始
  3         ADVANCE_TO(BeforeAttributeNameState);
  4     if (character == '/')
  5         ADVANCE_PAST_NON_NEWLINE_TO(SelfClosingStartTagState);
  6     if (character == '>')
  7         return emitAndResumeInDataState(source);
  8     if (m_options.usePreHTML5ParserQuirks && character == '<')
  9         return emitAndReconsumeInDataState();
 10     if (character == kEndOfFileMarker) {
 11         parseError();
 12         RECONSUME_IN(DataState);
 13     }
 14     m_token.appendToName(toASCIILower(character));
 15     ADVANCE_PAST_NON_NEWLINE_TO(TagNameState);
 16 END_STATE()
 17 
 18 #define ADVANCE_TO(newState)                                    \
 19     do {                                                        \
 20         if (!m_preprocessor.advance(source, isNullCharacterSkippingState(newState))) { \ // 移動到下一個字元
 21             m_state = newState;                                 \
 22             return haveBufferedCharacterToken();                \
 23         }                                                       \
 24         character = m_preprocessor.nextInputCharacter();        \
 25         goto newState;                                          \ // 跳轉到指定狀態
 26     } while (false)
 27 
 28 
 29 BEGIN_STATE(BeforeAttributeNameState)
 30     if (isTokenizerWhitespace(character)) // 如果標籤名後有連續空格,那麼就不停的跳過,在當前狀態不停迴圈
 31         ADVANCE_TO(BeforeAttributeNameState);
 32     if (character == '/')
 33         ADVANCE_PAST_NON_NEWLINE_TO(SelfClosingStartTagState);
 34     if (character == '>')
 35         return emitAndResumeInDataState(source);
 36     if (m_options.usePreHTML5ParserQuirks && character == '<')
 37         return emitAndReconsumeInDataState();
 38     if (character == kEndOfFileMarker) {
 39         parseError();
 40         RECONSUME_IN(DataState);
 41     }
 42     if (character == '"' || character == '\'' || character == '<' || character == '=')
 43         parseError();
 44     m_token.beginAttribute(source.numberOfCharactersConsumed()); // Token的屬性列表增加一個,用來存放新的屬性名與屬性值
 45     m_token.appendToAttributeName(toASCIILower(character)); // 新增屬性名
 46     ADVANCE_PAST_NON_NEWLINE_TO(AttributeNameState); // 跳轉到AttributeNameState,並且移動到下一個字元
 47 END_STATE()
 48 
 49 
 50 BEGIN_STATE(AttributeNameState)
 51     if (isTokenizerWhitespace(character))
 52         ADVANCE_TO(AfterAttributeNameState);
 53     if (character == '/')
 54         ADVANCE_PAST_NON_NEWLINE_TO(SelfClosingStartTagState);
 55     if (character == '=')
 56         ADVANCE_PAST_NON_NEWLINE_TO(BeforeAttributeValueState); // 在解析屬性名的過程中如果碰到=,說明屬性名結束,屬性值就要開始
 57     if (character == '>')
 58         return emitAndResumeInDataState(source);
 59     if (m_options.usePreHTML5ParserQuirks && character == '<')
 60         return emitAndReconsumeInDataState();
 61     if (character == kEndOfFileMarker) {
 62         parseError();
 63         RECONSUME_IN(DataState);
 64     }
 65     if (character == '"' || character == '\'' || character == '<' || character == '=')
 66         parseError();
 67     m_token.appendToAttributeName(toASCIILower(character));
 68     ADVANCE_PAST_NON_NEWLINE_TO(AttributeNameState);
 69 END_STATE()
 70 
 71 
 72 BEGIN_STATE(BeforeAttributeValueState)
 73     if (isTokenizerWhitespace(character))
 74         ADVANCE_TO(BeforeAttributeValueState);
 75     if (character == '"')
 76         ADVANCE_PAST_NON_NEWLINE_TO(AttributeValueDoubleQuotedState); // 有的屬性值有引號包圍,這裡跳轉到AttributeValueDoubleQuotedState,並移動到下一個字元
 77     if (character == '&')
 78         RECONSUME_IN(AttributeValueUnquotedState);
 79     if (character == '\'')
 80         ADVANCE_PAST_NON_NEWLINE_TO(AttributeValueSingleQuotedState);
 81     if (character == '>') {
 82         parseError();
 83         return emitAndResumeInDataState(source);
 84     }
 85     if (character == kEndOfFileMarker) {
 86         parseError();
 87         RECONSUME_IN(DataState);
 88     }
 89     if (character == '<' || character == '=' || character == '`')
 90         parseError();
 91     m_token.appendToAttributeValue(character); // 有的屬性值沒有引號包圍,新增屬性值字元到Token
 92     ADVANCE_PAST_NON_NEWLINE_TO(AttributeValueUnquotedState); // 跳轉到AttributeValueUnquotedState,並移動到下一個字元
 93 END_STATE()
 94 
 95 BEGIN_STATE(AttributeValueDoubleQuotedState)
 96     if (character == '"') { // 在當前狀態下如果遇到引號,說明屬性值結束
 97         m_token.endAttribute(source.numberOfCharactersConsumed()); // 結束屬性解析
 98         ADVANCE_PAST_NON_NEWLINE_TO(AfterAttributeValueQuotedState); // 跳轉到AfterAttributeValueQuotedState,並移動到下一個字元
 99     }
100     if (character == '&') {
101         m_additionalAllowedCharacter = '"';
102         ADVANCE_PAST_NON_NEWLINE_TO(CharacterReferenceInAttributeValueState);
103     }
104     if (character == kEndOfFileMarker) {
105         parseError();
106         m_token.endAttribute(source.numberOfCharactersConsumed());
107         RECONSUME_IN(DataState);
108     }
109     m_token.appendToAttributeValue(character); // 將屬性值字元新增到Token
110     ADVANCE_TO(AttributeValueDoubleQuotedState); // 跳轉到當前狀態
111 END_STATE()
112 
113 
114 BEGIN_STATE(AfterAttributeValueQuotedState)
115     if (isTokenizerWhitespace(character))
116         ADVANCE_TO(BeforeAttributeNameState); // 屬性值解析完畢,如果後面繼續跟著空白字元,說明後續還有屬性要解析,調回到BeforeAttributeNameState
117     if (character == '/')
118         ADVANCE_PAST_NON_NEWLINE_TO(SelfClosingStartTagState);
119     if (character == '>')
120         return emitAndResumeInDataState(source); // 屬性值解析完畢,如果遇到'>'字元,說明整個標籤也要解析完畢了,此時結束當前標籤解析,並且重置分詞狀態為DataState,並移動到下一個字元
121     if (m_options.usePreHTML5ParserQuirks && character == '<')
122         return emitAndReconsumeInDataState();
123     if (character == kEndOfFileMarker) {
124         parseError();
125         RECONSUME_IN(DataState);
126     }
127     parseError();
128     RECONSUME_IN(BeforeAttributeNameState);
129 END_STATE()
130 
131 BEGIN_STATE(AttributeValueUnquotedState)
132     if (isTokenizerWhitespace(character)) { // 當解析不帶引號的屬性值時遇到空白字元(這與帶引號的屬性值不一樣,帶引號的屬性值可以包含空白字元),說明當前屬性解析完畢,後面還有其他屬性,跳轉到BeforeAttributeNameState,並且移動到下一個字元
133         m_token.endAttribute(source.numberOfCharactersConsumed());
134         ADVANCE_TO(BeforeAttributeNameState);
135     }
136     if (character == '&') {
137         m_additionalAllowedCharacter = '>';
138         ADVANCE_PAST_NON_NEWLINE_TO(CharacterReferenceInAttributeValueState);
139     }
140     if (character == '>') { // 解析過程中如果遇到'>'字元,說明整個標籤也要解析完畢了,此時結束當前標籤解析,並且重置分詞狀態為DataState,並移動到下一個字元
141         m_token.endAttribute(source.numberOfCharactersConsumed());
142         return emitAndResumeInDataState(source);
143     }
144     if (character == kEndOfFileMarker) {
145         parseError();
146         m_token.endAttribute(source.numberOfCharactersConsumed());
147         RECONSUME_IN(DataState);
148     }
149     if (character == '"' || character == '\'' || character == '<' || character == '=' || character == '`')
150         parseError();
151     m_token.appendToAttributeValue(character); // 將遇到的屬性值字元新增到Token
152     ADVANCE_PAST_NON_NEWLINE_TO(AttributeValueUnquotedState); // 跳轉到當前狀態,並且移動到下一個字元
153 END_STATE()

從程式碼中可以看到,當屬性值帶引號和不帶引號時,解析的邏輯是不一樣的。當屬性值帶有引號時,屬性值裡面是可以包含空白字元的。如果屬性值不帶引號,那麼一旦碰到空白字元,說明這個屬性就解析結束了,會進入下一個屬性的解析當中。

WebKit Inside: DOM樹的構建

 

第四種 純文字解析

這裡的純文字指起始標籤與結束標籤之間的任何純文字,包括指令碼文、CSS文字等等,如下圖所示:

<!-- div標籤中的純文字 Hello,Word! -->
<div class=news align=center>Hello,World!</div>


<!-- script標籤中的純文字 window.name = 'Lucy'; -->
<script>window.name = 'Lucy';</script>

純文字的解析過程比較簡單,就是不停的在DataState狀態上跳轉,快取遇到的字元,直到遇見一個結束標籤的'<'字元,相關程式碼如下:

 1 BEGIN_STATE(DataState)
 2     if (character == '&')
 3         ADVANCE_PAST_NON_NEWLINE_TO(CharacterReferenceInDataState);
 4     if (character == '<') { // 如果在解析文字的過程中遇到開標籤,分兩種情況
 5         if (haveBufferedCharacterToken()) // 第一種,如果快取了文字字元就直接按當前DataState返回,並不移動字元,所以下次再進入分詞操作時取到的字元仍為'<'
 6             RETURN_IN_CURRENT_STATE(true);
 7         ADVANCE_PAST_NON_NEWLINE_TO(TagOpenState); // 第二種,如果沒有快取任何文字字元,直接進入TagOpenState狀態,進入到起始標籤解析過程,並且移動下一個字元
 8     }
 9     if (character == kEndOfFileMarker)
10         return emitEndOfFile(source);
11     bufferCharacter(character); // 快取遇到的字元
12     ADVANCE_TO(DataState); // 迴圈跳轉到當前DataState狀態,並且移動到下一個字元
13 END_STATE()

由於流程比較簡單,下面只給出解析div標籤中純文字的結果:

WebKit Inside: DOM樹的構建

 

建立節點與新增節點

1 相關類圖

WebKit Inside: DOM樹的構建

 

2 建立、新增流程

上面的分詞迴圈中,每分出一個Token,就會根據Token建立對應的Node,然後將Node新增到DOM樹上。(HTMLDocumentParser::pumpTokenizerLoop方法在上面分詞中有介紹)。

WebKit Inside: DOM樹的構建

 

 

上面方法中首先看HTMLTreeBuilder::constructTree,程式碼如下:

 1 // 只保留關健程式碼
 2 void HTMLTreeBuilder::constructTree(AtomHTMLToken&& token)
 3 {
 4     ...
 5 
 6     if (shouldProcessTokenInForeignContent(token))
 7         processTokenInForeignContent(WTFMove(token));
 8     else
 9         processToken(WTFMove(token)); // HTMLToken在這裡被處理
10 
11     ...
12 
13     m_tree.executeQueuedTasks(); // HTMLContructionSiteTask在這裡被執行,有時候也直接在建立的過程中直接執行,然後這個方法發現佇列為空就會直接返回
14     // The tree builder might have been destroyed as an indirect result of executing the queued tasks.
15 }
16 
17 
18 void HTMLConstructionSite::executeQueuedTasks()
19 {
20     if (m_taskQueue.isEmpty()) // 佇列為空,就直接返回
21         return;
22 
23     // Copy the task queue into a local variable in case executeTask
24     // re-enters the parser.
25     TaskQueue queue = WTFMove(m_taskQueue);
26 
27     for (auto& task : queue) // 這裡的task就是HTMLContructionSiteTask
28         executeTask(task); // 執行task
29 
30     // We might be detached now.
31 }

上面程式碼中HTMLTreeBuilder::processToken就是處理Token生成對應Node的地方,程式碼如下所示:

 1 void HTMLTreeBuilder::processToken(AtomHTMLToken&& token)
 2 {
 3     switch (token.type()) {
 4     case HTMLToken::Uninitialized:
 5         ASSERT_NOT_REACHED();
 6         break;
 7     case HTMLToken::DOCTYPE: // HTML中的DOCType標籤
 8         m_shouldSkipLeadingNewline = false;
 9         processDoctypeToken(WTFMove(token));
10         break;
11     case HTMLToken::StartTag: // 起始HTML標籤
12         m_shouldSkipLeadingNewline = false;
13         processStartTag(WTFMove(token));
14         break;
15     case HTMLToken::EndTag: // 結束HTML標籤
16         m_shouldSkipLeadingNewline = false;
17         processEndTag(WTFMove(token));
18         break;
19     case HTMLToken::Comment: // HTML中的註釋
20         m_shouldSkipLeadingNewline = false;
21         processComment(WTFMove(token));
22         return;
23     case HTMLToken::Character: // HTML中的純文字
24         processCharacter(WTFMove(token));
25         break;
26     case HTMLToken::EndOfFile: // HTML結束標誌
27         m_shouldSkipLeadingNewline = false;
28         processEndOfFile(WTFMove(token));
29         break;
30     }
31 }

可以看到上面程式碼對7類Token做了處理,由於處理的流程都是類似的,這裡只給出3種HTML標籤的建立新增過程,分別是DOCTYPE標籤,html標籤,title標籤文字,剩下的過程都使用圖表示。

2.1 DOCTYPE標籤

 1 // 只保留關健程式碼
 2 void HTMLTreeBuilder::processDoctypeToken(AtomHTMLToken&& token)
 3 {
 4     ASSERT(token.type() == HTMLToken::DOCTYPE);
 5     if (m_insertionMode == InsertionMode::Initial) { // m_insertionMode的初始值就是InsertionMode::Initial
 6         m_tree.insertDoctype(WTFMove(token)); // 插入DOCTYPE標籤
 7         m_insertionMode = InsertionMode::BeforeHTML; // 插入DOCTYPE標籤之後,m_insertionMode設定為InsertionMode::BeforeHTML,表示下面要開是HTML標籤插入
 8         return;
 9     }
10    
11    ...
12 }
13 
14 // 只保留關健程式碼
15 void HTMLConstructionSite::insertDoctype(AtomHTMLToken&& token)
16 {
17     ...
18 
19     // m_attachmentRoot就是Document物件,文件根節點
20     // DocumentType::create方法建立出DOCTYPE節點
21     // attachLater方法內部建立出HTMLContructionSiteTask
22     attachLater(m_attachmentRoot, DocumentType::create(m_document, token.name(), publicId, systemId));
23 
24     ...
25 }
26 
27 // 只保留關健程式碼
28 void HTMLConstructionSite::attachLater(ContainerNode& parent, Ref<Node>&& child, bool selfClosing)
29 {
30    ...
31 
32     HTMLConstructionSiteTask task(HTMLConstructionSiteTask::Insert); // 建立HTMLConstructionSiteTask
33     task.parent = &parent; // task持有當前節點的父節點
34     task.child = WTFMove(child); // task持有需要操作的節點
35     task.selfClosing = selfClosing; // 是否自關閉節點
36 
37     // Add as a sibling of the parent if we have reached the maximum depth allowed.
38     // m_openElements就是HTMLElementStack,在這裡還看不到它的作用,後面會講。這裡可以看到這個stack裡面加入的物件個數是有限制的,最大不超過512個。
39     // 所以如果一個HTML標籤巢狀過多的子標籤,就會觸發這裡的操作
40     if (m_openElements.stackDepth() > m_maximumDOMTreeDepth && task.parent->parentNode())
41         task.parent = task.parent->parentNode(); // 滿足條件,就會將當前節點新增到爺爺節點,而不是父節點
42 
43     ASSERT(task.parent);
44     m_taskQueue.append(WTFMove(task)); // 將task新增到Queue當中
45 }

從程式碼可以看到,這裡只是建立了DOCTYPE節點,還沒有真正新增。真正執行新增的操作,需要執行HTMLContructionSite::executeQueuedTasks,這個方法在一開始有列出來。下面就來看下每個Task如何被執行。

 1 // 方法位於HTMLContructionSite.cpp
 2 static inline void executeTask(HTMLConstructionSiteTask& task)
 3 {
 4     switch (task.operation) { // HTMLConstructionSiteTask儲存了自己要做的操作,構建DOM樹一般都是Insert操作
 5     case HTMLConstructionSiteTask::Insert:
 6         executeInsertTask(task); // 這裡執行insert操作
 7         return;
 8     // All the cases below this point are only used by the adoption agency.
 9     case HTMLConstructionSiteTask::InsertAlreadyParsedChild:
10         executeInsertAlreadyParsedChildTask(task);
11         return;
12     case HTMLConstructionSiteTask::Reparent:
13         executeReparentTask(task);
14         return;
15     case HTMLConstructionSiteTask::TakeAllChildrenAndReparent:
16         executeTakeAllChildrenAndReparentTask(task);
17         return;
18     }
19     ASSERT_NOT_REACHED();
20 }
21 
22 // 只保留關健程式碼,方法位於HTMLContructionSite.cpp
23 static inline void executeInsertTask(HTMLConstructionSiteTask& task)
24 {
25     ASSERT(task.operation == HTMLConstructionSiteTask::Insert);
26 
27     insert(task); // 繼續呼叫插入方法
28 
29     ...
30 }
31 
32 // 只保留關健程式碼,方法位於HTMLContructionSite.cpp
33 static inline void insert(HTMLConstructionSiteTask& task)
34 {
35    ...
36 
37     ASSERT(!task.child->parentNode());
38     if (task.nextChild)
39         task.parent->parserInsertBefore(*task.child, *task.nextChild);
40     else
41         task.parent->parserAppendChild(*task.child); // 呼叫父節點方法繼續插入
42 }
43 
44 // 只保留關健程式碼
45 void ContainerNode::parserAppendChild(Node& newChild)
46 {
47    ...
48 
49     executeNodeInsertionWithScriptAssertion(*this, newChild, ChildChange::Source::Parser, ReplacedAllChildren::No, [&] {
50         if (&document() != &newChild.document())
51             document().adoptNode(newChild);
52 
53         appendChildCommon(newChild); // 在Block回撥中呼叫此方法繼續插入
54         
55         ...
56     });
57 }
58 
59 // 最終呼叫的是這個方法進行插入
60 void ContainerNode::appendChildCommon(Node& child)
61 {
62     ScriptDisallowedScope::InMainThread scriptDisallowedScope;
63 
64     child.setParentNode(this);
65 
66     if (m_lastChild) { // 父節點已經插入子節點,執行在這裡
67         child.setPreviousSibling(m_lastChild);
68         m_lastChild->setNextSibling(&child);
69     } else
70         m_firstChild = &child; // 如果父節點是首次插入子節點,執行在這裡
71 
72     m_lastChild = &child; // 更新m_lastChild
73 }

經過執行上面方法之後,原來只有一個根節點的DOM樹變成了下面的樣子:

WebKit Inside: DOM樹的構建

2.2 html標籤

 1 // processStartTag內部有很多狀態處理,這裡只保留關健程式碼
 2 void HTMLTreeBuilder::processStartTag(AtomHTMLToken&& token)
 3 {
 4     ASSERT(token.type() == HTMLToken::StartTag);
 5     switch (m_insertionMode) {
 6     case InsertionMode::Initial:
 7         defaultForInitial();
 8         ASSERT(m_insertionMode == InsertionMode::BeforeHTML);
 9         FALLTHROUGH;
10     case InsertionMode::BeforeHTML:
11         if (token.name() == htmlTag) { // html標籤在這裡處理
12             m_tree.insertHTMLHtmlStartTagBeforeHTML(WTFMove(token));
13             m_insertionMode = InsertionMode::BeforeHead; // 插入完html標籤,m_insertionMode = InsertionMode::BeforeHead,表明即將處理head標籤
14             return;
15         }
16 
17     ...
18     }
19 }
20 
21 
22 // 只保留關健程式碼
23 void HTMLConstructionSite::insertHTMLHtmlStartTagBeforeHTML(AtomHTMLToken&& token)
24 {
25     auto element = HTMLHtmlElement::create(m_document); // 建立html節點
26     setAttributes(element, token, m_parserContentPolicy);
27     attachLater(m_attachmentRoot, element.copyRef()); // 同樣呼叫了attachLater方法,與DOCTYPE類似
28     m_openElements.pushHTMLHtmlElement(HTMLStackItem::create(element.copyRef(), WTFMove(token))); // 注意這裡,這裡向HTMLElementStack中壓入了正在插入的html起始標籤
29 
30     executeQueuedTasks(); // 這裡在插入操作直接執行了task,外面HTMLTreeBuilder::constructTree方法呼叫的executeQueuedTasks方法就會直接返回
31 
32     ...
33 }

執行上面程式碼之後,DOM樹變成了如下圖所示:

WebKit Inside: DOM樹的構建

當要插入title起始標籤之後,DOM樹以及HTMLElementStack m_openElements如下圖所示:

WebKit Inside: DOM樹的構建

 

3.3 title標籤文字,

title標籤的文字作為文字節點插入,生成文字節點的程式碼如下:

 1 // 只保留關健程式碼
 2 void HTMLConstructionSite::insertTextNode(const String& characters, WhitespaceMode whitespaceMode)
 3 {
 4     HTMLConstructionSiteTask task(HTMLConstructionSiteTask::Insert);
 5     task.parent = &currentNode(); // 直接取HTMLElementStack m_openElements的棧頂節點,此時節點是title
 6 
 7     ...
 8 
 9     unsigned currentPosition = 0;
10     unsigned lengthLimit = shouldUseLengthLimit(*task.parent) ? Text::defaultLengthLimit : std::numeric_limits<unsigned>::max(); // 限制文字節點最大包含的字元個數為65536
11 
12     ...
13 
14 
15     // 可以看到如果文字過長,會將分割成多個文字節點
16     while (currentPosition < characters.length()) {
17         AtomString charactersAtom = m_whitespaceCache.lookup(characters, whitespaceMode);
18         auto textNode = Text::createWithLengthLimit(task.parent->document(), charactersAtom.isNull() ? characters : charactersAtom.string(), currentPosition, lengthLimit);
19         // If we have a whole string of unbreakable characters the above could lead to an infinite loop. Exceeding the length limit is the lesser evil.
20         if (!textNode->length()) {
21             String substring = characters.substring(currentPosition);
22             AtomString substringAtom = m_whitespaceCache.lookup(substring, whitespaceMode);
23             textNode = Text::create(task.parent->document(), substringAtom.isNull() ? substring : substringAtom.string()); // 生成文字節點
24         }
25 
26         currentPosition += textNode->length(); // 下一個文字節點包含的字元起點
27         ASSERT(currentPosition <= characters.length());
28         task.child = WTFMove(textNode);
29 
30         executeTask(task); // 直接執行Task插入
31     }
32 }

從程式碼可以看到,如果一個節點後面跟的文字字元過多,會被分割成多個文字節點插入。下面的例子將title節點後面的文字字元個數設定成85248,使用Safari檢視確實生成了2個文字節點:

WebKit Inside: DOM樹的構建

 

 當遇到title結束標籤,程式碼處理如下:

 1 // 程式碼內部有很多狀態處理,這裡只保留關健程式碼
 2 void HTMLTreeBuilder::processEndTag(AtomHTMLToken&& token)
 3 {
 4     ASSERT(token.type() == HTMLToken::EndTag);
 5     switch (m_insertionMode) {
 6     ...
 7 
 8         case InsertionMode::Text: // 由於遇到title結束標籤之前插入了文字,因此此時的插入模式就是InsertionMode::Text
 9         
10         m_tree.openElements().pop(); // 因為遇到了title結束標籤,整個標籤已經處理完畢,從HTMLElementStack棧中彈出棧頂元素title
11         m_insertionMode = m_originalInsertionMode; // 恢復之前的插入模式
12         break;
13     
14     ...
15 }

每當遇到一個標籤的結束標籤,都會像上面一樣將HTMLElementStack m_openElementsStack的棧頂元素彈出。執行上面程式碼之後,DOM樹與HTMLElementStack如下圖所示:

WebKit Inside: DOM樹的構建

 

 當整個DOM樹構建完成之後,DOM樹和HTMLElementStack m_openElements如下圖所示:

WebKit Inside: DOM樹的構建

從上圖可以看到,當構建完DOM,HTMLElementStack m_openElements並沒有將棧完全清空,而是保留了2個節點:html節點與body節點。這可以從Xcode的控制檯輸出看到:

WebKit Inside: DOM樹的構建

同時可以看到,記憶體中的DOM樹結構和文章開頭畫的邏輯上的DOM樹結構是不一樣的。邏輯上的DOM樹父節點有多少子節點,就有多少指向子節點的指標,而記憶體中的DOM樹,不管父節點有多少子節點,始終只有2個指標指向子節點:m_firstChild與m_lastChild。同時,記憶體中的DOM樹兄弟節點之間也相互有指標引用,而邏輯上的DOM樹結構是沒有的。通過這樣的資料結構,使得記憶體中的DOM結構所佔用的空間大大減少,同時也能達到遍歷整棵樹的效果。試想一下,如果一個父節點有100個子節點,那麼使用邏輯上的DOM樹結構,父節點就需要100個指向子節點的指標,如果一個指標佔用8位元組,那麼總共就要佔用800位元組。但是使用上面記憶體中DOM的表示方式,父節點只需要2個指標就可以了,總共佔用16位元組,記憶體消耗大大減少。雖然兩者實現方式不一樣,但是兩者是等價的,都可以正確的表示HTML文件。

相關文章