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

做者李銀城,受權新前端轉載css

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

parse html > style > layout > paint > composite

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

解析Html構建DOM樹

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

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

1. 加載CSS

在構建DOM的過程當中,若是遇到link的標籤,當把它插到DOM裏面以後,就會觸發資源加載——根據href指明的連接:web

<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以下:chrome

.text{
    font-size: 20px;
}
.text p{
    color: #505050;
}
複製代碼

從打印的log能夠看出(添加打印的源碼略):瀏覽器

[DocumentLoader.cpp(558)] 「<!DOCType html>\n<html>\n<head>\n<link rel=\」stylesheet\」 href=\」demo.css\」> \n</head>\n<body>\n<div class=\」text\」>\n <p>hello, world</p>\n</div>\n</body>\n</html>\n」
[HTMLDocumentParser.cpp(765)] 「tagName: html |type: DOCTYPE|attr: |text: 「
[HTMLDocumentParser.cpp(765)] 「tagName: |type: Character |attr: |text: \n」
[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{\n font-size: 20px;\n}\n.text p{\n color: #505050;\n}\n」
複製代碼

在CSS沒有加載好以前,DOM樹已經構建好了。爲何DOM構建好了不把html放出來,由於沒有樣式的html直接放出來,給人看到的頁面將會是亂的。因此CSS不能太大,頁面一打開將會停留較長時間的白屏,因此把圖片/字體等轉成base64放到CSS裏面是一種不太推薦的作法。sass

2. 解析CSS

(1)字符串 -> tokens

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

CSS 解析爲 token

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

以下所示:

rgb 函數

若是改爲rgb,它將變成一個函數類型的token,這個函數須要再計算一下。從這裏看的話,使用16位色值確實比使用rgb好。

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的向量裏面,以下圖示意:

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文件。

生成哈希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的。

設置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對象能夠一窺:

ComputedStyle 對象

把它畫成一張圖表:

ComputedStyle 示意圖

主要有幾類,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樹從Chrome源碼看瀏覽器的事件機制
  2. 從Chrome源碼看瀏覽器的事件機制
相關文章
相關標籤/搜索