天天看點

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

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

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

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

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

浏覽器每收到一段html的文本之後,就會把它序列化成一個個的tokens,依次周遊這些token,執行個體化成對應的html結點并插入到DOM樹裡面。

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

1. 加載CSS

在建構DOM的過程中,如果遇到link的标簽,當把它插到DOM裡面之後,就會觸發資源加載——根據href指明的連結:

<link rel="stylesheet" href="demo.css" target="_blank" rel="external nofollow"  target="_blank" rel="external nofollow" >
           

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

<!DOCType html>
<html>
<head>
    <link rel="stylesheet" href="demo.css" target="_blank" rel="external nofollow"  target="_blank" rel="external nofollow" >
</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>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:

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

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

如下所示:

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

如果改成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的向量裡面,如下圖示意:

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

除了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對象可以一窺:

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

把它畫成一張圖表:

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

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

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

chrome浏覽器如何序列槽_從Chrome源碼看浏覽器如何計算CSS

而所有的色值會變成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源碼看浏覽器的事件機制