查看原文
其他

【第1121期】探究 CSS 解析原理

jartto.wang 前端早读课 2020-10-20

前言

今日早读文章由@jartto.wang授权分享。

正文从这开始~

吃早饭的时候,同事随意问了一句:“你知道 CSS 是怎么解析的吗?”,我一头雾水。对哦,作为前端,每天都在与 CSS 打交道,我竟然忽视了最基本的原理。

一、浏览器渲染
开篇,我们还是不厌其烦的回顾一下浏览器的渲染过程,先上图:



正如上图所展示的,我们浏览器渲染过程分为了两条主线:
其一,HTML Parser 生成的 DOM 树;
其二,CSS Parser 生成的 Style Rules ;

在这之后,DOM 树与 Style Rules 会生成一个新的对象,也就是我们常说的 Render Tree 渲染树,结合 Layout 绘制在屏幕上,从而展现出来。

本文的重点也就集中在第二条分支上,我们来探究一下 CSS 解析原理。

二、Webkit CSS 解析器

浏览器 CSS 模块负责 CSS 脚本解析,并为每个 Element 计算出样式。CSS 模块虽小,但是计算量大,设计不好往往成为浏览器性能的瓶颈。

CSS 模块在实现上有几个特点:CSS 对象众多(颗粒小而多),计算频繁(为每个 Element 计算样式)。这些特性决定了 webkit 在实现 CSS 引擎上采取的设计,算法。如何高效的计算样式是浏览器内核的重点也是难点。

先来看一张图:



Webkit 使用 Flex 和 Bison 解析生成器从 CSS 语法文件中自动生成解析器。

它们都是将每个 CSS 文件解析为样式表对象,每个对象包含 CSS 规则,CSS 规则对象包含选择器和声明对象,以及其他一些符合 CSS 语法的对象,下图可能会比较明了:



Webkit 使用了自动代码生成工具生成了相应的代码,也就是说词法分析和语法分析这部分代码是自动生成的,而 Webkit 中实现的 CallBack 函数就是在 CSSParser 中。

CSS 的一些解析功能的入口也在此处,它们会调用 lex , parse 等生成代码。相对的,生成代码中需要的 CallBack 也需要在这里实现。

举例来说,现在我们来看其中一个回调函数的实现,createStyleRule(),该函数将在一般性的规则需要被建立的时候调用,代码如下:

  1. CSSRule* CSSParser::createStyleRule(CSSSelector* selector)  

  2. {  

  3.    CSSStyleRule* rule = 0;  

  4.    if (selector) {  

  5.        rule = new CSSStyleRule(styleElement);  

  6.        m_parsedStyleObjects.append(rule);  

  7.        rule->setSelector(sinkFloatingSelector(selector));  

  8.        rule->setDeclaration(new CSSMutableStyleDeclaration(rule, parsedProperties, numParsedProperties));  

  9.    }  

  10.    clearProperties();  

  11.    return rule;  

  12. }

从该函数的实现可以很清楚的看到,解析器达到某条件需要创建一个 CSSStyleRule 的时候将调用该函数,该函数的功能是创建一个 CSSStyleRule ,并将其添加已解析的样式对象列表 m_parsedStyleObjects 中去,这里的对象就是指的 Rule 。

那么如此一来,经过这样一番解析后,作为输入的样式表中的所有 Style Rule 将被转化为 Webkit 的内部模型对象 CSSStyleRule 对象,存储在 m_parsedStyleObjects 中,它是一个 Vector。

但是我们解析所要的结果是什么?

  • 通过调用 CSSStyleSheet 的 parseString 函数,将上述 CSS 解析过程启动,解析完一遍后,把 Rule 都存储在对应的 CSSStyleSheet 对象中;

  • 由于目前规则依然是不易于处理的,还需要将之转换成 CSSRuleSet。也就是将所有的纯样式规则存储在对应的集合当中,这种集合的抽象就是 CSSRuleSet;

  • CSSRuleSet 提供了一个 addRulesFromSheet 方法,能将 CSSStyleSheet 中的 rule 转换为 CSSRuleSet 中的 rule ;

  • 基于这些个 CSSRuleSet 来决定每个页面中的元素的样式;

这里描述了大致过程,深入阅读可以查看如下链接:

  • Webkit CSS 引擎分析

  • CSS 样式表解析过程

  • Webkit CSS实现

三、CSS 选择器解析顺序

