现代 CSS

编写高效 CSS 选择器

特别声明:如果您喜欢小站的内容,可以点击申请会员进行全站阅读。如果您对付费阅读有任何建议或想法,欢迎发送邮件至: airenliao@gmail.com!或添加QQ:874472854(^_^)

早在十多年前,在社区就有很多专业人士探讨和深究过 CSS 选择器对渲染性能的影响。特别是对于今天的现代浏览器而言,他们经过了多年的变化(和优化),浏览器变得更聪明!对于 Web 开发人员,“不应该需要担心优化选择器的问题”,他对页面的渲染性能影响已经非常的小,正如 Antti Koivisto所说:

“My view is that authors should not need to worry about optimizing selectors (and from what I see, they generally don’t), that should be the job of the engine.”

即使如此,CSS的选择器的使用还是有分高效和非高效的,我们在编码的时候,还是应该尽可能的使用高效的CSS选择器,因为高效的CSS选择器对于页面的渲染是有一定帮助的,哪怕这种帮助很微小。但对于追求极致的渲染体验,这一切都是值得的,因为你要付出的并不会太多,反而得到的会较多。

如果对网站的所有领域,包括CSS都进行微小的改进,那么他们将产生更多的实质性变化;用户总是会受益的

CSS 是如何在浏览器中工作的

在《初探 CSS 渲染引擎》都提到 CSS 的选择器的解析会涉及到 样式计算和渲染树的影响。浏览器在构建了 DOM 和 CSSOM 之后,浏览器需要将两者合并成渲染树,在这一步,浏览器需要弄清楚每个元素的计算CSS。这个样式匹配中不可或缺的就是 CSS 的选择器,只有选择器配对成功的 CSS 才会用来进行样式计算,与 DOM节点匹配,构建出 CSSOM树。在这个过程中,也有可能致使计算过的样式失效(比如动态改了DOM节点或选择器),浏览器也需要使匹配的选择器树下的扎有内容失效,从而造成样式的重新计算。在渲染性能方面,这个过程也是较为耗时的,因此,为了避免这个问题的出现,其中有一个方法就是 减少CSS选择器的复杂性。即:编写高效的CSS选择器

在介绍如何编写高效的CSS选择器之前,我们有必要先花点时间了解 CSS在浏览器中是如何工作的?

我们知道,一个浏览器大概包括以下几个重要部分(高级组件):

浏览器的渲染引擎对网页的内容进行渲染。默认情况下,渲染引擎可以渲染 HTML、XML 和 图像(Image)。它根据请求的 URL 接收到的响应的 MIME 类型来渲染内容。例如,如果 MIME 类型是 text/html ,渲染引擎会解析 HTML 和 CSS ,并渲染内容。

渲染引擎的主要流程

渲染引擎从网络层获取文档的内容,通常是以 8kb 为单位,并对文档的内容进行以下工作。渲染是一个渐进的过程,当渲染引擎开始接收到要渲染的文档内容时,它就开始渲染!

  • 内容树的构建:HTML 元素被转换为 DOM 节点,即 DOM 树
  • 渲染树的构建:样式被解析(即 CSSOM 树)并添加到 DOM树中以生成渲染树(即 Render 树)
  • 布局过程:渲染树的每个节点被分配一个位置
  • 绘制过程:渲染树的每个节点使用 UI 后台(UI Backend)进行绘制

整个渲染过程如下图所示:

图:Webkit 内核渲染的过程

解析

解析是由渲染引擎进行的关键过程。解析是一个过程,在这个过程中,输入被分解成更小的元素,以便将输入转换为其他格式。解析过程产生了由节点树组成的文档结构。

比如表达式(2 + 3 - 1) ,解析之后如下图这样:

上图:数学表达式树节点

根据文档所遵循的词汇和语法规则对其进行解析。这些规则称为 无语镜语法,必须遵循代码才能被解析!

图:文件到解析树

解析由两个过程组成:

  • 词法解析(Lexical Analysis Process):在词法解析过程中,代码被分解为标记,这些标记是语言词汇表中的有效元素。它有词法分析器(Lexer)或 令牌器(Tokenizer)执行。这个过程也被称为 Token 化
  • 语法解析(Syntax Analysis Process):在语法分析过程中,语法规则被应用于由词法分析器(Lexer)返回的标记上

解析是一个递归过程。解析器试图将语法规则(Syntax Rules)与词法(Lexical)返回的标记相匹配。如果语法规则匹配,该标记就会被添加到解析树中,解析器会要求提供一个新的标记。如果该规则没有被匹配,那么该标记将被保存在内部,解析器将要求提供新的标记,直到找到一个与所有内部存储的标记相匹配的规则。如果规则没有被匹配,解析器会引发一个异常。这意味着按照无语境语法(上下文自由语法),该文档是无效的。

词汇和句法的表示
  • 语言的词汇以正则表达式的形式表达:上下文自由语法(Context Free Grammar)是以 Backus-Naur 形式的符号(表示法)技术来定义的,它被用来描述计算机中使用的语言的语法,比如计算机编程语言、文件格式、指令集和通信协议等。
  • 解析器的类型:解析器的类型主要有两种,自上而下的解析器(Top-down Parsers) 和 自下而上的解析器(Bottom-up Parsers)
    • 自上而下的解析器(Top-down Parsers):该解析器检查语法的高级结构,并试图找到一个规则匹配
    • 自下而上的解析器(Bottom-up Parsers):该解析器从输入开始,逐渐将其转化为语法规则,从低级规则开始,直到满足高级规则

还是拿 (2 + 3 -1) 表达式为例,看看这两种类型的解析器如何解析。

自上而下的解析器将从高级别(high-level)的规则开始:它将 2 + 3 标识为表达式。然后它将 2 + 3 - 1 标识为另一个表达式(识别表达式的过程是不断发展的,与其他规则相匹配,但起点是最高级别的规则)。

自下而上的解析器将扫描输入,直到有匹配的规则为止。然后,它将用该规则替换匹配的输入。这将一直持续到输入的结束。部分匹配的表达式被放在解析器的堆栈中。这种自下而上的解析器被称为 Shift-reduce解析器,因为输入被右移(想象一下,一个指针(光标指示器)首先指向输入的起点,然后随着输入向右移动),并逐渐还原为语法规则。

HTML 解析器

HTML解析器(HTML Parser)将 HTML 标记转换为解析树。 W3C HTML5 语法规范中定义了 HTML 的语法。HTML 不容易被解析器所需要的无语境语法所定义。有一种定义 HTML 的正确格式,即 DTD(Document Type Definition),但它不是一种无语境语法(Context Free Grammar)。由于 HTML 的语法不是无语境(也称无上下文)的,所以传统的解析器不能轻易地对其进行解析。 HTML 不能被 XML 解析器所解析。

HTML 符合数据类型定义格式,该格式用于定义 SGML系列的语言。该格式包含所有允许的元素、它们的属性和层次结构的定义。 HTML DTD 并不构成无语境语法。

DTD有一些变体。严格模式(Strict Mode)完全符合规范,但其他模式包含对浏览器过去使用的标记的支持。其目的是向后兼容较旧的内容。然而, HTML5 并非基于 SGML 的,因此不需要对 DTD 的引用。

文档对象模型(DOM)是一个平台和语言无关的接口,它允许程序和脚本动态访问和更新文档的内容、结构和样式。解析树(Parse Tree)是一个由 DOM 元素和属性节点组成的树状结构。

图:DOM 树(DOM Tree)

图:HTML 解析过程

给定一个编码,输入流中的字节必须转换为 Unicode 字符,以便标记(Token),这些过程在字节流解码器(Byte Stream Decoder)和输入流预处理器(Input Stream Preprocessor)中进行。标记化(Tokenizer)是语法解析(Lexical Analysis),将输入解析为标记。在 HTML 标记中,有开始标记(<)、结束标记(>)、属性名称(Attribute Name)和 属性值(Attribute Value)。标记化(Tokenizer)识别出标记(Token),将其交给树形构造器(Tree Construction),并使用下一个字符来识别下一个标记,以此类推,直到输入的结束。

图:构建 DOM 树的的过程(Webkit内核)

CSS 解析器

CSS 字节被转换为字符,然后是标记,然后是节点,最后它们被链接到一个被称为 CSS 对象模型(CSSOM) 的树状结构。

图:CSS 解析过程

图:CSS 解析过程(Webkit内核)

当计算页面上任何对象的最终样式集时,浏览器从适用于该节点的最一般的规则开始(例如,如果它是一个 body 元素的子元素,那么所有的 body 样式都适用),然后通过应用更具体的规则递归地完善计算出的样式;也就是说,规则是“层叠向下”的。

图:CSSOM 树(CSSOM Tree)

W3C CSS 2.2 Grammar 文档中对 CSS 语法进行了定义!

Webkit 使用 Flex(Flex Lexical Analyzer Generator) 来生成扫描器(Scanners)或词汇器(Lexers),使用 Bison 来生成解析器(Parser)。这些生成器使用 CSS 语法文件来生成 Lexer 和 Parser。 Bison 生成的是自下而上的 Shift-Reduce解析器。 Firefox 使用手动编写的自上而下的解析器。在这两种情况下,每个 CSS 文件都被解析为一个 CSSStyleSheet 对象,每个对象都包含 CSS 规则。CSS 规则(cssRules)对象包含选择器和声明对象以及其他对应于CSS语法的对象。接下来会进行 CSSRule 的匹配过程,去找到能够和 CSSRule Selector部分匹配的 THML 元素。

图:CSS 主要类与关系

Document 里包含了一个 DocumentStyleSheetCollection 类和一个 StyleSheetResolver 类, DocumentStyleSheetCollection 包含了所有的 StyleSheetStyleSheet里包含了CSS 的 href,类型,内容等信息。

