作者李银城,授权新前端转载

《Effective前端6:避免页面卡顿》这篇里面介绍了浏览器渲染页面的过程:

从 Chrome 源码看浏览器如何计算 CSS - 图1

并且《从Chrome源码看浏览器如何构建DOM树》介绍了第一步如何解析Html构建DOM树,这个过程大概如下:

从 Chrome 源码看浏览器如何计算 CSS - 图2

浏览器每收到一段html的文本之后,就会把它序列化成一个个的tokens,依次遍历这些 token,实例化成对应的 html 结点并插入到 DOM 树里面。

我将在这一篇介绍第二步 Style 的过程,即 CSS 的处理。

1. 加载CSS

在构建 DOM 的过程中,如果遇到 link 的标签,当把它插到 DOM 里面之后,就会触发资源加载——根据 href 指明的链接:

  1. <link rel="stylesheet" href="demo.css">

上面的rel指明了它是一个样式文件。这个加载是异步,不会影响 DOM 树的构建,只是说在 CSS 没处理好之前,构建好的 DOM 并不会显示出来。用以下的 html 和 css 做试验:

  1. <!DOCType html>
  2. <html>
  3. <head>
  4. <link rel="stylesheet" href="demo.css">
  5. </head>
  6. <body>
  7. <div class="text">
  8. <p>hello, world</p>
  9. </div>
  10. </body>

demo.css如下:

  1. .text{
  2. font-size: 20px;
  3. }
  4. .text p{
  5. color: #505050;
  6. }