可能很多同学都知道排版引擎解析 CSS 选择器时是从右往左解析,这是为什么呢?

  • HTML 经过解析生成 DOM Tree(这个我们比较熟悉);而在 CSS 解析完毕后,需要将解析的结果与 DOM Tree 的内容一起进行分析建立一棵 Render Tree,最终用来进行绘图。Render Tree 中的元素(WebKit 中称为「renderers」,Firefox 下为「frames」)与 DOM 元素相对应,但非一一对应:一个 DOM 元素可能会对应多个 renderer,如文本折行后,不同的「行」会成为 render tree 种不同的 renderer。也有的 DOM 元素被 Render Tree 完全无视,比如 display:none 的元素。

  • 在建立 Render Tree 时(WebKit 中的「Attachment」过程),浏览器就要为每个 DOM Tree 中的元素根据 CSS 的解析结果(Style Rules)来确定生成怎样的 renderer。对于每个 DOM 元素,必须在所有 Style Rules 中找到符合的 selector 并将对应的规则进行合并。选择器的「解析」实际是在这里执行的,在遍历 DOM Tree 时,从 Style Rules 中去寻找对应的 selector。

  • 因为所有样式规则可能数量很大,而且绝大多数不会匹配到当前的 DOM 元素(因为数量很大所以一般会建立规则索引树),所以有一个快速的方法来判断「这个 selector 不匹配当前元素」就是极其重要的。

  • 如果正向解析,例如「div div p em」,我们首先就要检查当前元素到 html 的整条路径,找到最上层的 div,再往下找,如果遇到不匹配就必须回到最上层那个 div,往下再去匹配选择器中的第一个 div,回溯若干次才能确定匹配与否,效率很低。

对于上述描述,我们先有个大概的认知。接下来我们来看这样一个例子,参考地址:

  1. <div>

  2.   <div class="jartto">

  3.      <p><span> 111 </span></p>

  4.      <p><span> 222 </span></p>

  5.      <p><span> 333 </span></p>

  6.      <p><span class='yellow'> 444 </span></p>

  7.   </div>

  8. </div>

CSS 选择器:

  1. div > div.jartto p span.yellow{

  2.   color:yellow;

  3. }

对于上述例子,如果按从左到右的方式进行查找:

  • 先找到所有 div 节点;

  • 在 div 节点内找到所有的子 div ,并且是 class = “jartto”;

  • 然后再依次匹配 p span.yellow 等情况;

  • 遇到不匹配的情况,就必须回溯到一开始搜索的 div 或者 p 节点,然后去搜索下个节点,重复这样的过程。

这样的搜索过程对于一个只是匹配很少节点的选择器来说,效率是极低的,因为我们花费了大量的时间在回溯匹配不符合规则的节点。

如果换个思路,我们一开始过滤出跟目标节点最符合的集合出来,再在这个集合进行搜索,大大降低了搜索空间。来看看从右到左来解析选择器:

  • 首先就查找到 的元素;

  • 紧接着我们判断这些节点中的前兄弟节点是否符合 P 这个规则,这样就又减少了集合的元素,只有符合当前的子规则才会匹配再上一条子规则。

结果显而易见了,众所周知,在 DOM 树中一个元素可能有若干子元素,如果每一个都去判断一下显然性能太差。而一个子元素只有一个父元素,所以找起来非常方便。

试想一下,如果采用从左至右的方式读取 CSS 规则,那么大多数规则读到最后(最右)才会发现是不匹配的,这样会做费时耗能,最后有很多都是无用的;而如果采取从右向左的方式,那么只要发现最右边选择器不匹配,就可以直接舍弃了,避免了许多无效匹配。

浏览器 CSS 匹配核心算法的规则是以从右向左方式匹配节点的。这样做是为了减少无效匹配次数,从而匹配快、性能更优。

深入阅读,请移步:

  • jQuery 源码解析

  • CSS 选择器从右向左的匹配规则

  • CSS 选择器

四、CSS 语法解析过程

CSS 样式表解析过程中讲解的很细致,这里我们只看 CSS 语法解释器,大致过程如下:

  • 先创建 CSSStyleSheet 对象。将 CSSStyleSheet 对象的指针存储到 CSSParser 对象中。

  • CSSParser 识别出一个 simple-selector ,形如 “div” 或者 “.class”。创建一个 CSSParserSelector 对象。

  • CSSParser 识别出一个关系符和另一个 simple-selecotr ,那么修改之前创建的 simple-selecotr, 创建组合关系符。

  • 循环第3步直至碰到逗号或者左大括号。

  • 如果碰到逗号,那么取出 CSSParser 的 reuse vector,然后将堆栈尾部的 CSSParserSelector 对象弹出存入 Vecotr 中,最后跳转至第2步。如果碰到左大括号,那么跳转至第6步。

  • 识别属性名称,将属性名称的 hash 值压入解释器堆栈。

  • 识别属性值,创建 CSSParserValue 对象,并将 CSSParserValue 对象存入解释器堆栈。

  • 将属性名称和属性值弹出栈,创建 CSSProperty 对象。并将 CSSProperty 对象存入 CSSParser 成员变量m_parsedProperties 中。

  • 如果识别处属性名称,那么转至第6步。如果识别右大括号,那么转至第10步。

  • 将 reuse vector 从堆栈中弹出,并创建 CSSStyleRule 对象。CSSStyleRule 对象的选择符就是 reuse vector, 样式值就是 CSSParser 的成员变量 m_parsedProperties 。

  • 把 CSSStyleRule 添加到 CSSStyleSheet 中。

  • 清空 CSSParser 内部缓存结果。

  • 如果没有内容了,那么结束。否则跳转值第2步。