StyleSheetResolver 负责组织用来为 DOM 里的节点匹配的规则,里面包含了一个 DocumentRuleSets 的类,用来表示多个 RuleSet

图:CSS 文档结构的类

图:StyleRuleBase 相关继承关系

图:StyleRule 类的结构

我们可以使用 document.styleSheets 把页面 CSS 相关信息打印出来:

通过 styleSheets ,我们可以看到以下几个信息:

  • 页面 CSS 结构解析之后生成 CSSOM 树中,多少个 style 标签,在 StyleSheetList 对象中应用几条规则
  • 按照先后顺序,StyleSheetList 先插入的是开发样式、其次是浏览器用户样式,最后面的规则是浏览器默认样式,优先级最低
  • 每一条规则都有一个 disabled 属性,控制该规则何时生效
  • CSS样式解析生成规则之后存储在 cssText 对象上
  • 在单条 style 规则中,规则由 cssTextstyleselectorTextparentStyleSheet 等对象组成,供开发者访问操作
  • 返回的是一个 CSSStyleDeclaration 集合,和上面规则中的 style 对象是同一种类型,里面存储了所有CSS属性的值,没有添加样式的都为空(null
  • 每个元素上都有样式(style)接口
  • 通过 JavaScript 脚本操作style,可以修改该元素CSSStyleDeclaration 集合中 CSS 属性的值,相当于行内样式(元素的style 属性)

CSS 解析完,节点会调用 CSSStyleSelectorstyleForElement 来给节点创建 RenderStyle 实例。 RenderObject 需要 RenderStyle 的排版信息。 CSSSstyleSelector 会从 CSSRuleList 里将匹配的样式属性取出来进行规则匹配。相关类图如下:

CSS 规则匹配的流程如下图所示:

渲染树的构建

在构建 DOM 树的同时,渲染引擎还构建了渲染树(Render Tree)。 CSSOM 和 DOM 树组合成一棵渲染树。然后用来计算每个可见元素的布局,并作为渲染过程的输入,将像素渲染到屏幕。渲染树只包含渲染页面所需的节点。

为了构建渲染树,浏览器大致做了以下工作:

  • 从 DOM 树的根节点开始,遍历每个可见节点:有些节点是不可见的(比如 <script><link> 等),由于它们没有反映在渲染的输出中,所以被省略了。有些节点是通过 CSS 隐藏,也从渲染树中省略了(比如设置了display: none 的 DOM 节点)
  • 对于每个可见节点,找到适当的匹配的 CSSOM 规则并应用它们
  • 发出带有内容和其计算样式的可见节点

浏览器渲染引擎会把渲染树和DOM树节点做映射(并不是一一对应的),也就是说,有些渲染对象(Render Object)有对应的 DOM节点(DOM Node),但是不在树的相同位置(比如,设置了绝对定位的元素),他们会放在树的其他地方。

图:渲染树和DOM对做对应

在创建渲染树流程中,遇到 <html><body> 标记就会构建渲染树根节点,作为最上层的块(Block),包含了所有的块(Block),他的尺寸就是窗口大小(Viewport):

通过添加和删除元素,改变属性、为或通过动画来改变 DOM,都会导致浏览器重新计算元素样式,并且在很多情况下,对页面或其部分进行重排。这个过程也被称为 样式计算。样式计算我们将单独拿出来介绍。

布局过程

到渲染过程为止,我们得到了所有应该可见的节点和这些节点的样式属性。唯一缺少的属性是元素在设备视口(Viewport)中的位置和大小。这些是在布局过程中计算的。它也被称为 回流(重排)过程

有关于页面重排和重绘方面,更详细的内容可以阅读《理解 Web 的重排和重绘》。

布局是一个递归过程。它从根渲染器(<html> 元素)开始,通过框架层次结构中的一些或所有渲染器继续进行,这些渲染器需要计算几何信息。

在浏览器中, Dirty Bit System 被用来避免在发生小的变化时计算整个布局。当一个新的渲染器被添加或现有渲染器被更改时,它就会将自己及其子代标记为“脏位”(Dirty)。如果渲染器及其子代是脏的,就会使用“Dirty”标记。当渲染器没有被改变,但一个或多个子代被改变或添加时,“子代是脏的”标记就被设置。

全局布局过程(Global Layout Process)是指由于全局样式的变化而整个渲染树上触发的过程。例如,窗口大小的变化、全局样式的变化。全局布局通常是同步进行的。

递增式布局过程(Incremetal Layout Process)发生在以下情况:由于某个特定的渲染器或其子代的样式变化,或者增加了 DOM 节点,而在脏渲染器上触发该过程。递增式布局通常是以异步方式进行的,除非在某些特殊情况下,比如脚本请求样式值时。

绘制过程

绘制是将渲染树中的每个节点转换为屏幕上的实际像素的过程。它也被称为 “光栅化(Rasterizing)”。当布局完成后,浏览器发出 paint 事件,利用浏览器的基础设备组件在屏幕上实际绘制内容。

与布局过程类似,绘制过程也可以是全局或增量的。在全局绘制过程中,整个树被绘制。在增量绘制过程中,一些渲染器和它们的子节点被绘制。被修改的渲染器会使其在屏幕上的矩形失效,从而导致操作系统将该矩形视为“脏区域”(Dirty Region),并生成“涂抹(Paint)”事件。在 Chrome 中,这个过程很复杂,因为渲染器是在独立的进程中,而不是在主进程中。 Chrome 在一定程度上模拟了操作系统的行为。渲染器会监听这些事件,并将消息委托给渲染根节点(Render Root)。渲染树会被遍历,直到到达相关的渲染器。如果需要的话,它将重新绘制自己和它的子代。

绘制过程的顺序是由 CSS 规范定义的,是按照元素在层叠上下文中的层叠顺序进行的。这个顺序会影响绘制,因为层叠是从后往前画的。渲染器的层叠顺序是:背景色、背景图片、边框、子元素、轮廓。

浏览器试图对变化做出最小可能的反应。如果元素的颜色发生了变化,浏览器只会重新绘制该元素。如果元素的位置发生变化,浏览器将对该元素、子元素以及可能的同级元素进行重排和重绘。如果添加了 DOM 节点,浏览器将对该节点进行布局和绘制。如果发生了重大变化,比如根元素的字体大小变化,那么所有的布快叫 缓存都会失效,整个树的重排和重绘会进行。

渲染引擎的基本过程

渲染引擎的主要工作就是把页面的 HTML 和 CSS 文件中转化为屏幕上显示的像素点。

想要把文件转化为屏幕上像素点,目前主流浏览器的渲染引擎基本上都会做以下相同的事情。

Step01: 把 HTML 文件解析成浏览器能理解的对象,包括 DOM。从这个角度来说, DOM 掌握了整个页面结构。它知道每个元素之间的相互关系:父子兄弟后代,相当于一个家族的族谱:

图:从 HTML 到 DOM 树过程(HTML经过 HTML Parser 处理之后得到一棵 DOM 树)

Step02:弄清楚每个元素应该长什么样子(UI 效果)。对于每个 DOM 节点 (DOM Node),CSS 引擎会弄清楚应该在 DOM 节点上采用哪些 CSS 规则。然后,会计算出每个 CSS 属性的值。

图:从 CSS 到 CSSOM 过程(CSS 经过 CSS Parser 之后得到 CSSOM 树)

Step03:计算出每个DOM 节点的尺寸和位在屏幕上的位置。为每个要在屏幕上显示的内容创建盒模型。这些盒模型不仅仅用来表示 DOM 节点,也用来表示 DOM 节点的内部内容,比如元素的文本内容(文本节点):

图:DOM 和 CSSOM 的结合,构建渲染树(Render Tree)

Step04:绘制不同的盒模型。这可以发生在多个图层上。它就像是以前使用半透明纸的手绘动画,每个图层都是独立的一张纸。这样我们就可以只改变当前图层的内容,而不影响到其他图层的内容。

Step05:取出已绘制的图层,应用任何仅包含合成器的属性,然后把它们合成为一张图。这就好比为这些叠加在一起的图层拍一张照片,之后这张照片将会在屏幕上渲染出来。

在这五个基本过程中,当 CSS 引擎开始计算样式时,它已经得到两个重要信息:

  • DOM树
  • 样式表规则列表

渲染引擎会逐个遍历所有 DOM 节点,并计算出它们的样式。这个过程中,它会给 DOM 节点的每个 CSS 属性进行求值,包括样式表中没有声明的属性。

这个过程就像一个人从头到尾填一张表格一样。 CSS 引擎需要给每个 DOM 节点都填写一张表格。并且,表格的每一处空白都需要填上值。

为了填这张表,渲染引擎需要做两件事:

  • 选择器匹配(Selector Mathing) :计算出每个节点应该用什么样式规则,它的先决条件就是 CSS 的选择器与 DOM 节点相匹配
  • 样式级联(Cascading): 根据父节点或默认值计算出缺失的属性值

样式计算

介绍样式计算之前,先来了解几个重要概念,比如选择器匹配、选择器权重、CSS的级联和继承。

选择器匹配

CSS 的选择器是用来决定 DOM 节点和 CSS 规则匹配的重要条件之一,在开始介绍 选择器如何和样式规则匹配之前,先简单介绍选择器中几个重要的概念。

选择器的解析

先看一个选择器是怎么解析的。来看几个常有的 CSS 选择器解析。

标签元素选择器,如 div

ID选择器,比如#id :

类选择器,比如 .class

上面截图来自 Parsel,可以在线查看 CSS 选择器解析结果,Token 和 AST 的对比,以及选择器权重。

浏览器渲染引擎在解析 CSS 选择器是从右到左的一个过程。拿一个简单的例子来说明:

<style>
    .text{
        font-size: 22em;
    }
    .text p{
        color: #505050;
    }
</style>
<div class="text">
    <p>hello, world</p>
</div>

上面的代码会生成两个规则,第一个规则会放到classRules 里面(是一个hashMap),第二个规则放到 tagRules 里面。

// 四个 hashMap 
CompactRuleMap m_idRules;                    //id
CompactRuleMap m_classRules;                 //class
CompactRuleMap m_tagRules;                   //标签
CompactRuleMap m_shadowPseudoElementRules;   //伪类选择器

CSS 解析完会触发 layoutTree,会给每个可视 DOM 节点(Node)创建一个 Layout 节点,创建时需要计算样式,这个过程包括找到选择器和样式规则。

// Layout 会更新递归所有 DOM 元素
void ContainerNode::attachLayoutTree(const AttachContext& context) {
    for (Node* child = firstChild(); child; child = child->nextSibling()) {
        if (child->needsAttach())
        child->attachLayoutTree(childrenContext);
    }
}

开始从 document 开始进行深度遍历,对于每一个节点(Node)会依次按照 id 、类、伪元素、标签的顺序取出所有选择器,进行比较判断,最后是通配符。

//如果结点有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);
//最后是通配符
...

可以看到 id 是唯一的,容易直接取到,class 就需要遍历数组来设置样式。上面示例中的 DOM 的规则只有两个,一个是 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 将会在上面第七行匹配成功,并且它左边没有限定了,所以返回匹配成功。

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

前一个选择器的检验关键是靠当前选择器和它的关系,解析器一开始的定义了几种 relationType ,这里的 prelationTypeDescendant,即后代。上面在调了 checkOne 成功之后,继续往下走:

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
};

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 将返回成功,并且它已经是最后一个选择器了,所以判断结束,返回成功匹配。