从打印的 log 可以看出(添加打印的源码略):

  1. [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
  2. [HTMLDocumentParser.cpp(765)] tagName: html |type: DOCTYPE|attr: |text:
  3. [HTMLDocumentParser.cpp(765)] tagName: |type: Character |attr: |text: \n
  4. [HTMLDocumentParser.cpp(765)] tagName: html |type: startTag |attr: |text:
  5. [HTMLDocumentParser.cpp(765)] tagName: html |type: EndTag |attr: |text:
  6. [HTMLDocumentParser.cpp(765)] tagName: |type: EndOfFile|attr: |text:
  7. [Document.cpp(1231)] readystatechange to Interactive
  8. [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 源码看浏览器如何计算 CSS - 图3

经常看到有人建议 CSS 的色值使用 16 位的数字会优于使用 rgb 的表示,这个是子虚乌有,还是有根据的呢?

如下所示:

从 Chrome 源码看浏览器如何计算 CSS - 图4

如果改成 rgb,它将变成一个函数类型的 token,这个函数需要再计算一下。从这里看的话,使用 16 位色值确实比使用 rgb 好。

tokens -> styleRule

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

  1. .text .hello{
  2. color: rgb(200, 200, 200);
  3. width: calc(100% - 20px);
  4. }
  5. #world{
  6. margin: 20px;
  7. }

打印出来的选择器结果为(相关打印代码省略):

  1. selector text = “.text .hello
  2. value = hello matchType = Class relation = Descendant
  3. tag history selector text = “.text
  4. value = text matchType = Class relation = SubSelector
  5. selector text = “#world
  6. value = world matchType = Id relation = SubSelector

从第一个选择器可以看出,它的解析是从右往左的,这个在判断 match 的时候比较有用。

blink 定义了几种 matchType:

  1. enum MatchType {
  2. Unknown,
  3. Tag, // Example: div
  4. Id, // Example: #id
  5. Class, // example: .class
  6. PseudoClass, // Example: :nth-child(2)
  7. PseudoElement, // Example: ::first-line
  8. PagePseudoClass, // ??
  9. AttributeExact, // Example: E[foo="bar"]
  10. AttributeSet, // Example: E[foo]
  11. AttributeHyphen, // Example: E[foo|="bar"]
  12. AttributeList, // Example: E[foo~="bar"]
  13. AttributeContain, // css3: E[foo*="bar"]
  14. AttributeBegin, // css3: E[foo^="bar"]
  15. AttributeEnd, // css3: E[foo$="bar"]
  16. FirstAttributeSelectorMatch = AttributeExact,
  17. };

还定义了几种选择器的类型:

  1. enum RelationType {
  2. SubSelector, // No combinator
  3. Descendant, // "Space" combinator
  4. Child, // > combinator
  5. DirectAdjacent, // + combinator
  6. IndirectAdjacent, // ~ combinator
  7. // Special cases for shadow DOM related selectors.
  8. ShadowPiercingDescendant, // >>> combinator
  9. ShadowDeep, // /deep/ combinator
  10. ShadowPseudo, // ::shadow pseudo element
  11. ShadowSlot // ::slotted() pseudo element
  12. };

.text .hello 的 .hello 选择器的类型就是 Descendant,即后代选择器。记录选择器类型的作用是协助判断当前元素是否 match 这个选择器。例如,由于.hello是一个父代选器,所以它从右往左的下一个选择器就是它的父选择器,于是判断当前元素的所有父元素是否匹配 .text 这个选择器。

第二个部分——属性打印出来是这样的:

  1. selector text = “.text .hello
  2. perperty id = 15 value = rgb(200, 200, 200)”
  3. perperty id = 316 value = calc(100% 20px)”
  4. selector text = “#world
  5. perperty id = 147 value = 20px
  6. perperty id = 146 value = 20px
  7. perperty id = 144 value = 20px
  8. perperty id = 145 value = 20px

所有的 CSS 的属性都是用 id 标志的,上面的 id 依次对应:

  1. enum CSSPropertyID {
  2. CSSPropertyColor = 15,
  3. CSSPropertyWidth = 316,
  4. CSSPropertyMarginLeft = 145,
  5. CSSPropertyMarginRight = 146,
  6. CSSPropertyMarginTop = 147,
  7. CSSPropertyMarkerEnd = 148,
  8. }

设置了margin: 20px,会转化成四个属性。从这里可以看出 CSS 提倡属性合并,但是最后还是会被拆成各个小属性。所以属性合并最大的作用应该在于减少 CSS 的代码量。

一个选择器和一个属性集就构成一条 rule,同一个 css 表的所有 rule 放到同一个 stylesheet 对象里面,blink 会把用户的样式存放到一个 m_authorStyleSheets 的向量里面,如下图示意:

从 Chrome 源码看浏览器如何计算 CSS - 图5

除了 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:

  1. dialog {
  2. position: absolute;
  3. left: 0;
  4. right: 0;
  5. width: -webkit-fit-content;
  6. height: -webkit-fit-content;
  7. margin: auto;
  8. border: solid;
  9. padding: 1em;
  10. background: white;
  11. color: black;
  12. }

另外还有怪异模式的样式表:quirk.css,这个文件很小,影响比较大的主要是下面:

  1. /* This will apply only to text fields, since all other inputs already use border box sizing */
  2. input:not([type=image i]), textarea {
  3. box-sizing: border-box;
  4. }

blink 会先去加载 html.css文件,怪异模式下再接着加载 quirk.css 文件。

生成哈希map

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

  1. CompactRuleMap m_idRules;
  2. CompactRuleMap m_classRules;
  3. CompactRuleMap m_tagRules;
  4. 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:

  1. <style>
  2. .text{
  3. font-size: 22em;
  4. }
  5. .text p{
  6. color: #505050;
  7. }
  8. </style>
  9. <div class="text">
  10. <p>hello, world</p>
  11. </div>

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

当这个样式表解析好时,触发 layout,这个 layout 会更新所有的 DOM 元素:

  1. void ContainerNode::attachLayoutTree(const AttachContext& context) {
  2. for (Node* child = firstChild(); child; child = child->nextSibling()) {
  3. if (child->needsAttach())
  4. child->attachLayoutTree(childrenContext);
  5. }
  6. }

这是一个递归,初始为 document 对象,即从 document 开始深度优先,遍历所有的 dom 结点,更新它们的布局。

对每个 node,代码里面会依次按照 id、class、伪元素、标签的顺序取出所有的 selector,进行比较判断,最后是通配符,如下:

  1. //如果结点有id属性
  2. if (element.hasID())
  3. collectMatchingRulesForList(
  4. matchRequest.ruleSet->idRules(element.idForStyleResolution()),
  5. cascadeOrder, matchRequest);
  6. //如果结点有class属性
  7. if (element.isStyledElement() && element.hasClass()) {
  8. for (size_t i = 0; i < element.classNames().size(); ++i)
  9. collectMatchingRulesForList(
  10. matchRequest.ruleSet->classRules(element.classNames()[i]),
  11. cascadeOrder, matchRequest);
  12. }
  13. //伪类的处理
  14. ...
  15. //标签选择器处理
  16. collectMatchingRulesForList(
  17. matchRequest.ruleSet->tagRules(element.localNameForSelectorMatching()),
  18. cascadeOrder, matchRequest);
  19. //最后是通配符
  20. ...

在遇到 div.text 这个元素的时候,会去执行上面代码的取出 classRules 的那行。

上面 domo 的 rule 只有两个,一个是 classRule,一个是 tagRule。所以会对取出来的这个 classRule 进行检验:

  1. if (!checkOne(context, subResult))
  2. return SelectorFailsLocally;
  3. if (context.selector->isLastInTagHistory()) {
  4. return SelectorMatches;
  5. }

第一行先对当前选择器(.text)进行检验,如果不通过,则直接返回不匹配,如果通过了,第三行判断当前选择器是不是最左边的选择器,如果是的话,则返回匹配成功。如果左边还有限定的话,那么再递归检查左边的选择器是否匹配。

我们先来看一下第一行的 checkOne 是怎么检验的:

  1. switch (selector.match()) {
  2. case CSSSelector::Tag:
  3. return matchesTagName(element, selector.tagQName());
  4. case CSSSelector::Class:
  5. return element.hasClass() &&
  6. element.classNames().contains(selector.value());
  7. case CSSSelector::Id:
  8. return element.hasID() &&
  9. element.idForStyleResolution() == selector.value();
  10. }

很明显,.text 将会在上面第 6 行匹配成功,并且它左边没有限定了,所以返回匹配成功。

到了检验 p 标签的时候,会取出“.text p”的 rule,它的第一个选择器是 p,将会在上面代码的第 3 行判断成立。但由于它前面还有限定,于是它还得继续检验前面的限定成不成立。

前一个选择器的检验关键是靠当前选择器和它的关系,上面提到的 relationType,这里的 p 的 relationType 是Descendant 即后代。上面在调了 checkOne 成功之后,继续往下走:

  1. switch (relation) {
  2. case CSSSelector::Descendant:
  3. for (nextContext.element = parentElement(context); nextContext.element;
  4. nextContext.element = parentElement(nextContext)) {
  5. MatchStatus match = matchSelector(nextContext, result);
  6. if (match == SelectorMatches || match == SelectorFailsCompletely)
  7. return match;
  8. if (nextSelectorExceedsScope(nextContext))
  9. return SelectorFailsCompletely;
  10. }
  11. return SelectorFailsCompletely;
  12. case CSSSelector::Child:
  13. //...
  14. }

由于这里是一个后代选择器,所以它会循环当前元素所有父结点,用这个父结点和第二个选择器“.text”再执行checkOne 的逻辑,checkOne 将返回成功,并且它已经是最后一个选择器了,所以判断结束,返回成功匹配。

后代选择器会去查找它的父结点 ,而其它的 relationType 会相应地去查找关联的元素。

所以不提倡把选择器写得太长,特别是用 sass/less 写的时候,新手很容易写嵌套很多层,这样会增加查找匹配的负担。例如上面,它需要对下一个父代选器启动一个新的递归的过程,而递归是一种比较耗时的操作。一般是不要超过三层。

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

设置style

  1. style->inheritFrom(*state.parentStyle())
  2. matchUARules(collector);
  3. matchAuthorRules(*state.element(), collector);

每一步如果有 styleRule 匹配成功的话会把它放到当前元素的 m_matchedRules 的向量里面,并会去计算它的优先级,记录到 m_specificity 变量。这个优先级是怎么算的呢?

  1. for (const CSSSelector* selector = this; selector;
  2. selector = selector->tagHistory()) {
  3. temp = total + selector->specificityForOneSelector();
  4. }
  5. return total;

如上代码所示,它会从右到左取每个 selector 的优先级之和。不同类型的 selector 的优级级定义如下:

  1. switch (m_match) {
  2. case Id:
  3. return 0x010000;
  4. case PseudoClass:
  5. return 0x000100;
  6. case Class:
  7. case PseudoElement:
  8. case AttributeExact:
  9. case AttributeSet:
  10. case AttributeList:
  11. case AttributeHyphen:
  12. case AttributeContain:
  13. case AttributeBegin:
  14. case AttributeEnd:
  15. return 0x000100;
  16. case Tag:
  17. return 0x000001;
  18. case Unknown:
  19. return 0;
  20. }
  21. return 0;
  22. }

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

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

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

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

  1. collector.sortAndTransferMatchedRules();

