在我的博客从url输入到页面渲染:渲染流程(一)中介绍了渲染阶段的第一个步骤:构建DOM树,通过树解析算法解析器将HTML转换成浏览器可以识别的DOM树结构,但是此时我们我们仍然不知道每个DOM节点的正确样式,因此此时需要进入第二步:样式计算。
样式计算的目的是计算出DOM节点中每个元素的具体样式,这个阶段大体上可以分为如下三步:
和HTML文件一样,浏览器无法直接理解这种纯文本的CSS样式,因此当浏览器渲染引擎接收到CSS文本时会执行一个转换操作,将CSS文本装换成一种浏览器可以理解的结构——styleSheets。
我们在控制台输入document.styleSheets可以查看浏览器生成的styleSheets结构:
那么渲染引擎是如何将CSS文本转换为styleSheets结构呢?
在我的同系列博客从url输入到页面渲染:渲染流程(一)中介绍了HTML文档解析构建DOM树的过程,事实上如果在DOM树的构建过程中遇到link标签,当把它插入到DOM里面后就会触发资源加载,如下所示:
<link rel="stylesheet" href="demo.css">
上面的rel属性表明它是一个样式文件,href属性表明了其资源加载的链接路径,这个资源加载是一个异步加载,并不会影响DOM树的构建,但是我们需要注意到在CSS没有处理好之前构建好的DOM并不会显示出来,以下面代码为例:
<html>
<head>
<link rel="stylesheet" href="demo.css">
head>
<body>
<div class="text">
<p>hello, worldp>
div>
body>
dom.css文件的内容如下:
.text{
font-size: 20px;
}
.text p{
color: #505050;
}
整体解析过程如下图所示:
在CSS没有加载好之前,DOM树已经构建好了。为什么DOM构建好了不把html放出来,因为没有样式的html直接放出来,给人看到的页面将会是乱的。所以CSS不能太大,页面一打开将会停留较长时间的白屏,所以把图片/字体等转成base64放到CSS里面是一种不太推荐的做法。
CSS解析和html解析的过程有很多相似的地方,都是先将字符串格式化为tokens。CSS token定义了很多种类型,如下所示的CSS样式会被拆成多个token:
每个styleRule主要包含两个部分,一个是选择器selectors,第二个是属性集properties。用以下CSS:
.text .hello{
color: rgb(200, 200, 200);
width: calc(100% - 20px);
}
#world{
margin: 20px;
}
解析生成的选择器结果如下所示:
从上图我们也可以看出选择器的解析是从右向左进行的,因此先识别出的是.hello选择器,其次才是.text选择器。同时我们也注意到了解析结果中的matchType和relation字段,那么这两个字段分别代表着什么呢?
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,
};
blink定义的relationType如下所示,表明选择器类型:
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这个选择器。
解析生成的属性结果如下图所示:
如上图所示,解析生成的所有属性都是以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和全屏的默认样式表。
最后会把生成的rule集放到四个类型哈希map:
CompactRuleMap m_idRules;
CompactRuleMap m_classRules;
CompactRuleMap m_tagRules;
CompactRuleMap m_shadowPseudoElementRules;
map的类型是根据最右边的selector的类型:id、class、标签、伪类选择器区分的,这样做的目的是为了在比较的时候能够很快地取出匹配第一个选择器的所有rule,然后每条rule再检查它的下一个selector是否匹配当前元素。
现在我们已经把现有的CSS文本转化为浏览器可以理解的结构,那么接下来我们就需要对其进行属性值的标准化操作,我们以如下CSS文本示例:
body { font-size: 2em }
p { color: blue }
span { display: none }
div { font-weight: bold }
div p { color: green }
div { color: red }
示例中的许多属性值诸如2em,blue,bold这些数值不容易被渲染引擎理解,因此我们需要把所有值转换为渲染引擎容易理解的、标准化的计算值,上述示例经过标准化后的结果如下图:
body { font-size: 32px }
p { color: rgb(0, 0, 255) }
span { display: none }
div { font-weight: 700}
div p { color: rgb(0, 128, 0)}
div { color: rgb(255, 0, 0) }
CSS表解析好之后,会触发layout tree,进行layout的时候,会把每个可视的Node结点相应地创建一个Layout结点,而创建Layout结点的时候需要计算一下得到它的style。为什么需要计算style,因为可能会有多个选择器的样式命中了它,所以需要把几个选择器的样式属性综合在一起,以及继承父元素的属性以及UserAgent样式(浏览器内置的默认样式)。这个过程包括两步:找到命中的选择器和设置样式。
我们以用下代码示例:
<style>
.text{
font-size: 22em;
}
.text p{
color: #505050;
}
style>
<div class="text">
<p>hello, worldp>
div>
上面会生成两个rule,第一个rule会放到上面提到的四个哈希map其中的classRules里面,而第二个rule会放到tagRules里面。
当这个样式表解析好时,触发layout,这个layout从document节点开始递归遍历所有DOM节点,更新所有的DOM元素的布局。对于每个node节点,代码会按照id、class、伪元素、标签的顺序取出所有选择器,进行比较判断,最后是通配符,具体实现源码如下:
//如果节点有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的顺序是先继承父结点,然后使用UserAgent的样式,最后再使用用户的style(这个过程解释了CSS的继承规则和层叠规则):
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;
}
当match完了当前元素的所有CSS规则,全部放到了collector的m_matchedRules里面,再把这个向量根据优先级从小到大排序,如果大小相同则比较它们的位置,出现位置靠后的选择器排在后面。
把css表的样式处理完了之后,blink再去取style的内联样式(这个在已经在构建DOM的时候存放好了),把内联样式push到上面排好序的容器里,由于它是由小到大排序的,所以放最后面的优先级肯定是最大的,因此内联样式的优先级要高于所有的css文本样式。
样式里面的important的优先级又是怎么处理的?
实际上浏览器会先设置正常的规则,最后再设置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就可以一目了然,可以很快地看到想要了解的关键信息。
最后把生成的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相关的解析和计算就分析完毕。
参考文章:
1、从Chrome源码看浏览器如何计算CSS
2、极客时间——浏览器工作原理与实践专栏