上面的解释了 CSS选择器从右到左的实现。在解析选择器的过程中,会递所有父节点,再次执行 checkOne ,这样直到找到需要的父节点以及如何需要的父节点的父节点才会停止递归。

那么, CSS 选择器的解析 为什么是 从右到左

其实上面的浏览器内核代码阐述了其中原因。不过我想通过下面这个示例,用另外一种方式来阐述,选择器的解析为什么是 从右到左,而不是从左到右 。比如我们有这样的一个示例:

<!DOCTYPE html>
<html lang="en">
    <head>
        <style>
            div ul.container li span.active {
                color: red
            }
        </style>    
    </head>
    <body>
        <div>
            <ul class="container">
                <li><span>Item1</span></li>
                <li><span>Item2</span></li>
                <li><span>Item3</span></li>
                <li><span class="active">Item4</span></li>
            </ul>
        </div>
    </body>
</html>

示例对应的 DOM 树如下:

我们先按照 “从左到右” 的方式来进行分析:

  • Step01:先找到 DOM 树中的所有 div 节点
  • Step02:在 div 节点内找到所有的子节点 ul ,并且是 class="container"
  • Step03:在 ul.container 的节点内找到所有子节点 li
  • Step04:在 li 节点中找到所有 span 节点,并且是 class="active"

遇到不匹配的情况下,就必须回溯到一开始搜索的 div 节点,重复上面的过程中。比如说,我们页面有很上千个 divullispan 标签的情况下,可想而知得多少遍历查询的时间。简单地说,这种 查询方式大量时间花在回溯匹配不符合规则的节点。

我们再反过来看,从右到左 的方式:

  • Step01:查询DOM树中所有 span 节点,且 class="active"
  • Step02:接着检测 span 节点的父节点是否是 li 节点,如果不是则进入同级其他节点的遍历,如果是则进入下一步(Step03)
  • Step03:匹配 li 节点的父节点是 ul,且 class="container" ,如果不是则进入同级其他节点的遍历,如果是则进入下一步(Step04)
  • Step04:匹配 ul class="container" 节点的父节点是 div ,如果不是则进入同级其他节点遍历,如果是则结束遍历

这种方式减少了遍历次数,只有符合当前的子规则才会匹配再上一条子规则,直到最左侧为止。

这样做是为了减少无效匹配的次数,从而匹配快,需要计算的时间更少。所以我们在编写 CSS 选择器时,层级越少越好,这样遍历时间就少。这也是为什么说 CSS 选择器层级最多不超过三级

选择器权重

任何一个选择器都有自己的权重,选择器权重是用来决定元素匹配哪条样式规则的条件之一。CSS 选择器权重可以按照下图的方式来计算:

如下图所示,选择器权重从左到右代表的是从高到低。

CSS 选择器优先级:!important > 行内样式(1, 0, 0, 0)> ID 选择器(0,1, 0, 0) > 类选择器 (0,0,1, 0)> 标签选择器 > (0, 0, 0, 1) > 通配符选择器(0, 0, 0, 0)

另外,CSS 选择器权重是组合计算的过程,也就是说,决定一个选择器的权重是多少,需要根据选择器的个数,类型等根据自己所属类型所占权重进行计数,然后再组合起来。比如 div ul.container li span.active 权重:

CSS 选择器权重计算,可以使用在线工具来计算,比如 CSS Specificity Calculator

来看一个简单的示例:

<style>
    p {
        color: red;
    }

    .foo {
        color: green;
    }

    .bar {
        color: lime;
    }

    .foo.bar {
        color: orange;
    } 

    #baz {
        color: blue;
    }
</style>  

<body>
    <p class="foo bar" id="baz">Text</p>
</body>  

示例中的 CSS 规则(五条不同CSS选择器对应的CSS规则块)都可以运在 <p> 元素,那最终是哪条规则和 <p> 匹配,这个时候选择器权重值就很关键了,上面五条选择器对应的权重如下:

如上图所示:

  • p 标签选择器权重是 (0,0,1)
  • .foo.bar 类选择器权重相同,都是 (0, 1, 0)
  • .foo.bar 类组合选择器权重是 (0,2,0)
  • #baz ID选择器权重是 (1, 0, 0)

最终 #baz 选择器权重最大,因此其对应的规则就会运用到 <p> 元素上,即 color: blue

样式级联

级联又称为层叠(Cascad)!

级联是解决多个 CSS 规则适用于一个 HTML 元素的冲突的算法。级联算法分为四个不同的阶段:

  • 出现的位置和顺序:你的 CSS 规则出现的顺序
  • 选择器权重:一种确定哪个 CSS 选择器具有强匹配的算法(上一节有介绍过)
  • 来源:CSS 出现的顺序和它的来源,无论是浏览器的样式、浏览器扩展(插件)的样式,还是你自己写的样式
  • 重要性:有些 CSS 规则的权重比其他规则高,比如使用了 !importat 的样式规则

出现的位置和顺序

级联在计算冲突解决方案时,会考虑你的 CSS 规则出现的顺序以及它们出现的方式。

比如下面这个示例,两相选择器出现在同一个样式表(样式文件中),而且它们具有相同的选择器权重,出现在后面的那个样式规则将获取:

<style>
    .foo {
        color: red;
    }

    .baz {
        color: blue;
    }
</style>  

<body>
    <p class="foo baz">text</p>
</body>  

.baz 类选择器对应的样式规则 color: blue 获胜!

页面的样式来源也可以是来自 HTML 的不同位置(出现的先后顺序),也可以是来自 HTML 的不同来源方式,比如是 <link> 标签引入的外部样式文件,<style> 标签内部样式,以及元素 style 属性指定的行内样式。

如果把上面的示例代码稍微调整,.foo 放在外部的一个 style.css 文件中:

/* style.css */
.foo {
    color: red;
}

.baz 样式放在 HTML 的内联 <style> 标签内:

<style>
    .baz {
        color: blue
    }
</style>  

在 HTML 中使用 <link> 标签引入 style.css 外部文件,并且在 HTML 中继续使用 <style> 标签定义的内联样式。但这两者出现的顺序将决定 元素使用哪条规则,比如:

<head>
    <link href="./style.css" rel="stylesheet" />
    <style>
        .baz {
            color: blue;
        }
    </style>  
</head>  
<body>
    <p class="foo baz">Text</p>
</body>  

这个时候 .baz 对应的样式规则运用于 p 元素,因为 .foo.baz 选择器权重是相同的,但 .baz 出现 顺序更置后,所以它获胜。如果我们把上面示例稍作调整,<link> 放在 <style> 之后,那么结果就反过来了,.foo 获胜:

<head>
    <style>
        .baz {
            color: blue;
        }
    </style>
    <link href="./style.css" rel="stylesheet" />
</head>  
<body>
    <p class="foo baz">Text</p>
</body>  

有的时候,相同的两条(或多条)属性规则出现在同一个选择器中,比如:

.foo {
    color: red;
    color: blue;
}

这有可能是误操作!

这种情况出现时,那么后面的CSS 属性获胜,比如上面示例中的 color: blue 会运用到类名为 .foo 的元素上。

样式来源

页面上的 CSS 可能不只是一个,甚至有的不是来自你写的 CSS。级联会考虑到 CSS 的来源。这个来源包括 浏览器内部样式表、由浏览器扩展(插件)或电脑操作系统添加的样式,以及你自己写的样式。这些来源也是具有一定权重的区分,权重从小到大是按下面的顺序区分的:

  • 用户代理样式(User Agent Style):你的浏览器客户端默认给 HTML 元素添加的样式
  • 本地用户样式(Local User Style):这些样式可能来自操作系统层面,也有可能来自浏览器插件
  • 作者编写的样式(Authored Style):开发者(也就是你)编写的样式
  • 作者编写带有 !important 的样式:你编写的 CSS 样式规则中带有 !important
  • 本地用户带有 !important 的样式 :任何来自操作系统或浏览器插件的带有 !important 样式规则
  • 用户代理带有 !important 的样式:任何帖浏览器提供的带有 !important 样式规则

