浏览器为什么从右往左匹配 CSS 选择器?选择器性能应该怎么看?
浏览器计算样式时是拿着一个元素去问“哪些规则命中它”,选择器定义里元素要先命中最右边那段复合选择器,再沿父级或兄弟往左验证,所以从右往左最省事,大多数规则在最右一段就被排除。Blink 还按最右一段的 id、类名、标签把规则分桶,并用祖先过滤器提前丢掉不可能命中的后代规则。选择器性能一般不是首要瓶颈,真要优化先用 DevTools 的 Selector Stats 找出耗时的选择器,再减少被匹配的元素数量和复杂度。
简答
比如
.nav li a,人是从左往右读的:先找.nav,再找里面的li,再找a。浏览器算样式时的问题却是反过来的:手里拿着一个元素,要知道哪些规则命中它。所以它先看这个元素是不是a,是的话再往上找祖先里有没有li、有没有.nav。这样做省事,是因为选择器规范本来就这样定义:元素先要命中最右边那一段,前面每一段再按组合符的关系去命中它的祖先或兄弟。页面上大多数规则在最右一段就对不上,直接排除,不用往上走。如果从左往右,找到
.nav以后还要遍历它的整棵子树,大多数分支最后都白找了。Blink 在这之上还做了两层优化:按最右一段的 id、类名、标签把规则分桶,一个元素只需要看和自己相关的那几个桶;再用一个类似布隆过滤器的祖先过滤器,提前丢掉“要求某个祖先、但祖先里根本没有”的规则。
所以选择器写法通常不是性能的首要问题。真要优化,先在 DevTools 的 Performance 面板打开 Selector Stats,找出耗时长、尝试次数多但命中少的选择器,再改写。常见方向是少用
body *这种命中大量元素的写法,:has()的锚点和内部选择器都要尽量收窄。
规范是怎样定义“命中”的
Selectors Level 4 说,选择器是一个判断式:给它一个元素,回答这个元素命中还是不命中。对于 .nav li a 这种由组合符连起来的复杂选择器,规范的定义是:元素要命中最后那一段复合选择器,并且前面每一段也分别命中一个元素,这些元素之间满足组合符要求的关系。
这个定义天然是从右往左的。拿到一个元素,先拿最右边的 a 去比;比上了,再看空格这个后代组合符,往祖先里找 li;找到了,再往更上面找 .nav。最右边那一段在规范里叫“主体”(subject),就是这条选择器最终要选中的元素。
为什么从右往左更快
样式计算的工作是给页面上每个元素找到所有命中的规则。假设页面有几千条规则,对每个元素都要回答“这几千条里哪些命中我”。
从右往左时,大部分规则在第一步就被排除了。一个 <span> 去比 .nav li a,最右边是 a,对不上,这条规则就结束了,不需要往上走。只有最右一段对上了,才需要沿着祖先链往上查,祖先链的长度也就是 DOM 的深度。
反过来从左往右,找到一个 .nav 以后,要往下遍历它的所有后代找 li,再在每个 li 下面找 a。可是大多数路径最后找不到 a,这些遍历都白做了。
Blink 在此之上做了哪些优化
Chromium 的 Blink 引擎在源码注释里写了它的做法。第一层是分桶:RuleSet 把规则按 id、类名、标签、属性等分组存放,用的是最右一段里的这些值,比如 .foo[baz] 会按类名 foo 和属性 baz 来分组;没法归类的规则放进“通用”组。这样算一个元素的样式时,只需要看它自己的 id、类名、标签对应的那几组,加上通用组。
第二层是祖先过滤器 SelectorFilter。遍历 DOM 时,Blink 把一路上每个祖先的 id、标签、类名、属性做哈希存进一个位集合,相当于只有一个哈希函数的布隆过滤器。比如规则是 article #b,而当前元素的祖先里根本没有 <article>,查一下过滤器就能确定这条规则不可能命中,直接丢掉。过滤器有误报,通过的规则还要正常检查,但不会漏掉该命中的规则。源码注释提到,按 2022 年实际网页的统计,它能在早期丢掉 60% 到 70% 的规则。
另外 Blink 存储选择器时,组合符连接的各段也是按从右往左的顺序排的,.a.b #c 存下来时 #c 在前、.a.b 在后,匹配时顺着这个顺序往下走就行。
选择器性能应该怎么看
有了分桶和祖先过滤器,大部分选择器的差别很小,不必凭感觉去改写。真正需要关注的是两类情况。
一类是命中的元素太多。MDN 的例子是 body * { font-size: 14px; display: flex; },它让 body 里每个元素都参与这条规则,而 font-size 这类属性本来就会继承,不需要每个元素都写一遍。MDN 也建议能写 .headline 就不要写 body div#main-content article.post h2.headline,后者解析和维护成本都更高。
另一类是 DOM 变化时需要大范围重新检查的选择器,典型的是 :has()。MDN 提到,body:has(.sidebar) 这种锚点太宽的写法,页面里任何变化都要重新检查;.ancestor:has(.foo) 内部不加约束,可能要遍历整棵子树,改成 :has(> .foo) 能缩小范围。:has() 的用法见《:has() 怎样根据后代或相邻元素设置样式?》。
要找出哪些选择器真的慢,用 Chromium 内核浏览器的 DevTools。在 Performance 面板的设置里勾选 “Enable CSS selector stats (slow)”,录制后点一个 Recalculate Style 事件,下面的 Selector Stats 表格会列出每个选择器的耗时(Elapsed)、尝试匹配的元素数(Match Attempts)、实际命中数(Match Count)和走慢路径的未命中比例。微软 Edge 文档的建议是,重点看耗时长、尝试次数多、命中却很少、慢路径比例高的选择器。改完以后再录一次,确认 Recalculate Style 的时间确实降了。
常见追问
ID 选择器一定比类选择器快吗?
从 Blink 的实现看,id 和类名都能用来分桶,元素只会去比和自己 id、类名对应的那几组规则,两者都能很快排除无关规则。实际页面里,选择器之间的这点差别通常比不上“命中元素太多”或“DOM 频繁变化引起大范围重算”带来的影响,所以应该用 Selector Stats 实测,而不是按选择器类型下结论。
样式重算一般在什么时候发生?
规则的适用范围可能变化时就要重算,比如增删 DOM 元素、修改
class或id属性、鼠标移动或焦点变化影响了:hover这类状态。重算之后如果几何信息变了,还会接着触发布局,见《重排和重绘有什么区别?哪些操作会触发它们?》。