排序的规则是这样的:

  1. static inline bool compareRules(const MatchedRule& matchedRule1,
  2. const MatchedRule& matchedRule2) {
  3. unsigned specificity1 = matchedRule1.specificity();
  4. unsigned specificity2 = matchedRule2.specificity();
  5. if (specificity1 != specificity2)
  6. return specificity1 < specificity2;
  7. return matchedRule1.position() < matchedRule2.position();
  8. }

先按优先级,如果两者的优先级一样,则比较它们的位置。

把 css 表的样式处理完了之后,blink 再去取 style 的内联样式(这个在已经在构建 DOM 的时候存放好了),把内联样式 push_back 到上面排好序的容器里,由于它是由小到大排序的,所以放最后面的优先级肯定是最大的。

  1. collector.addElementStyleProperties(state.element()->inlineStyle(),
  2. isInlineStyleCacheable);

样式里面的 important 的优先级又是怎么处理的?

所有的样式规则都处理完毕,最后就是按照它们的优先级计算 CSS 了。将在下面这个函数执行:

  1. applyMatchedPropertiesAndCustomPropertyAnimations(
  2. state, collector.matchedResult(), element);

这个函数会按照下面的顺序依次设置元素的 style:

  1. applyMatchedProperties<HighPropertyPriority, CheckNeedsApplyPass>(
  2. state, matchResult.allRules(), false, applyInheritedOnly, needsApplyPass);
  3. for (auto range : ImportantAuthorRanges(matchResult)) {
  4. applyMatchedProperties<HighPropertyPriority, CheckNeedsApplyPass>(
  5. state, range, true, applyInheritedOnly, needsApplyPass);
  6. }

