從Chrome源碼看瀏覽器如何計算CSS

在《Effective前端6:避免頁面卡頓》這篇裡面介紹了瀏覽器渲染頁面的過程:

並且《從Chrome源碼看瀏覽器如何構建DOM樹》介紹了第一步如何解析Html構建DOM樹,這個過程大概如下:

瀏覽器每收到一段html的文本之後,就會把它序列化成一個個的tokens,依次遍歷這些token,實例化成對應的html結點並插入到DOM樹裡面。

我將在這一篇介紹第二步Style的過程,即CSS的處理。

1. 載入CSS

在構建DOM的過程中,如果遇到link的標籤,當把它插到DOM裡面之後,就會觸發資源載入——根據href指明的鏈接:

<link rel="stylesheet" href="demo.css">

上面的rel指明了它是一個樣式文件。這個載入是非同步,不會影響DOM樹的構建,只是說在CSS沒處理好之前,構建好的DOM並不會顯示出來。用以下的html和css做試驗:

<!DOCType html><html><head> <link rel="stylesheet" href="demo.css"></head><body><div class="text"> <p>hello, world</p></div></body>

demo.css如下:

.text{ font-size: 20px;}.text p{ color: #505050;}

從列印的log可以看出(添加列印的源碼略):

[DocumentLoader.cpp(558)] 「<!DOCType html>
<html>
<head>
<link rel=」stylesheet」 href=」demo.css」>
</head>
<body>
<div class=」text」>
<p>hello, world</p>
</div>
</body>
</html>

[HTMLDocumentParser.cpp(765)] 「tagName: html |type: DOCTYPE|attr: |text: 「

[HTMLDocumentParser.cpp(765)] 「tagName: |type: Character |attr: |text:

[HTMLDocumentParser.cpp(765)] 「tagName: html |type: startTag |attr: |text: 「

[HTMLDocumentParser.cpp(765)] 「tagName: html |type: EndTag |attr: |text: 「

[HTMLDocumentParser.cpp(765)] 「tagName: |type: EndOfFile|attr: |text: 「

[Document.cpp(1231)] readystatechange to Interactive

[CSSParserImpl.cpp(217)] recieved and parsing stylesheet: 「.text{
font-size: 20px;
}
.text p{
color: #505050;
}

在CSS沒有載入好之前,DOM樹已經構建好了。為什麼DOM構建好了不把html放出來,因為沒有樣式的html直接放出來,給人看到的頁面將會是亂的。所以CSS不能太大,頁面一打開將會停留較長時間的白屏,所以把圖片/字體等轉成base64放到CSS裡面是一種不太推薦的做法。

2. 解析CSS

(1)字元串 -> tokens

CSS解析和html解析有比較像的地方,都是先格式化成tokens。CSS token定義了很多種類型,如下的CSS會被拆成這麼多個token:

經常看到有人建議CSS的色值使用16位的數字會優於使用rgb的表示,這個是子虛烏有,還是有根據的呢?

如下所示:

如果改成rgb,它將變成一個函數類型的token,這個函數需要再計算一下。從這裡看的話,使用16位色值確實比使用rgb好。

(2)tokens -> styleRule

這裡不關心它是怎麼把tokens轉化成style的規則的,我們只要看格式化後的styleRule是怎麼樣的就可以。每個styleRule主要包含兩個部分,一個是選擇器selectors,第二個是屬性集properties。用以下CSS:

.text .hello{ color: rgb(200, 200, 200); width: calc(100% - 20px);} #world{ margin: 20px;}

列印出來的選擇器結果為(相關列印代碼省略):

selector text = 「.text .hello」

value = 「hello」 matchType = 「Class」 relation = 「Descendant」

tag history selector text = 「.text」

value = 「text」 matchType = 「Class」 relation = 「SubSelector」

selector text = 「#world」

value = 「world」 matchType = 「Id」 relation = 「SubSelector」

從第一個選擇器可以看出,它的解析是從右往左的,這個在判斷match的時候比較有用。

blink定義了幾種matchType:

enum MatchType { Unknown, Tag, // Example: div Id, // Example: #id Class, // example: .class PseudoClass, // Example: :nth-child(2) PseudoElement, // Example: ::first-line PagePseudoClass, // ?? AttributeExact, // Example: E[foo="bar"] AttributeSet, // Example: E[foo] AttributeHyphen, // Example: E[foo|="bar"] AttributeList, // Example: E[foo~="bar"] AttributeContain, // css3: E[foo*="bar"] AttributeBegin, // css3: E[foo^="bar"] AttributeEnd, // css3: E[foo$="bar"] FirstAttributeSelectorMatch = AttributeExact, };

還定義了幾種選擇器的類型:

enum RelationType { SubSelector, // No combinator Descendant, // "Space" combinator Child, // > combinator DirectAdjacent, // + combinator IndirectAdjacent, // ~ combinator // Special cases for shadow DOM related selectors. ShadowPiercingDescendant, // >>> combinator ShadowDeep, // /deep/ combinator ShadowPseudo, // ::shadow pseudo element ShadowSlot // ::slotted() pseudo element };

.text .hello的.hello選擇器的類型就是Descendant,即後代選擇器。記錄選擇器類型的作用是協助判斷當前元素是否match這個選擇器。例如,由於.hello是一個父代選器,所以它從右往左的下一個選擇器就是它的父選擇器,於是判斷當前元素的所有父元素是否匹配.text這個選擇器。

第二個部分——屬性列印出來是這樣的:

selector text = 「.text .hello」

perperty id = 15 value = 「rgb(200, 200, 200)」

perperty id = 316 value = 「calc(100% – 20px)」

selector text = 「#world」

perperty id = 147 value = 「20px」

perperty id = 146 value = 「20px」

perperty id = 144 value = 「20px」

perperty id = 145 value = 「20px」

所有的CSS的屬性都是用id標誌的,上面的id依次對應:

enum CSSPropertyID { CSSPropertyColor = 15, CSSPropertyWidth = 316, CSSPropertyMarginLeft = 145, CSSPropertyMarginRight = 146, CSSPropertyMarginTop = 147, CSSPropertyMarkerEnd = 148,}

設置了margin: 20px,會轉化成四個屬性。從這裡可以看出CSS提倡屬性合併,但是最後還是會被拆成各個小屬性。所以屬性合併最大的作用應該在於減少CSS的代碼量。

一個選擇器和一個屬性集就構成一條rule,同一個css表的所有rule放到同一個stylesheet對象裡面,blink會把用戶的樣式存放到一個m_authorStyleSheets的向量裡面,如下圖示意:

除了autherStyleSheet,還有瀏覽器默認的樣式DefaultStyleSheet,這裡面有幾張,最常見的是UAStyleSheet,其它的還有svg和全屏的默認樣式表。Blink ua全部樣式可見這個文件html.css,這裡面有一些常見的設置,如把style/link/script等標籤display: none,把div/h1/p等標籤display: block,設置p/h1/h2等標籤的margin值等,從這個樣式表還可以看到Chrome已經支持了HTML5.1新加的標籤,如dialog:

dialog { position: absolute; left: 0; right: 0; width: -webkit-fit-content; height: -webkit-fit-content; margin: auto; border: solid; padding: 1em; background: white; color: black;}

另外還有怪異模式的樣式表:quirk.css,這個文件很小,影響比較大的主要是下面:

/* This will apply only to text fields, since all other inputs already use border box sizing */input:not([type=image i]), textarea { box-sizing: border-box;}

blink會先去載入html.css文件,怪異模式下再接著載入quirk.css文件。

(4)生成哈希map

最後會把生成的rule集放到四個類型哈希map:

CompactRuleMap m_idRules; CompactRuleMap m_classRules; CompactRuleMap m_tagRules; CompactRuleMap m_shadowPseudoElementRules;

map的類型是根據最右邊的selector的類型:id、class、標籤、偽類選擇器區分的,這樣做的目的是為了在比較的時候能夠很快地取出匹配第一個選擇器的所有rule,然後每條rule再檢查它的下一個selector是否匹配當前元素。

3. 計算CSS

CSS表解析好之後,會觸發layout tree,進行layout的時候,會把每個可視的Node結點相應地創建一個Layout結點,而創建Layout結點的時候需要計算一下得到它的style。為什麼需要計算style,因為可能會有多個選擇器的樣式命中了它,所以需要把幾個選擇器的樣式屬性綜合在一起,以及繼承父元素的屬性以及UA的提供的屬性。這個過程包括兩步:找到命中的選擇器和設置樣式。

(1)選擇器命中判斷

用以下html做為demo:

<style>.text{ font-size: 22em;}.text p{ color: #505050;}</style><div class="text"> <p>hello, world</p></div>

上面會生成兩個rule,第一個rule會放到上面提到的四個哈希map其中的classRules裡面,而第二個rule會放到tagRules裡面。

當這個樣式表解析好時,觸發layout,這個layout會更新所有的DOM元素:

void ContainerNode::attachLayoutTree(const AttachContext& context) { for (Node* child = firstChild(); child; child = child->nextSibling()) { if (child->needsAttach()) child->attachLayoutTree(childrenContext); }}

這是一個遞歸,初始為document對象,即從document開始深度優先,遍歷所有的dom結點,更新它們的布局。

對每個node,代碼裡面會依次按照id、class、偽元素、標籤的順序取出所有的selector,進行比較判斷,最後是通配符,如下:

//如果結點有id屬性if (element.hasID()) collectMatchingRulesForList( matchRequest.ruleSet->idRules(element.idForStyleResolution()), cascadeOrder, matchRequest);//如果結點有class屬性if (element.isStyledElement() && element.hasClass()) { for (size_t i = 0; i < element.classNames().size(); ++i) collectMatchingRulesForList( matchRequest.ruleSet->classRules(element.classNames()[i]), cascadeOrder, matchRequest);}//偽類的處理...//標籤選擇器處理collectMatchingRulesForList( matchRequest.ruleSet->tagRules(element.localNameForSelectorMatching()), cascadeOrder, matchRequest);//最後是通配符...

在遇到div.text這個元素的時候,會去執行上面代碼的取出classRules的那行。

上面domo的rule只有兩個,一個是classRule,一個是tagRule。所以會對取出來的這個classRule進行檢驗:

if (!checkOne(context, subResult)) return SelectorFailsLocally;if (context.selector->isLastInTagHistory()) { return SelectorMatches;}

第一行先對當前選擇器(.text)進行檢驗,如果不通過,則直接返回不匹配,如果通過了,第三行判斷當前選擇器是不是最左邊的選擇器,如果是的話,則返回匹配成功。如果左邊還有限定的話,那麼再遞歸檢查左邊的選擇器是否匹配。

我們先來看一下第一行的checkOne是怎麼檢驗的:

switch (selector.match()) { case CSSSelector::Tag: return matchesTagName(element, selector.tagQName()); case CSSSelector::Class: return element.hasClass() && element.classNames().contains(selector.value()); case CSSSelector::Id: return element.hasID() && element.idForStyleResolution() == selector.value();}

很明顯,.text將會在上面第6行匹配成功,並且它左邊沒有限定了,所以返回匹配成功。

到了檢驗p標籤的時候,會取出」.text p」的rule,它的第一個選擇器是p,將會在上面代碼的第3行判斷成立。但由於它前面還有限定,於是它還得繼續檢驗前面的限定成不成立。

前一個選擇器的檢驗關鍵是靠當前選擇器和它的關係,上面提到的relationType,這裡的p的relationType是Descendant即後代。上面在調了checkOne成功之後,繼續往下走:

switch (relation) { case CSSSelector::Descendant: for (nextContext.element = parentElement(context); nextContext.element; nextContext.element = parentElement(nextContext)) { MatchStatus match = matchSelector(nextContext, result); if (match == SelectorMatches || match == SelectorFailsCompletely) return match; if (nextSelectorExceedsScope(nextContext)) return SelectorFailsCompletely; } return SelectorFailsCompletely; case CSSSelector::Child: //...}

由於這裡是一個後代選擇器,所以它會循環當前元素所有父結點,用這個父結點和第二個選擇器」.text」再執行checkOne的邏輯,checkOne將返回成功,並且它已經是最後一個選擇器了,所以判斷結束,返回成功匹配。

後代選擇器會去查找它的父結點 ,而其它的relationType會相應地去查找關聯的元素。

所以不提倡把選擇器寫得太長,特別是用sass/less寫的時候,新手很容易寫嵌套很多層,這樣會增加查找匹配的負擔。例如上面,它需要對下一個父代選器啟動一個新的遞歸的過程,而遞歸是一種比較耗時的操作。一般是不要超過三層。

上面已經較完整地介紹了匹配的過程,接下來分析匹配之後又是如何設置style的。

(2)設置style

設置style的順序是先繼承父結點,然後使用UA的style,最後再使用用戶的style:

style->inheritFrom(*state.parentStyle())matchUARules(collector);matchAuthorRules(*state.element(), collector);

每一步如果有styleRule匹配成功的話會把它放到當前元素的m_matchedRules的向量裡面,並會去計算它的優先順序,記錄到m_specificity變數。這個優先順序是怎麼算的呢?

for (const CSSSelector* selector = this; selector; selector = selector->tagHistory()) { temp = total + selector->specificityForOneSelector();}return total;

如上代碼所示,它會從右到左取每個selector的優先順序之和。不同類型的selector的優級級定義如下:

switch (m_match) { case Id: return 0x010000; case PseudoClass: return 0x000100; case Class: case PseudoElement: case AttributeExact: case AttributeSet: case AttributeList: case AttributeHyphen: case AttributeContain: case AttributeBegin: case AttributeEnd: return 0x000100; case Tag: return 0x000001; case Unknown: return 0; } return 0;}

其中id的優先順序為0x10000 = 65536,類、屬性、偽類的優先順序為0x100 = 256,標籤選擇器的優先順序為1。如下面計算所示:

/*優先順序為257 = 265 + 1*/.text h1{ font-size: 8em;} /*優先順序為65537 = 65536 + 1*/#my-text h1{ font-size: 16em;}

內聯style的優先順序又是怎麼處理的呢?

當match完了當前元素的所有CSS規則,全部放到了collector的m_matchedRules裡面,再把這個向量根據優先順序從小到大排序:

collector.sortAndTransferMatchedRules();

排序的規則是這樣的:

static inline bool compareRules(const MatchedRule& matchedRule1, const MatchedRule& matchedRule2) { unsigned specificity1 = matchedRule1.specificity(); unsigned specificity2 = matchedRule2.specificity(); if (specificity1 != specificity2) return specificity1 < specificity2; return matchedRule1.position() < matchedRule2.position();}

先按優先順序,如果兩者的優先順序一樣,則比較它們的位置。

把css表的樣式處理完了之後,blink再去取style的內聯樣式(這個在已經在構建DOM的時候存放好了),把內聯樣式push_back到上面排好序的容器里,由於它是由小到大排序的,所以放最後面的優先順序肯定是最大的。

collector.addElementStyleProperties(state.element()->inlineStyle(), isInlineStyleCacheable);

樣式裡面的important的優先順序又是怎麼處理的?

所有的樣式規則都處理完畢,最後就是按照它們的優先順序計算CSS了。將在下面這個函數執行:

applyMatchedPropertiesAndCustomPropertyAnimations( state, collector.matchedResult(), element);

這個函數會按照下面的順序依次設置元素的style:

applyMatchedProperties<HighPropertyPriority, CheckNeedsApplyPass>( state, matchResult.allRules(), false, applyInheritedOnly, needsApplyPass); for (auto range : ImportantAuthorRanges(matchResult)) { applyMatchedProperties<HighPropertyPriority, CheckNeedsApplyPass>( state, range, true, applyInheritedOnly, needsApplyPass); }

先設置正常的規則,最後再設置important的規則。所以越往後的設置的規則就會覆蓋前面設置的規則。

最後生成的Style是怎麼樣的?

按優先順序計算出來的Style會被放在一個ComputedStyle的對象裡面,這個style裡面的規則分成了幾類,通過檢查style對象可以一窺:

把它畫成一張圖表:

主要有幾類,box是長寬,surround是margin/padding,還有不可繼承的nonInheritedData和可繼承的styleIneritedData一些屬性。Blink還把很多比較少用的屬性放到rareData的結構裡面,為避免實例化這些不常用的屬性佔了太多的空間。

具體來說,上面設置的font-size為:22em * 16px = 352px:

而所有的色值會變成16進位的整數,如blink定義的兩種顏色的色值:

static const RGBA32 lightenedBlack = 0xFF545454;static const RGBA32 darkenedWhite = 0xFFABABAB;

同時blink對rgba色值的轉化演算法:

RGBA32 makeRGBA32FromFloats(float r, float g, float b, float a) { return colorFloatToRGBAByte(a) << 24 | colorFloatToRGBAByte(r) << 16 | colorFloatToRGBAByte(g) << 8 | colorFloatToRGBAByte(b);}

從這裡可以看到,有些CSS優化建議說要按照下面的順序書寫CSS規則:

1.位置屬性(position, top, right, z-index, display, float等)

2.大小(width, height, padding, margin)

3.文字系列(font, line-height, letter-spacing, color- text-align等)

4.背景(background, border等)

5.其他(animation, transition等)

這些順序對瀏覽器來說其實是一樣的,因為最後都會放到computedStyle裡面,而這個style裡面的數據是不區分先後順序的。所以這種建議與其說是優化,倒不如說是規範,大家都按照這個規範寫的話,看CSS就可以一目了然,可以很快地看到想要了解的關鍵信息。

(3)調整style

最後把生成的style做一個調整:

adjustComputedStyle(state, element); //style在state對象裡面

調整的內容包括:

第一個:把absolute/fixed定位、float的元素設置成block:

// Absolute/fixed positioned elements, floating elements and the document// element need block-like outside display.if (style.hasOutOfFlowPosition() || style.isFloating() || (element && element->document().documentElement() == element)) style.setDisplay(equivalentBlockDisplay(style.display()));

第二個,如果有:first-letter選擇器時,會把元素display和position做調整:

static void adjustStyleForFirstLetter(ComputedStyle& style) { // Force inline display (except for floating first-letters). style.setDisplay(style.isFloating() ? EDisplay::Block : EDisplay::Inline); // CSS2 says first-letter can"t be positioned. style.setPosition(StaticPosition);}

還會對表格元素做一些調整。

到這裡,CSS相關的解析和計算就分析完畢,筆者將嘗試在下一篇介紹渲染頁面的第三步layout的過程。

相關閱讀:

  1. 從Chrome源碼看瀏覽器如何構建DOM樹
  2. 從Chrome源碼看瀏覽器的事件機制

推薦閱讀:

TAG:前端开发 | CSS | 源码阅读 |