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

KiaBoothman 7年前發布 | 11K 次閱讀 CSS 前端技術

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

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

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

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

1. 加載CSS

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

<linkrel="stylesheet" href="demo.css">

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

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

demo.css如下:

.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里面是一種不太推薦的做法。

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:

  CompactRuleMapm_idRules;
  CompactRuleMapm_classRules;
  CompactRuleMapm_tagRules;
  CompactRuleMapm_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>
<divclass="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)) { 
      MatchStatusmatch = 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的優先級為0x100000 = 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 (autorange : 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 RGBA32lightenedBlack = 0xFF545454;
static const RGBA32darkenedWhite = 0xFFABABAB;

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

RGBA32makeRGBA32FromFloats(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就可以一目了然,可以很快地看到想要了解的關鍵信息。

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

 

來自:http://www.renfed.com/2017/02/22/chrome-css/

 

 本文由用戶 KiaBoothman 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!