先设置正常的规则,最后再设置 important 的规则。所以越往后的设置的规则就会覆盖前面设置的规则。

最后生成的 Style 是怎么样的?

按优先级计算出来的 Style 会被放在一个 ComputedStyle 的对象里面,这个 style 里面的规则分成了几类,通过检查 style 对象可以一窥:

从 Chrome 源码看浏览器如何计算 CSS - 图6

把它画成一张图表:

从 Chrome 源码看浏览器如何计算 CSS - 图7

主要有几类,box 是长宽,surround 是 margin/padding,还有不可继承的 nonInheritedData 和可继承的styleIneritedData 一些属性。Blink 还把很多比较少用的属性放到 rareData 的结构里面,为避免实例化这些不常用的属性占了太多的空间。

具体来说,上面设置的 font-size 为:22em * 16px = 352px:

从 Chrome 源码看浏览器如何计算 CSS - 图8

而所有的色值会变成 16 进制的整数,如 blink 定义的两种颜色的色值:

  1. static const RGBA32 lightenedBlack = 0xFF545454;
  2. static const RGBA32 darkenedWhite = 0xFFABABAB;

同时 blink 对 rgba 色值的转化算法:

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

从这里可以看到,有些 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 做一个调整:

  1. adjustComputedStyle(state, element); //style在state对象里面

调整的内容包括:

第一个:把 absolute/fixed 定位、float 的元素设置成 block:

  1. // Absolute/fixed positioned elements, floating elements and the document
  2. // element need block-like outside display.
  3. if (style.hasOutOfFlowPosition() || style.isFloating() ||
  4. (element && element->document().documentElement() == element))
  5. style.setDisplay(equivalentBlockDisplay(style.display()));

第二个,如果有 :first-letter 选择器时,会把元素 display 和 position 做调整:

  1. static void adjustStyleForFirstLetter(ComputedStyle& style) {
  2. // Force inline display (except for floating first-letters).
  3. style.setDisplay(style.isFloating() ? EDisplay::Block : EDisplay::Inline);
  4. // CSS2 says first-letter can't be positioned.
  5. style.setPosition(StaticPosition);
  6. }

还会对表格元素做一些调整。

到这里,CSS 相关的解析和计算就分析完毕,笔者将尝试在下一篇介绍渲染页面的第三步 layout 的过程。

相关阅读:

  1. 从Chrome源码看浏览器如何构建DOM树从Chrome源码看浏览器的事件机制
  2. 从Chrome源码看浏览器的事件机制