重要性

并非所有 CSS 规则的计算方法都是一样的,或者说彼此之间的权重是一样的。也有重要性之分(并不完全是设置 !important 的样式规则),比如下面所列的,就是从最不重要到最重要的一个顺序:

  • 普通规则类型
  • 动画规则类型
  • 带有 !important 规则的类型

CSS animation@keyframes定义的样式规则)和 transition 定义的样式规则要比普通样式规则有更高的权重!

把上面几个点综合起来,可以用下图来描述:

继承

CSS 中有些属性是具有可继承性的。如下图所示:

上图罗列的是常见的可继承的 CSS 属性!

判断一个 CSS 属性是不是可继承的,最的方式就是在相应规范文档中查看 inherited 参数,如果该参数的值是 yes ,表示该属性是一个可继承属性,比如 font-family:

CSS 可继承属性可能从父元素传播给他们的后代元素。一个元素上的属性的继承值是该元素的父元素上的属性的计算值。对于根元素,它没有父元素,继承值是属性的初始值。有些属性是继承的属性,这也意味着,除非级联的结果是一个值,否则该值将由继承来决定。

在优先级的计算中,继承得到的样式的优先级是最低的,在会何时候,只要元素本身有同属性的样式定义,就可以覆盖掉继承值。比如下面这个示例:

<style>
    p {
        color: red;
    }

    span {
        color: blue;
    }
</style>
<body>
    <p><span>text</span></p>
</body>  

示例中,color 是一个可继承的属性,如果 <span> 元素未显式设置 color 的值,那它将继承其父元素 <p> 中的 color 值,如果 <p> 元素中也未显式设置 color ,那么就会一级一级往上查询,直到根元素为止。如果根元素也未设置,将会取系统默认的值,一般是黑色。而示例中 <span> 显式设置了 color 值,所以就会覆盖其祖先元素中已设置的 color 值。

另外,存在多个继承样式时,层级关系距离当前元素最近的父元素的继承样式,具有相对最高的优先级。比如:

<style>
    body {
        color: red;
    }

    p {
        color: blue;
    }
</style>
<body>
    <p><span>text</span></p>
</body> 

示例中 <span> 示显式设置 color ,但 <p> 元素是其父元素,且显式设置了 color ,因此 <span> 元素会继承 <p> 元素的 color ,而不是继承离他较完的 <body> 元素的 color

把前面几个部分结合起来,CSS样式规则在元素上生效的规则可以用下图来描述:

渲染引擎中的样式计算

有了上面的基础,我们回到浏览器的渲染引擎中来。

在做选择器匹配的过程中,渲染引擎会把所有与 DOM 节点相匹配的样式规则添加到一个列表中。正如上面示例所示,可能会有多个 CSS 规则都在匹配中,所以可能有多个相同的 CSS 属性声明。

此外,浏览器本身也提供了一些默认的样式规则,即 用户代理样式表。这个时候CSS选择器权重就可以用来确定哪个规则被运用于节点上。渲染引擎会创建类似下面这样的一张表格,然后根据不同的列对其进行排序。

最终,选择器权重高的将获胜。所以,基于这个表格,渲染引擎就能够填充那些它能够填充的属性值了。对于不能使用这个方式计算出的值,就会使用样式级联。

在渲染引擎中,为了找出级联样式属性,渲染引擎会查看表格中的空白部分。如果属性默认为继承值,那么渲染引擎会沿着 DOM 树往上遍历,看看其祖先元素是否已经设置了该值。如果所有的祖先元素都没有设置该值,或者该属性并不是继承,那么就会使用默认值。

至此, 一个 DOM 节点所有样式属性就已经得到计算值了

样式结构共享

其实,上面提到的样式表格与实际情形并不完全一致。

CSS 拥有的样式属性非常多,达到上百个。如果渲染引擎针对每个 DOM 节点的属性都保存一份样式值,那么内存将会迅速耗尽。相反,渲染引擎通常会使用 样式结构共享(Style Struct Sharing)。它会把通常在一起使用的样式值存储于一个单独的对象中,该对象称为 样式结构。然后,与其重新存储相同对象上的所有样式值,计算的样式对象实际只保存了指向那个对象的指针。对于每个类别的样式,实际上存储的都是一个指向样式结构的指针。

这种共享方式既省内存又省时间。这样的话,具有相似样式的节点(比如兄弟节点)就只需要保存指向共享样式结构对象的指针即可。而且,由于很多属性都是继承的,所以祖先节点可以跟所有的子孙节点共享相同的样式结构对象。

上面所说的,就是优化之前的样式计算过程。

这个过程中进行了很多计算工作。而且它不只是在第一个页面加载的时候发生。它会在用户与页面进行交互的过程中反复发生,比如鼠标悬浮在元素上或 JavaScript动态改变 DOM 结构,都会触发 样式重新计算(Restyle)。

也就是说,CSS 样式计算是一个举足轻重的待优化点。而且在过去的20年里,各个浏览器一直都在测试使用不同的策略来优化它。

规则树

对于每个 DOM 节点,渲染引擎需要遍历所有的样式规则来完成选择器匹配。但是对于大多数节点来说,这种匹配规则并不是改变的太频繁。比如,如果用户把鼠标悬浮在某个父元素上,那么匹配中该元素的样式规则就可能改变了。我们也需要重新计算它的后代元素的样式,以重新处理那些继承属性。当然,匹配中这些后代元素的规则也可能是不变的。

如果我们能够记录哪些规则能够匹配这些后代元素,那是极好的,这样我们就不需要对它们重新进行选择器匹配。这就是 规则树(Rule Tree)原理。浏览器引擎会经历选择器进行匹配的整个过程,然后按照选择器权重来排列它们,由此创建一个规则链表。该链表会被添加到规则树中。

渲染引擎会尽量使得规则树的要支数量保持在最小值。为此,它会尽量复用已存在的规则分支。

如果链表中的选择器与已存在的分支相同,那么它将顺着相同的路径往下走。不过它可能最终会走到某下一个规则不同的节点处,只有这个时候引擎才会新增一个分支。