五、内联样式如何解析?

通过上文的了解,我们知道,当 CSS Parser 解析完 CSS 脚本后,会生成 CSSStyleSheetList ,他保存在Document 对象上。为了更快的计算样式,必须对这些 CSSStyleSheetList 进行重新组织。

计算样式就是从 CSSStyleSheetList 中找出所有匹配相应元素的 property-value 对。匹配会通过CSSSelector 来验证,同时需要满足层叠规则。

将所有的 declaration 中的 property 组织成一个大的数组。数组中的每一项纪录了这个 property 的selector,property 的值,权重(层叠规则)。

可能类似如下的表现:

  1. p > a {

  2.  color : red;

  3.  background-color:black;

  4. }  

  5. a {

  6.  color : yellow

  7. }  

  8. div {

  9.  margin : 1px;

  10. }

重新组织之后的数组数据为(weight我只是表示了他们之间的相对大小,并非实际值。)

  1. selector      property                       weight  

  2. a             color:yellow                   1  

  3. p > a         color:red                      2  

  4. p > a         background-color:black         2  

  5. div           margin:1px                     3

好了,到这里,我们来解决上述问题:
首先,要明确,内敛样式只是 CSS 三种加载方式之一;

其次,浏览器解析分为两个分支,HTML Parser 和 CSS Parser,两个 Parser 各司其职,各尽其责;

最后,不同的 CSS 加载方式产生的 Style rule ,通过权重来确定谁覆盖谁;

到这里就不难理解了,对浏览器来说,內联样式与其他的加载样式方式唯一的区别就是权重不同。

深入了解,请阅读Webkit CSS引擎分析

六、何谓 computedStyle ?

到这里,你以为完了?Too young too simple, sometimes naive!

浏览器还有一个非常棒的策略,在特定情况下,浏览器会共享 computedStyle,网页中能共享的标签非常多,所以能极大的提升执行效率!如果能共享,那就不需要执行匹配算法了,执行效率自然非常高。

也就是说:如果两个或多个 element 的 computedStyle 不通过计算可以确认他们相等,那么这些 computedStyle 相等的 elements 只会计算一次样式,其余的仅仅共享该 computedStyle 。

那么有哪些规则会共享 computedStyle 呢?

  • 该共享的element不能有id属性且CSS中还有该id的StyleRule.哪怕该StyleRule与Element不匹配。

  • tagName和class属性必须一样;

  • mappedAttribute必须相等;

  • 不能使用sibling selector,譬如:first-child, :last-selector, + selector;

  • 不能有style属性。哪怕style属性相等,他们也不共享;

  1. <span><p style="color:red">paragraph1</span></p>

  2. <span><p style="color:red">paragraph2</span></p>

当然,知道了共享 computedStyle 的规则,那么反面我们也就了解了:不会共享 computedStyle 的规则,这里就不展开讨论了。

深入了解,请参考:Webkit CSS 引擎分析 - 高效执行的 CSS 脚本

七、眼见为实

如上图,我们可以看到不同的 CSS 选择器的组合,解析速度也会受到不同的影响,你还会轻视 CSS 解析原理吗?

感兴趣的同学可以参考这里:speed/validity selectors test for frameworks

八、有何收获?

1.使用 id selector 非常的高效。在使用 id selector 的时候需要注意一点:因为 id 是唯一的,所以不需要既指定 id 又指定 tagName:

  1. Bad

  2. p#id1 {color:red;}  


  3. Good  

  4. #id1 {color:red;}

当然,你非要这么写也没有什么问题,但这会增加 CSS 编译与解析时间,实在是不值当

2.避免深层次的 node ,譬如:

  1. Bad  

  2. div > div > div > p {color:red;}


  3. Good  

  4. p-class{color:red;}

3.慎用 ChildSelector ;

4.不到万不得已,不要使用 attribute selector,如:p[att1=”val1”]。这样的匹配非常慢。更不要这样写:p[id=”id1”]。这样将 id selector 退化成 attribute selector。

  1. Bad  

  2. p[id="id1"]{color:red;}  

  3. p[class="class1"]{color:red;}  


  4. Good

  5. #id1{color:red;}  

  6. .class1{color:red;}

5.理解依赖继承,如果某些属性可以继承,那么自然没有必要在写一遍;

6.规范真的很重要,不仅仅是可读性,也许会影响你的页面性能。这里推荐一个 CSS 规范,可以参考一下。

九、总结

“学会使用”永远都是最基本的标准,但是懂得原理,你才能触类旁通,超越自我。

十、更多资源

  • CSS 解析顺序

  • 优先级详细探索

  • 简单剖析 CSS 的解析规则

最后,为你推荐

【第1067期】CSS选择器从右向左的匹配规则


【第801期】 构建稳固的、可升缩的CSS框架的八大原则


关于本文

作者:@jartto.wang

原文:http://jartto.wang/2017/11/13/Exploring-the-principle-of-CSS-parsing/index.html


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存