CSS原理浅析

193 阅读10分钟

 在详细介绍CSS原理之前我们来简单回顾一下浏览器加载页面的步骤,简单可以概括为以下几个步。

  1. 页面加载

      浏览器根据DNS服务器得到域名的IP地址

      向这个IP的机器发送HTTP请求

      服务器收到、处理并返回HTTP请求

      浏览器得到返回内容(其实就是一堆HTML格式的字符串)

  1. 浏览器渲染

     html经过HTML parser解析为DOM tree

     css根据css规则经过css解析器解析为 style Rules

     两棵树经过attachment结合Render Tree

     render tree (渲染树)经过Layout计算DOM的位置以及样式

     将计算好的页面paint画出来,并显示在浏览器上

我们这里重点介绍以下CSS解析和计算的部分过程

CSS解析

字符串 -> tokens

css解析是先格式化token,CSS token定义了很多种类型,如下的CSS会被拆成这么多个token:

image.png

这里的token可以简单理解为描述一段css代码的属性,这样浏览器就可以对应解析相关的css代码

这里可以引申出一个小细节,大家可能在css性能优化的时候听过color的值尽量要用16位的数字的值而不是rgb的值,相关原理如下

image.png

大家可以看到这些带括号的属性本质上是一种计算函数,如果改成rgb,它将变成一个函数类型的token

这样就要先进行一步计算,如此看来使用16位色值确实比使用rgb好。

tokens -> styleRule

这里不关心它是怎么把tokens转化成style的规则的,我们只要看格式化后的styleRule是怎么样的就可以。每个styleRule主要包含两个部分,一个是选择器,第二个是属性集。如下所示

.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的时候体现作用

以下是定义的几种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的向量里面,如下图示意:

image.png

生成哈希map

最后会把生成的rule放到四个类型哈希map

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

map的类型是根据最右边的selector的类型:id、class、标签、伪类选择器区分的,这样做的目的是为了在比较的时候能够很快地取出匹配第一个选择器的所有rule,然后每条rule再检查它的下一个selector是否匹配当前元素。

计算CSS

CSS表解析好之后,会触发layout tree,进行layout的时候,会把每个可视的Node结点相应地创建一个Layout结点,而创建Layout结点的时候需要计算一下得到它的style。原因是可能会有多个选择器的样式命中了它,所以需要把几个选择器的样式属性综合在一起,以及继承父元素的属性以及UA (浏览器的默认样式) 的提供的属性。这个过程包括两步:找到命中的选择器和设置样式。

选择器命中判断

下面这段代码会生成两个rule,第一个rule会放到上面提到的四个哈希map其中的classRules里面,而第二个rule会放到tagRules里面。

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

当这个样式表解析好时,触发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结点,更新它们的布局。

ps:(这里的->操作符相当于js中的child.nextSibling)

对每个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会相应地去查找关联的元素。

所以CSS性能优化老生常谈不要把选择器层级写的太长是因为这个原因,它需要对下一个父代选器启动一个新的递归的过程,而递归是一种比较耗时的操作。一般是不要超过三层。

上面已经较完整地介绍了匹配的过程,接下来分析匹配之后又是如何设置style的。

设置style

设置style的顺序是先继承父结点,然后使用UA的style,最后再使用用户的style。

每一步如果有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; // 16进制
    case PseudoClass:
      return 0x000100; // 16进制
    case Class:
    case PseudoElement:
    case AttributeExact:
    case AttributeSet:
    case AttributeList:
    case AttributeHyphen:
    case AttributeContain:
    case AttributeBegin:
    case AttributeEnd:
      return 0x000100; // 16进制
    case Tag:
      return 0x000001; // 16进制
    case Unknown:
      return 0;
  }
  return 0;
}

其中id的优先级为0x10000 = 65536,类、属性、伪类的优先级为0x100 = 256,标签选择器的优先级为1。demo的优先级如下所示

/*优先级为257 = 265 + 1*/
.text h1{
    font-size: 8em;
}
 
/*优先级为65537 = 65536 + 1*/
#my-text h1{
    font-size: 16em;
}

内联style的优先级又是怎么处理的呢?

当match完了当前元素的所有CSS规则,全部放到了collector的m_matchedRules里面,再把这个向量根据优先级从小到大排序,排序的规则是这样的

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);

最后生成的Style是按优先级计算出来的,这个style里面的规则分成了几类,通过检查style对象可以一窥

image.png

集成为一张图如下所示,大致分为这几类

image.png

拿我们上述代码的demo举例

设置的font-size为:22em * 16px = 352px:

image.png

而所有的色值会变成16进制的整数

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

对rgba色值的转化算法如下

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

调整style

调整的内容主要包括以下两点 

第一点:把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做调整

(:first-letter用于选择一段文字中第一行文字的第一个字母)

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的解析和计算部分基本分析完毕。

总结

我们大概看完CSS解析计算的源码之后,对CSS在浏览器中运作方式有了一个相对具象的了解,同时基于部分源码我们也能看出为什么不要使用较长的后代选择器原因。这对我们使用CSS时候还是有一定帮助的。