10分钟,不,15分钟搞懂CSS样式计算原理?!

1,160 阅读6分钟
当今前端界有很多文章都在阐述浏览器的渲染原理,几乎所有的前端从业者都有看到过下面这张图并读过与pipeline相关的文章。


打开chrome浏览器的performance面板,记录页面加载的情况。大致有这么几个重要阶段:Loading、Scripting、Rendering、Painting。



Loading对应HTML等资源的加载,Scripting对应Javascript执行,Rendering对应Style的样式计算和Layout布局,Painting对应浏览器的栅格化、绘制和合成分层等启用GPU加速渲染的过程。

当然,上面这张图Scripting阶段占比比较高,如果想达到60FPS的流畅效果,根据RAIL规则,需要针对Scripting阶段进行优化。

这类文章有很多感兴趣的读者可以自行谷歌,我们今天详细说明Rendering阶段中的样式计算过程。

在进行样式计算之前,我们必须要有样式表。在Loading阶段浏览器除了解析HTML生成DOM之外还会进行CSS样式规则解析生成样式表。Web应用所有的样式规则应该在这个阶段全部注册到样式表内。为什么呢?稍后解答。

我们知道浏览器中DOM是运行时构建的,简单点说,DOM树的构建过程就是调用Node类的appendChild、insertBefore、removeChild这三个方法生成父子关系节点的过程,每个DOM节点都是一个Element实例,其构造函数继承关系如下:


从设计浏览器模型的角度来看,生成DOM节点并插入父节点的过程和样式计算的过程应该是相互独立、解耦合的。浏览器在实现上也确实是这样。

调用Document实例的createElement方法生成一个DOM节点,接着调用父节点的appendChild或insertBefore方法将DOM节点插入。紧接着DOM节点被传入到浏览器样式计算模块,在样式计算模块内根据样式表进行样式匹配和计算,样式计算完成后生成CSSOM节点插入到CSSOM树中。接下来就是大家熟悉的Layout布局和Paint绘制过程,这里不细表。

接下来举个例子,定义如下一个StyleSheet:

.class1 {
    color: #ff0000;
}
.class2 {
    color: #00ff00;
}
.class1 .class2 {
    color: #0000ff;
}
span {  
    color: #f0f0f0;
}

HTML结构如下:

<div class="class1">
    <div class="class3">
        <span class="class2">一棠世界</span>  
    </div>
</div>

样式表生成一个类似以下结构的对象。

{  
    ".class1": {    
        score: 10e6 + 1, // 代表权重    
        selector: "class",  // 类选择器    
        def: {      
            color: "#ff0000"    
        },   
        name: ".class1"
     },  
    ".class2": {    
        score: 10e6 + 2, // 代表权重    
        selector: "class",  // 类选择器    
        def: {
           color: "#00ff00"   
        },    
        name: ".class2" 
     },  
    ".class1 .class2": {    
        score: 2 * 10e6 + 3, // 代表权重    
        selector: "descendant",  // 后代选择器    
        def: {      
            color: "#0000ff"   
        },   
        name: ".class1 .class2"  
        },  
    "span": {    
        score: 10e3 + 4, // 代表权重   
        selector: "tag",  // 标签选择器    
        def: {  
            color: "#f0f0f0"   
        },    
        name: "span"  
    }
}

第一步,生成外层的div元素,并设置其class属性为class1。

在样式表中匹配到的样式规则如下:

[{
    score: 10e6 + 1, // 代表权重    
    selector: "class",  // 类选择器  
    def: {      
        color: "#ff0000"
    },   
    name: ".class1"  
}]

第二步,生成第二层的div,并设置其class属性为class3。未匹配到样式表中的规则。

第三步,生成第三层的span,并设置其class属性为class2。匹配到的样式规则如下:

 {".class1 .class2": {
    score: 2 * 10e6 + 3, // 代表权重    
    selector: "descendant",  // 后代选择器    
    def: {
        color: "#0000ff"    
    },    
    name: ".class1 .class2"  
    },  
    "span": {    
        score: 10e3 + 4, // 代表权重    
        selector: "tag",  // 标签选择器    
        def: {      
            color: "#f0f0f0"    
        },    
        name: "span"
    }

最后,针对返回的数组做一次合并,也就是CSS样式层叠。

这里要强调一下后代选择器的匹配过程,先匹配样式表中最末尾选择器是.class2的样式规则,找到.class2之前的选择器是.class1,那么在DOM树中以.class2为起点沿着父节点向上查找,直到找到.class1这个选择器。如果后代选择器较长,则查找的时间也随之增加。具体过程如下:

这里将模型简单化,以说明实现原理和过程。实际上,还存在内联样式和其他提升样式权重的选择器。同时浏览器中做了大量的算法和缓存优化,保证样式匹配的速度。

从上面这个例子可以看出,在实际的开发过程中,如果书写了大量的后代选择器,对样式计算的影响是非常大的。这里我建议开发者使用BEM方案解决,尽量减少选择器的长度。

前文阐述了第一次构建DOM树时的样式匹配过程。目前流行的前端框架会在运行时动态修改DOM的id或者class属性。这里请读者仔细思考一下,如果修改了某个元素的属性,那么沿着它的父节点向上所有节点的样式会受到影响吗?

答案是否定的。修改某个DOM的属性只会影响它本身以及后代元素的样式。当然这也发生在样式表中有例如`.class1 .class2`这种后代选择器规则的情况下。如果所有的选择器都是id选择器、类选择器、标签选择器这样的简单规则,那么每一次属性更新中,浏览器只需要做少量工作。

还有一种情况需要考虑。调用removeChild移除某个DOM节点,浏览器只需要在垃圾回收时移除对应的DOM。其子节点也一并移除,无需重新计算样式,这种情况是最简单也是最好理解的。

前面留下的问题还记得吗?在Loading阶段完成所有的样式规则的注册,不要在其他阶段动态添加样式。试想下,如果在其他阶段动态添加样式规则,那么之前DOM匹配到的样式就有可能被新的样式覆盖,浏览器无法保证每个节点的准确性就需要对所有的DOM节点重新进行样式计算。这显然也是一个非常大的性能损耗!

综上可以总结出几点针对样式计算阶段的CSS书写规范:

1、将应用中所有使用到的css样式规则统一在Loading阶段注册,不要动态注册样式。

2、尽量避免使用较长的后代选择器。

3、可以用BEM方案进行CSS模块化处理。

由于这是我的第一篇公众号文章,微信还没有给我开通评论功能。如果有读者希望继续深入了解相关知识,可以加我微信:xiaomuhenkaixin。或者扫描下面的二维码进入讨论群。