DOM 节点会取得指向这个规则最末端节点的指针(如示例中的 div#warning 规则)。而且,它的权重最高。

在样式重新计算时,渲染引擎会进行一项快速检查,以判断对父元素的变更是否会影响匹配中子元素的规则。如果不影响,那么对于任何后代节点,引擎只需要顺着后代节点保存的规则指针就可以找到对应规则分支。在规则树中,只要顺着树向上遍历到根节点就可以获得所有匹配的样式规则。也就是说,渲染引擎完全跳过了选择器匹配和权重排列过程。

这样就减少了样式重新计算过程的计算量。

虽然如此,但是在样式初始化时还是会耗费大量计算。假如有 10000 个节点,仍然需要进行 10000次选择器匹配。不过,渲染引擎还采用了样式共享缓存来对它进行优化。

样式共享缓存

对于一个拥有成千上万个节点的页面,其中有许多节点都会匹配相同的样式规则。比如,对一个很长的”维基页面“,主要内容区的段落应该都是应用相同的样式规则,因此也就有相同的样式计算。如果不做优化的话,渲染引擎必须对每个段落都进行一次选择匹配和样式计算。但是,如果有一种方式能证明这些不同段落使用的是相同样式规则的话,那么引擎就只需要做一次计算即可,然后其他段落点都指向相同的计算样式。这种方式就是 样式共享缓存(Style Sharing Cache)。

当处理完一个节点之后,引擎会把计算样式放进缓存。然后,在开始计算下一个节点样式之前,引擎会做一些检查来判断是否可以使用已缓存的样式。

这些检查包括:

  • 两个节点是否有相同的 IDclass 等?如果是,那么它们可以匹配相同的样式规则
  • 对于任何不是基于选择器的样式,比如行内样式,节点具有相同的样式值?如果是,那么继承自己父节点的属性不会被覆盖,或者以相同的方式被覆盖
  • 节点的父节点是否指向相同的计算样式对象?如果是,那么继承的样式规则是一样的

从样式共享缓存被提出的一开始,这些检查就已经应用了。不过,随着 CSS 的发展,有许多其它小场景会导致样式共享缓存的检查方式失效。比如,如果一个 CSS 规则使用 :first-child 选择器,那么两个段落元素时就可能会导致样式不一致,即使上面的那些检查都认为它们是相同的。

如果一个 DOM 节点可以使用已经计算的样式缓存,那么引擎就可以直接跳过大量的计算过程。由于页面中经常有大量的 DOM 节点拥有相同的样式规则,所以样式共享缓存不仅可以节省内存,同时也能加快计算过程。

规则哈希值

我们知道浏览器渲染引擎是从右到左来匹配样式规则的,所以最右边的选择器非常的重要。规则哈希值 将一个样式表根据最右边的选择器分成几组。例如,下面的样式表会被分成三组:

a {}
div p {}
div p.legal {}
#sidebar a {}
#sidebar p {}
a
p
p.legal
a {}
div p {}
div p.legal {}
#sidebar a {}
#sidebar p{}
a p p.legal
a{} div p {} div p.legal{}
#sidebar a {} #sidebar p {}  

当浏览器使用哈希规则时,它不必查看整个样式表中的每一个选择器,而只需要查看一小部分真正有机会匹配的选择器。这样消除了页面上每一个 HTML 元素的不必要的工作。

祖先过滤器

祖先过滤器(Ancestor Filters)要复杂一些。它们是概率过滤器(Probability Filters),计算一个选择器的匹配可能性。由于这个原因,祖先过滤器可以快速消除有关元素没有所需的匹配祖先的规则。在这种情况下,它查询后代和子代选择器,并根据类、id 和 标签进行匹配。特别是子孙选择器,以前被认为是相当慢的,因为渲染引擎需要在每个祖先节点中循环查询,以获得匹配。布隆过滤器(Bloom Filter)对此做了优化。

布隆过滤器(Bloom Filter)是 1970 年被布隆提出来的,用于判断某个元素是否在一个集合中,优点是空间效率和查询时间都非常高,缺点是有一定的误判率和删除困难!

布隆过滤器是一种数据结构,可以让你测试一个特定的选择器是否是一个集合的成员。听起来很像选择器匹配,对吗?布隆过滤器查询一个 CSS 规则是否与你当前查询的元素相匹配的规则集合的成员。布隆过滤器最酷的一点是,假阳性(False Positives)是可能的,但假阴性(False Negatives)是不可能的。这意味着,如果布隆过滤器说一个选择器与当前元素不匹配,那么浏览器就会停止查找,转而寻找下一个选择器。这是一个巨大的节省时间的方法。另一个方面,布隆过滤器说当前的选择器是匹配的,浏览器可以继续使用正常的匹配方法来百分百确定它是匹配的。较大的样式会有更多的误报,所以保持你的样式表合理的精简是一个较好的主意。

祖先过滤器使匹配后代和子代选择器变得非常快。它也可能用来将其他缓慢的选择器扩展到最小的子树,这样浏览器就很少需要处理效率较低的选择器。

无效样式

通过添加或删除 DOM 节点,或者修改属性和输入状态来突然改变 DOM,将会改变 DOM 中一组元素的计算样式。原因是选择器的计算(Selector Evaluation)将由于这些突变而改变。修改树形结构将使选择器因为组合器和结构性伪类而改变计算,而修改属性和输入状态将因为伪类、ID选择器、类选择器和属性选择器而改变计算。树形结构的变化将导致渲染树的重新连接,因此,对于被移除或插入的节点及其整个子树,完整的样式会重新计算。由于相邻的组合器和结构伪类会影响到这些兄弟姐妹,这种类型的变化也会导致被插入或移除节点的兄弟姐妹的计算样式的重新计算。

受 DOM 突变的影响的节点数量取决于文档的作者(开发者)或 UA 样式表中存在的选择器。通过目前 Blink 内核中实现的 CSS 选择器,有可能被一个给定元素的变化影响其计算样式的元素是:

  • 该元素本身
  • 它的后代
  • 所有后面的同级元素和它们的后代

最坏的情况可以用这个规则来表示。

.a, .a *, .a ~ *, .a ~ * * { 

}

当在一个元素上的 class 属性设置为 a 时,

  • 第一个选择器(.a)将选择该元素本身
  • 第二个选择器(.a *)将选择该元素的所有的后代元素
  • 第三个选择器(.a ~ *)将选择该元素其所有的兄弟(姐妹)元素
  • 第四个选择器(.a ~ * *)将选择该元素其所有的兄弟姐妹元素的后代元素

因此,你必须重新计算所有这些子孙和兄弟元素的计算样式。然而,在常见的情况下,当改变一个元素上的类、ID 或其他属性时,需要重新计算样式的元素就少得多。比如:

.a .b {

}

只有该元素(.a)的后代元素,且类名为 .b 元素才会受影响。

考虑到可能受影响的兄弟姐妹和后代的集合,重新计算最暴力的方式就是 ”在最坏情况下重新计算所有元素“。早在 2014年初,Blink 内核的浏览器存储元数据采用的是哈希集,即,如果一个 ID、类或属性出现在选择器中,如果一个元素可能与包含相邻组合器的选择器匹配,以及整个文档中连续相邻组合器的最大数量,它就在一个哈希集中。在实践中,可以在大多数情况下跳过兄弟姐妹子树的重新计算,但所有被修改的元素的后代总是被重新计算其计算样式。例如,在body元素上的任何改变都会导致整个文档的重新计算。

而性能优化的目标是”能够在手机上以 60FPS 的速度来渲染页面“,这意味着我们每帧有 16ms 的时间来处理 输入、执行脚本,并通过 样式重新计算、构建渲染树、布局、合成、绘制 和 向图形硬件推送变化来执行脚本的渲染管道。因此,样多重新计算只能使用这 16ms 中的一小部分。为了达到这个目标,重新计算所有元素计算样式是不太好的一种方案。

另外,你可以通过存储选择器的集合来最小化重新计算其样式的元素的数量,这些选择器将为每个可能的属性和状态变化改变计算,并为每个至少与这些选择器之一相匹配的元素重新计算计算样式,也子孙集合相比较。

浏览器渲染引擎大约有 50% 的时间用于计算一个元素的计算样式,用于匹配选择器,另外一半时间用于从匹配的规则中构建渲染样式(RenderStyle)。匹配选择器以弄清楚哪些元素需要重新计算样式,然后进行全面匹配,可能也太昂贵了。

为此,渲染引擎决定使用称之为 子代无效集 的东西来存储关于选择器的元数据,并在一个称为 样式无效 的过程中使用这些集合来决定哪些元素需要重新计算它们的计算样式。

子代无效集

子代无效集是这样定义的:

  • 给定一个元素 E 和一个在 E 上被改变的属性值 P 。如果 P 有一个子代无效集 S,并且 F 上有一个属性值是 S 的成员,那么 E 的子代 F 需要重新计算它的样式。P 的例子是类名,ID 属性值和其他属性的名称
  • 一个空的无效集意味着计算样式只需要对属性值被改变的元素进行重新计算
  • 每个无效集都有一个 wholeSubtreeInvalid 布尔值标记,如果所有的后代都需要重新计算它们的计算样式,这个标记就真的
  • 每个无效集都有一个 treeBoundaryCrossing 布尔值标记,如果该无效集 Shadow Tree 中元素无效,这个标记就是真的
  • 每个无效集都有一个 insertionPointCrossing 布尔值标记,如果无效集使分布在内容元素后裔下的元素无效,这个标记就是真的

无效集是通过遍历所有的 CSS 选择器来构建和聚合的。对于每一个选择器,首先从最右边的复合选择器的简单选择器中提取属性,然后将其作为属性添加到通过查看其他复合选择器中的简单选择器找到的属性的无效集。

来自最右边的复合选择器的简单选择器是要被添加到无效集的有趣的属性,因为这些选择器是那些将与从样式规则中获得声明的元素相匹配的。比如:

.a .b { 
    color:green 
}

将有助于 .b 元素的计算样式,而 .a 将只在与选择器匹配。

每个选择器的算法可以:

  • 对于最右边的复合选择器中的每个简单选择器,映射到一个无效集属性 P
    • P 添加到一个临时集合 S
    • 如果没有 P 的后代无效集,为 P 创建一个空的无效集
  • 对于所有其他复合选择器
    • 对于每个映射到无效集属性 P 的简单选择器
      • 如果没有 P 的无效集,为 P 创建一个无效集
      • 如果 S 是空的,或者如果复合选择器右边的组合器是一个相邻组合器,在 P 的无效集上设置 wholeSubtreeInvalid 标记
      • 否则将 S 的所有成员加入到 P 集合中
      • 如果在 P 的简单选择器的右边有一个伪元素(比如 ::beofre::after 等),或者 P 的简单选择器在:host:host-context 伪类中,在 P 集合上设置 insertionPointCrossing 标记
      • 如果在 P 的简单选择器的右侧有一个::shadow/deep/ 组合器,或者 P 的简单选择器在 :host:host-context 伪类中,则在 P 的集合中设置 treeBoundaryCrossing 标记

当你有伪类时,从最右边的复合选择器中提取 P 还有另一种复杂性,它只需要一个复合选择器列表,如 :-webkit-any() 。这些选择器列表是不连贯的,因为只有一个复合选择器需要匹配,才能匹配整个伪类。因此,当 -webkit-any(*, .a) 出现在最右边的复合选择器中时,就无效集而言,它是一个通用选择器。然而,当出现在其他复合选择器中时,所有选择器列表中的P都需要构建其无效集。

在下面的示例中,我们将会使用:

  • a:标签元素(元素选择器)
  • .a:类名(类选择器)
  • #a:ID(ID选择器)
  • [a]:属性名称(属性选择器)
  • * :表示 wholeSubtreeInvalid
  • !:表示 treeBoundaryCrossing
  • !!:表示 insertionPointCrossing
  • P {P0, P1,....,PN}:表示 P 的无效集合

比如:

// 1. 选择器
.a {}
// 无效集
.a {}

// 2. 选择器
.a .b {}
.c {}
// 无效集
.a {.b}
.b {}
.c{}

// 3. 选择器 :not(.b) 没有映射到一个P, 所以最右边的复合体中没有 P,这导致整个 SubtreeInvalid 被设置为 .a 的集合
#x  * {}
#x  .a {}
.a  :not(.b) {}
// 无效集
.a {*}
#x {*, .a}

// 4 选择器(treeBoundaryCrossing 和 insertionPointCrossing)
:host-context(.a) span { }
:host(.b) input[disabled] { }
[type] ::content > .c { }
// 无效集
.a { span, !, !! }
.b { input, [disabled], ! }
.c { }
[type] { .c, !! }

// 5. 选择器(不相关的选择器列表)
.a :-webkit-any(:hover, .b) { }
.c :-webkit-any(.d, .e) { }
:-webkit-any(.f, .g) { }
:-webkit-any(.h, *) #i { }
// 无效集
.a { * }
.b { }
.c { .d, .e }
.d { }
.e { }
.h { #i }

// 6. 选择器(被否定的简单选择器映射到非最右的复合选择器中的属性)
:not(.a):not(.b) > span#id { }
// 无效集
.a { span, #id }
.b { span, #id }

// 7. 选择器(相邻的选择器导致整个子树无效)
.a + .b #id { }
.c ~ span { }
.d ~ [type] div { }
// 无效集
.a { * }
.b { #id }
#id { }
.c { * }
.d { * }
[type] { div }

预设无效性

当我们改变一个类属性、ID属性等时,我们检索该属性的后代无效集,并将其添加到该的预设无效列表中。实际的无效化是异步发生的,并被按排在计算样式重新计算之前发生。

样式无效和样式重新计算

在代码中,有两个通过 DOM 树的过程,被命名为 样式无效样式重新计算。样式无效是对元素子树下的元素应用后裔无效集的过程,并对元素进行样式重新计算。样式重新计算然后遍历 DOM 树,并计算被标记元素的计算样式(RenderStyle 对象)。

在 DOM 树遍历过程中,一个无效集被推到应用无效集的集合上,因为该集合被安排的元素被输入。然后,无效集被用来与子树中的元素匹配它的简单选择器成员,当我们离开它被推送的子树根时,弹出它。设置了treeBoundaryCrossing标志的无效集会被传播到Shadow Tree中。同样地,insertionPointCrossing标志允许无效集跨越<content>元素应用到分布式节点。

<style>
    .a .b { color: green }
    #body #c { color: pink }
</style>
<body>
    <div class=”b”>
        <div id=”t”>
        <div class=”b”></div>
        <div>
            <span id=”c”></span>
        </div>
        </div>
    </div>
</body>
<script>
    // force up-to-date style before applying changes
    document.body.offsetTop; 

    document.body.id = “body”;
    t.classList.addClass(“a”);

    // force recalc of changes
    document.body.offsetTop;
</script>

无效集:

.a { .b }
.b { }
.c { }
#body { #c }

这个例子中, 为 body 元素安排 {#c},为 #t 元素安排 {.b} 。第一个集合将匹配 id="c"<span> 元素,并被标记为重新计算,因为它是 body 子元素。bodydiv 子元素将不会被标记为重新计算,因为第二个集合在我们进入其 id="t"div 子元素之前不会被推送,但 class="b" 的内部 div 会被标记。

由于我们将简单选择器从最右边的复合选择器中分离出来,成为无效集的不同成员,所以类选择器和类、标识的组合可能是次优(sub-optimal)的。考虑选择器.a span.b 。假设你有一个带有大量 span 后裔的 div ,其中只有一个具有 b 类名(class="b"),并在该 div 上设置了 a 类名(class="a")。只有类为 bspan 需要重新计算样式,但由于 .a 的无效设置变成了 {span , .b},我们将为所有这些 span 的了代重新计算计算样式。

相邻组合器仍然非常昂贵。我们可以扩展无效集的概念,增加相邻无效集。有几种方法可以实现这一点。一种方法是通过子孙无效集间接地实现。一个相邻无效集可以包含在相邻组合器链的最右边复合选择器中发现的属性集 P。当一个给定的变化存在这样的无效集时,为相邻无效集的成员安排在被修改元素的兄弟姐妹上的子孙无效集。

// 选择器

.a + .b {}
.c + .d + .e .f {}
.g ~ .h .i + .j {}

// 子孙无效集

.b { }
.e { .f }
.f { }
.h { .j }
.j { }

// 相邻无效集

.a { .b }
.c { .e }
.d { .e }
.g { .h }
.i { .j }

例如,当在一元素上添加或删除g类时,我们可以为 .g 安排一个相邻无效集,这反过来又会为匹配 .h 的兄弟姐妹安排 .h 的子孙无效集。

尽管在标记元素进行样式重新计算时,子孙无效集会给你带来误报,但事实证明,在样式重新计算过程中,它大大减少了受影响的元素数量,从而减少了跳跃(Janks)的发生率和严重性。

CSS 选择器

很多人都忘记了,或者根本没有意识到,CSS 既可以是高性能的,也可以是非高性能的。然而,当你意识到时,你可以使用相关的技巧编写出高性能的 CSS,为你的用户带来更好的用户体验。

CSS 选择器对于 Web 开发者而言并不陌生,比较基本的选择器有 标签选择器(如div)、ID选择器(比如 #header)和 类选择器(比如 .container,除了基本选择器之外,还有一些伪类选择器(比如:hover)和更复杂的选择器,比如[class^="grid-"]。整个 CSS 选择器的分类,如下图所示:

图:CSS选择器分类(来源:https://raw.githubusercontent.com/linxz/blog/gh-pages/img/2021-05/css-selector.png)

虽然有很多新的CSS选择器给我们带来更多方式来匹配元素,但它对于性能(样式计算和选择器匹配)是昂贵的。如果从效率的角度来看,CSS选择器从高(高效率)到低(低效率)的顺序是:

  • ①:ID选择器, 比如 #header
  • ②:类选择器,比如 .header
  • ③:标签(元素)选择器,比如 div
  • ④:相邻兄弟选择器,比如 h2 + p
  • ⑤:子选择器,比如 ul > li
  • ⑥:后代选择器,比如 ul a
  • ⑦:通配符选择器,*
  • ⑧:属性选择器,比如[class^="grid-"]
  • ⑨:伪类选择器(或伪元素),比如a:hovera::before

虽然说 ID 选择器效率最高,但并不是说你有编写 CSS 规则的时候只使用 ID 选择器。具体情况还是需要具体分析。

前面我们说过,浏览器解析 CSS 选择器遵循从右到左的原则。复合选择器中最右边的选择器称为 关键选择器。因此,比如 #header .nav > ul a 选择器,其中 a 标签选择器是关键选择器。就该示例而言,它会找到页面上的所有与 a 选择器相匹配的元素(即,所有 <a>元素),然后再找页面上所有的 ul 元素,并且将 a 向下过滤到只是那些作为 ul 的后代的元素,依此类推,直到到达选择器最左边的选择器 #header 为止。

因此,选择器越短越好。如果可能的话,确保关键选择器是一个类。

测试选择器性能

我们使用 Navigation Timing API来对选择器的性能做测试:

<script type="text/javascript">
    ;(function TimeThisMother() {
        window.onload = function(){
            setTimeout(function(){
            var t = performance.timing;
                alert("CSS 选择器速度: " + (t.loadEventEnd - t.responseEnd) + " ms");
            }, 0);
        };
    })();
</script>

这样可以限制所有资源被接收(responseEnd)和页面被渲染(loadEventEnd)之间的时间。

创建一个简单示例(每个页面测试不同的选择器),他们都有一个相同的、巨大的 DOM,由 1000 个相同的 HTML 代码块组成:

<div class="tagDiv wrap1">
    <div class="tagDiv layer1" data-div="layer1">
        <div class="tagDiv layer2">
            <ul class="tagUl">
                <li class="tagLi"><b class="tagB"><a href="/" class="tagA link" data-select="link">Select</a></b></li>
            </ul>
        </div>
    </div>
</div>

需要测试的选择器有 20 种:

/* 1. Data Attribute (unqualified) */
[data-select] {
    color: red;
}

/* 2. Data Attribute (qualified) */
a[data-select] {
    color: red;
}

/* 3. Data Attribute (unqualified with value)    */
[data-select="link"] {
    color: red;
}

/* 4. Data Attribute (qualified with value) */
a[data-select="link"] {
    color: red;
}

/* 5. Multiple Data Attributes (qualified with values) */
div[data-div="layer1"] a[data-select="link"] {
    color: red;
}

/* 6. Solo Pseudo selector */
a::after {
    content: "after";
    color: red;
}

/* 7. Combined classes */
.tagA.link {
    color: red;
}

/* 8. Multiple classes */
.tagUl .link {
    color: red;
}

/* 9. Multiple classes (using child selector) */
.tagB > .tagA {
    color: red;
}

/* 10. Partial attribute matching */
[class^="wrap"] {
    color: red;
}    

/* 11. Nth-child selector */
.div:nth-of-type(1) a {
    color: red;
}

/* 12. Nth-child selector followed by nth-child selector */

.div:nth-of-type(1) .div:nth-of-type(1) a {
    color: red;
}

/* 13. Insanity selection (unlucky for some) */
div.wrapper > div.tagDiv > div.tagDiv.layer2 > ul.tagUL > li.tagLi > b.tagB > a.TagA.link {
    color: red;
}

/* 14. Slight insanity */
.tagLi .tagB a.TagA.link {
    color: red;
}

/* 15. Universal */
* {
    color: red;
}

/*    16. Element single */

a {
    color: red;
}

/*    17. Element double */
div a {
    color: red;
}

/* 18. Element treble */
div ul a {
    color: red;
}

/* 19. Element treble pseudo */
div ul a::after {
    content: "after";
    color: red;
}

/*    20. Single class */
.link {
    color: red;
}    

20种选择器需要分别在不同的页面中进行测试,比如说我们要测试第一个选择器 [data-select] ,创建一个单的HTML页面:

<!DOCTYPE html>
<html lang="en">

    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>测试选择器速度</title>
    </head>
    <style>
        /* 1. Data Attribute (unqualified) */
        [data-select] {
            color: red;
        }
    </style>

    <body>
        <div class="wrapper">
            <div class="tagDiv wrap1">
                <div class="tagDiv layer1" data-div="layer1">
                    <div class="tagDiv layer2">
                        <ul class="tagUl">
                            <li class="tagLi"><b class="tagB"><a href="/" class="tagA link" data-select="link">Select</a></b>
                            </li>
                        </ul>
                    </div>
                </div>
            </div>
            <!-- 中间省略 998 个 -->
            <div class="tagDiv wrap1000">
                <div class="tagDiv layer1" data-div="layer1">
                    <div class="tagDiv layer2">
                        <ul class="tagUl">
                            <li class="tagLi"><b class="tagB"><a href="/" class="tagA link" data-select="link">Select</a></b>
                            </li>
                        </ul>
                    </div>
                </div>
            </div>
        </div>
        <script type="text/javascript">
            ; (function TimeThisMother() {
                window.onload = function () {
                    setTimeout(function () {
                        var t = performance.timing;
                        alert("CSS 选择器速度: " + (t.loadEventEnd - t.responseEnd) + " ms");
                    }, 0);
                };
            })();
        </script>
    </body>
</html>

测试另一个选择器时,只需要更改上面代码中 <style> 中的 CSS规则。我在 Chrome 浏览器(版本 92.0.4515.131)隐身模式下进行了相应的测试(每个选择器测试五次),结果如下表所示:

从这个测试结果来看,不同选择器的性能相差不大,但这并不能代表是最终的观点。上面测试用例,只是测试了DOM结构完全相同之下,仅选择器不同时,他们的差异。

另外,我采用了另外一种方式,测试了 5000 个 DOM 情况之下,几个常用选择器的速度:

<!DOCTYPE html>
<html lang="en">

    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
        <style>
            body {
                font-family: sans-serif;
            }

            * {
                box-sizing: border-box;
            }

            .btn {
                background: #000;
                display: block;
                appearance: none;
                margin: 20px auto;
                color: #FFF;
                font-size: 24px;
                border: none;
                border-radius: 5px;
                padding: 10px 20px;
            }

            .box-container {
                background: #E0E0E0;
                display: flex;
                flex-wrap: wrap;
            }

            .box {
                background: #FFF;
                padding: 10px;
                width: 25%
            }
        </style>
    </head>

    <body>
        <button class="btn">Measure</button>
        <div class="box-container"></div>
        <script>
            const createFragment = html =>
            document.createRange().createContextualFragment(html);

            const btn = document.querySelector(".btn");
            const container = document.querySelector(".box-container");
            const count = 50000;
            const selectors = [
                "div",
                ".box",
                ".box > .title",
                ".box .title",
                ".box ~ .box",
                ".box + .box",
                ".box:last-of-type",
                ".box:nth-of-type(2n - 1)",
                ".box:not(:last-of-type)",
                ".box:not(:empty):last-of-type .title",
                ".box:nth-last-child(n+6) ~ div",
            ];
            let domString = "";

            const box = count => `
            <div class="box">
                <div class="title">${count}</div>
            </div>`;

            btn.addEventListener("click", () => {
                console.log('-----\n');
                selectors.forEach(selector => {
                    console.time(selector);
                    document.querySelectorAll(selector);
                    console.timeEnd(selector);
                });
            });

            for (let i = 0; i < count; i++) {
                domString += box(i + 1);
            }

            container.append(createFragment(domString));

        </script>
    </body>

</html>

同样在 Chrome 浏览器(版本 92.0.4515.131)隐身模式运行,具体结果如下图所示:

从这个测试中可以看到,为 CSS 选择器的性能花费时间其实是不值得的。只要不过度使用伪类选择器和嵌套层级较深的选择器即可。

编写高效的 CSS 选择器

虽然不同 CSS 选择器在现代浏览器中的运行速度差异很小,但我们在编码的时候还是有一些方式(或)技巧可以让我们编写出更高效的 CSS 选择器。即使这些高效的 CSS 选择器并不能给网站的性能带来很大的变化,但我们也应该尽可能的去避免使用那些低效的 CSS 选择器。

你可以有独立的选择器,比如 #nav ,它将选择 id="nav" 的元素,或者你可以有组合的选择器,比如 #nav a ,它将匹配 id="nav" 元素中的所有 a 元素。

现在,我们从左到右阅读这些内容。我们看到,我们正在寻找 #nav ,然后是里面的任何 a 元素。浏览器并不是这样理解的,浏览器是从右到左的查询。对于浏览器来说,从最右边的元素开始(它知道自己要找的元素),然后沿着 DOM 树往上走,比从 DOM 树的顶点,然后往下走,最终可能不会到达最右边的选择器(也就是我们前面所说的 关键选择器),效率更高。这对 CSS 选择器的性能也有非常大的影响。

如上所述,关键选择器是较大(多层级嵌套)的 CSS 选择器的最右边部分。这也是游览器在匹配选择器时首先要寻找的东西。无论哪种选择器是关键选择器,都会影响选择器的性能(哪怕这种影响很微小)。在编写高效的 CSS 选择器时,关键选择器是性能匹配的关键

比如像这样的一个“关键选择器”(.intro):

#content .intro {}

相对而言,这是一种较好的表现,因为“类选择器” 本身就是一个好的选择器(速度快)。浏览器会查询 DOM 树节点上所有 class="intro" 的节点(可能并不多),然后在 DOM 树上查询匹配的关键选择器是否存在于一个 id="content" 的元素中。

然而,下面这个选择器性相比之下就没有那以高效:

#content * {}

它所做的是查询 DOM 树中的每一个节点,然后查看是否有任何一个元素在 id="content" 父节点上。这是一个非常不高效的选择器,因为关键选择器是一个非常昂贵的选择器。

利用这些知识,我们可以根据选择器的分类做出更好的决定。

假设你有一个拥有很多 DOM 的页面,在这个页面中有上千个 <a> 元素,还有一些用于社交媒体的链接元素 <a> 放在id=“social”<ul> 中。比如有一个 Twitter、一个Facebook、一个 Dribbble和一个Google+的社交媒体链接,此外还有数百其他的 a 链接。

因此,下面这个选择器不是一个高效的选择器:

#social a {}

上面示例中的选择器,浏览器会查询该页面上的所有<a> 元素(可能有上千个),然后再确定 id="social" 元素中的四个<a> 元素。关键选择器匹配了太多其他 DOM 节点,而很多 DOM 节点和要匹配的选择器没有任何的关联。想象一下,在大范围中找几个人容易还是在小范围内容易。

为了解决这个问题,我们可以在社交媒体的几个 <a> 元素上添加一个更具体,更明确的选择器,比如 .social-link 。但这又违背了我们的认知,“当我们可以使用更精简的标记(HTML)时,不要在元素上添加不必要的类”。并且一直以来,我们都是受到这样的教育:

在编写模板结构(HTML文档)时,能不用的标记和相关属性则不用!

事实上这一点和性能优化点也是相契合的,因为用于文档的字符数越少(结构更精简,字符就更少),文件体积就更少,同时需要计算的时间就少,造成重排,重绘机率就会减少,所以性能就会更好。

如此一来,同样都是为了优化性能,但又同时产生了世纪之争:

  • A说:HTML结构越精减,性能越好
  • B说:添加类,关键选择器匹配更快,性能越好

这也是性能优化有趣的一面,其实这也是 Web 标准最佳实践和纯粹的速度(性能)之间的一个平衡!简单地说,这种之争,没有最好,只有最适合!

就这样的场景而言,我们通常会有:

<head>
    <style>
        /* 关键选择器匹配费时 */
        #social a {}
    </style>
</head>
<body>
    <!-- HTML 更精简 -->
    <ul id="social">
        <li><a href="#" class="twitter">Twitter</a></li>
        <li><a href="#" class="facebook">Facebook</a></li>
        <li><a href="#" class="dribble">Dribbble</a></li>
        <li><a href="#" class="gplus">Google+</a></li>
    </ul>
</body>  

为了提高关键选择器的匹配效率,我们会这样写:

<head>
    <style>
        /* 关键选择器匹配更高效 */
        #social .social-link {}
    </style>
</head>
<body>
    <!-- HTML 更臃肿 -->
    <ul id="social">
        <li><a href="#" class="social-link twitter">Twitter</a></li>
        <li><a href="#" class="social-link facebook">Facebook</a></li>
        <li><a href="#" class="social-link dribble">Dribbble</a></li>
        <li><a href="#" class="social-link gplus">Google+</a></li>
    </ul>
</body>  

使用 #social .social-link 选择器的话,关键选择器就是 .social-link ,该选择器匹配的元素就要少得多了,这就意味着浏览器可以更快地找到它们,并对它们进行样式化处理,从而可以继续处理后面的事情。

而且,如果我们对页面了解的更多,在知道别的地方可能不会使用 social-link 类名,那我们在使用 CSS 选择器选中这几个社交媒体的 <a> 元素时,可以直接使用.social-link{},这样一来,我们就避免过度使用限定的选择器。

好了,现在我们知道了什么是 关键选择器,也知道了大部分的工作来自于此,我们就可以进一步优化了。拥有明确的关键选择器的最好的事情是:

尽可能的避免过度限定的选择器!

一个过度限定的选择器可以看起来像下面这个选择器:

html body .wrapper #content a {}

从上面的示例代码可以看出,所谓的过度限定选择器指的就是我们编写的选择器嵌套层级过深(这种现象常常出现在CSS处理器编码过程中,编码过程中无限制的嵌套)。就该示例而言,其中至少有三个选择器是完全没必要的。顶多可以是这样:

#content a {}

这两条CSS选择器相比,且按照浏览器查询选择器是从右往左的原则,意味着:

  • 第一条选择器(html body .wrapper #content a {}),浏览器必须查询页面上所有 a 元素,然后查询它们是否在一个 id="content" 的元素内,再查询 #content 元素是否在一个 class="wrapper" 中,依此类推,一直查询到选择器最左边的 html 。这使浏览器需要做很多查询工作。
  • 第二条选择器(#content a {}),虽然浏览器同样需要查询页面上所有 a 元素,但查询它们是否在一个 id="content" 的元素内就结束了,相比第一条选择器,浏览器减少了三个环节的查询(class="wrapper"bodyhtml)。这使浏览器需要查询的工作就少得多了。

了解了这一点,我们就可以像下面这样编写 CSS 选择器:

<style>
    /* Bad */
    #social li a {}

    /* Good */
    #social a {}
</style>

<ul id="social">
    <li><a href="#">Twitter</a></li>
    <li><a href="#">Facebook</a></li>
    <li><a href="#">Dribbble</a></li>
    <li><a href="#">Google+</a></li>
</ul>

就上面示例而言,我们知道 <a><li> 里面,而<li> 对在 <ul id="social"> 中,我们可以直接使用 #social a 选择器(加上 id 在一个页面中是具有唯一性的)。如果你的 HTML 结构变成下面这样,#social a 同样是一个较佳的选择器:

<nav  id="social">
    <ul>
        <li><a href="#">Twitter</a></li>
        <li><a href="#">Facebook</a></li>
        <li><a href="#">Dribbble</a></li>
        <li><a href="#">Google+</a></li>
    </ul>
</nav>  

这个简单的示例告诉我们:过度限定的选择器(即层级嵌套过深选择器)使浏览器工作变得更加困难,匹配选择器所费时间更多,因此,通过削减选择器中不必要的部分,使你的选择器更精减(即,减少选择器嵌套层级),选择器会更高效。

即使如此,#social a 削减了过度限定选择器的使用,但也并不是最高效的选择器,具体原因前面已经分析过了,这里不再阐述。

另外,在我们实际编码的时候,我们可以使用一些工具,比如 StyleLint来帮助我们尽可能的避免过度限定选择器的使用。特别是喜欢使用嵌套编码的同学,我们的层级嵌套不应该超过三层。除此之外,我们还可以使用一些 CSS 的方法论,比如,BEMSMACSSAMCSS或者CSS Modules、Scoped CSS 或者 Functional CSS(比如,TailwidCSS) 等,这些方法论都可以有效的削减过度限定选择器的使用。除此之外,还可以考虑使用 CSS Blocks,因为她具有 CSS Modules、BEM和 Atomic CSS(即 Functional CSS)众多优点于一身。比如其官网提供的一个示例代码:

不知道你是否有留意过自己在编写 CSS 选择器,有的时候会在选择器中加上标签元素,比如:

nav#social { }

如果你有这样的习惯,或者曾经这样编码。那么请从今天开始,永远不要这样做

众所周之,ID 在页面中是具有唯一性的,所以不需要一个标签来配合它。因为这样做会降低选择器的效率。如果你能避免的话,也不要在类名前加上标签,比如 a.social-link (这样也不是一个好的习惯)。虽然类(class)选择器不像 ID 选择器,具有唯一性,但是从理论上讲,可以让一个类名做一些对多个不同元素都有用的事情。如果你想让样式根据元素的不同而不同,你可能需要进行标签限定(例如 a.social-linkbutton.social-link),但这种现象相对而言是较少的。因此,一般情况来说,也不要在类选择器前用标签来限定

/* Bad */
nav#social {}

a.social-link {}

/* Good */
#social {}

.social-link {}

继承 是 CSS 的一个基本特性,在 CSS 中有很多属性是具有可继承的特性(前面我们介绍过),那么我们在编写 CSS 的时候应该尽可能的利用该特性,因为它能减少浏览器计算样式的时间,从这个角度来说,也是有利于性能的优化。比如说:

#nav  li a { 
    font-family: Georgia, Serif; 
}

上面代码中 font-family 是一个可继承的属性,所以你没有必要用这么一个具体的选择器(你要做的只是在需要改变字体的地方重新设置)。这样做同样有效,而且更有效率:

#nav {
    font-family: Georgia, Serif; 
}

就这一点而言,我们很多同学大多数的时候都会忽略这一点的。比如下面这样的一个 UI 界面中的文本颜色 :

从源码中,我们可以看到:

.rkCe5 {
    color: #000000;
}

.r-eZO {
    color: #000000;
}

.r9qHm {
    color: #ff0049;
}

.rBIha {
    color: #ff0049;
}

.rbfb2 {
    color: #ffffff;
}

.r9iMF {
    color: rgba(51, 51, 51, 0.46);
}

.r2iJD {
    color: rgba(51, 51, 51, 0.46);
}

示例中 color: #000000color: #ff0049color: rgba(51, 51, 51, 0.46) 都被使用过两次,而 color 同样也是可被继承的属性,如果利用其可被继承的特性,我们的代码完全可以像下面这样来编写:

.rx3UE {
    color: #000;
}

.rCUP0 {
    color: #ff0049;
}

.rbfb2 {
    color: #fff;
}

.r_hNc {
    color: rgba(51, 51, 51, 0.46);
}

我想,你已经发现这两者之间的差距了吧。

随着 CSS 技术不断的革新,最近几年 CSS 选择器的发展也是非常的快速,CSS 新增了很多选择器,特别是伪类选择器,这些选择器可以帮助我们更好的定位我们想要的元素,同时保持我们 HTML 干净,比如:

li:not(:last-child) {}

li:nth-child(2n+1):last-nth-child(2n) {}

但是,从选择器性能测试的结果中,我们也发现了,这些伪类选择器(花哨的选择器)在使用时更耗费浏览器资源 。如果你更关注页面的性能,就根不应该使用它们。那么我们是否真的不应该使用它们?我个人认为还是应该考虑实际情况。尽可能避免复杂的伪类选择器以及属性选择器的使用

简单地小结一下:

  • 从效率上来讲,ID 选择器最快,其次是 类选择器、标签选择器、相邻兄弟选择器、子选择器、后代选择器、通配符选择器、属性选择器、伪类选择器。
    • 虽然 ID 选择器效率最高,但并不意味就全文只使用 ID 选择器,这将是非常荒缪的。毕竟 ID 选择器是唯一性的,如果只使用 ID 选择器,无形之中让你的代码变得难维护、难阅读。也就是说,不要为了高效的CSS而牺牲语义和可维护性,这也违背了Web开发最佳实践(一般不在页面中使用ID选择器)
    • ID选择器和类选择器效率其实非常的接近,我们使用类选择器,并不会比使用ID选择器更耗费浏览器资源
    • 属性选择器和伪类选择器是效率最低的两类选择器,特别是多个伪类选择器或属性选择器组合在一起使用,更可能耗费浏览器资源。我们在使用的时候尽可能避免复杂的、组合的伪类选择器或属性选择器。虽然他们给我们定位元素带来极大的便利性,也应该能不用之时就不用
  • 浏览器匹配选择器是从右向左,其中最右侧的选择器是关键选择器
    • 关键选择器应该尽可能的使用效率最高的选择器,比如类选择器
    • 尽可能的避免使用过度限定选择器,即选择器层级不宜过多,因为层级越多,浏览器查询所费时间就越多,效率就越低
    • 可以使用 StyleLint 工具帮助我们削减过度限定选择器的使用,即选择器嵌套层级(从右到左的分级)不超过三级,特别是在 CSS 处理器编码过程中,CSS代码块的嵌套不要超过三级
  • 在编码过程中可以借助 CSS 的方法论,比如 BEM、Atomic CSS、CSS Module 和 CSS Blocks,这些方法论可以:
    • 你的选择器尽可能使用类选择器,即使用了效率较高的选择器
    • 有效帮助你削减过度使用限定选择器,甚至让你避免选择器的嵌套使用
    • 让你的关键选择器也是最高效的选择器
    • 让你的代码变得更具维护性
  • 在使用选择器时,应该尽可能的将材标签选择器和ID选择器或类选择器组合在一起使用,比如ul#navli.link
  • 有效的利用 CSS 级联、继承等特性,尽可能的减少 CSS 样式规则的使用,从而尽可能的减少样式的计算
  • CSS选择器也可能被用于 JavaScript中,许多JavaScript库中就有使用,这些同样的概念也适用。ID选择器将是最快的,而复杂的限定后代选择器之类的则会慢一些
  • 在工程链路中有效使用相关工具,比如 StyleLint、UnCSS 和 Analyze-css ,尽可能避免人为造成的错误
    • StyeLint: 根据配置的相关规则来监控你的CSS代码,比如,避免选择器层级嵌套过深,就可以通过配置 max-nesting-depth值为3,另外内置了 170 多个规则来捕捉错误,应用限制和强制执行你的样式风格等
    • Analyze-css:是一款 CSS 选择器的复杂性和性能分析器。Analyze-css 被构建为一组与CSS解析器触发的事件绑定的规则,每条规则都可以生成度量指标,可以生成带有详细信息的报告,告诉你哪些规则违规了
    • UnCSS:移除未使用的 CSS 规则
    • cssnano:是一款现代的模块化的压缩工具,允许我们使用许多强大的特性来适当的压缩 CSS,比如postcss-merge-rules合并CSS样式规则、CSS选择器等。同类的插件还有 postcss-csso(一个具有结构化的CSS压缩器,在内部,插件将 PostCSS AST 转换为 CSSO 的 AST,并对其进行优化和转换。postcss-csso的性能与 CSSO 本身的性能大致相同)

对于现代浏览器来说,选择器的优化相对来说是徒劳的,大多数选择器速度都很快,不值得花太多时间去进行优化和研究。此外,不同的浏览器中,最慢的选择器是什么也不一样。从性能上讲,过多未使用的样式可能比你选择的任何选择器都要花费更多的时间。另外,要想获得高性能 CSS,不是靠 CSS 选择器,而是靠对属性的合理使用。

那么,我们怎么知道什么是昂贵的样式呢?值得庆幸的是,我们可以运用常识来判断什么是对浏览器造成影响的。任何需要浏览器在绘制页面之前进行操作、计算的东西都会比较昂贵,例如 box-shadowborder-radiusopacitytransformfilter 等。

返回顶部