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

    xiaoxiao2022-07-15  147

    作者:李银城(内容经作者授权转载)

    原文链接请点击左下角「阅读原文」

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

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

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

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

    加载 CSS

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

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

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

    <!DOCType html> <html> <head> <link rel="stylesheet" href="demo.css"> </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 里面是一种不太推荐的做法。

    解析 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 文件。

    (3)生成哈希 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。为什么需要计算 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 变量。这个优先级是怎么算的呢?

    如上代码所示,它会从右到左取每个 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 对象可以一窥:

    把它画成一张图表:

    主要有几类,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 规则:

    位置属性(position, top, right, z-index, display, float 等)

    大小(width, height, padding, margin)

    文字系列(font, line-height, letter-spacing, color- text-align 等)

    背景(background, border 等)

    其他(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 相关的解析和计算就分析完毕

    end

    LeanCloud,领先的 BaaS 提供商,为移动开发提供强有力的后端支持。更多内容请关注「 LeanCloud 通讯」

    最新回复(0)