CSS 选择器优先级怎么算:为什么权重看起来更高,样式却没生效

.page .toolbar .button.primary 这种选择器,看起来比 .button 精确得多,权重也该更高。但把它加进样式表之后,按钮的背景色依旧是旧的那个——浏览器没有报错,样式规则也确实写进了 CSS 文件,只是没有生效。打开 DevTools 检查,能看到这条新规则确实出现在了元素的样式列表里,属性名和属性值都没写错,甚至连选择器本身也确实命中了目标元素,但整条声明前面被划上了一道删除线,说明它被别的规则盖过去了。这种"选择器看起来更具体,却压不过原来的规则"的情况,几乎都能归因到优先级计算规则本身没吃透,而不是哪个属性写错了。

优先级不是"谁写得晚谁生效",也不是"谁的选择器字符串更长谁赢",它有一套固定的计算方式:按类型分层比较,同一层内数量多的赢,层与层之间不进位、不叠加。把这套规则讲清楚,很多"明明改了样式却没效果"的问题能直接定位。

优先级的计算方式

CSS 把选择器分成三个等级,从高到低依次是:ID、类(包括类、属性选择器、伪类)、标签(包括伪元素)。计算优先级时,先数每个等级里出现了几个,再逐级比较,等级高的哪怕只有一个,也压得过等级低的一大串。

1#app .toolbar .button {}      /* 1 个 ID,2 个类 */
2.page .toolbar .button.primary {}  /* 0 个 ID,4 个类 */

第二条选择器类的数量比第一条多,但因为第一条有一个 ID,直接在最高等级上就分出胜负,第二条再怎么堆类名也追不上。这就是文章开头那种"权重看起来更高却不生效"最常见的原因——写的人只看见选择器字符串变长了,没意识到对手的选择器里藏着一个 ID。

标签选择器和通配符 * 的权重最低,几乎总是被类和 ID 压制。* 严格来说权重是零,不管它出现在选择器的哪个位置,都不参与比较,比如 * .button.button 权重完全一样,通配符纯粹是为了扩大匹配范围,不会给选择器加分。这一点在写重置样式的时候容易被误解,有人以为 * 加了一层选择器就该多算一点权重,实际上加了也是白加。

伪元素(::before::after)算在标签这一级,伪类(:hover:first-child:not())算在类这一级。:not() 本身不计权重,但括号里的选择器要计入,比如 .item:not(.disabled) 是两个类的权重,不是一个。这个规则在 :not() 嵌套复杂条件时特别容易被漏算,比如 .list-item:not(.disabled):not(:last-child).list-item 本身占一个类,两个 :not() 各带一个参数(.disabled:last-child)再各算一个类,三部分加起来是 (0, 3, 0),而不是只数两个 :not() 得出的 (0, 2, 0)——外层那个类名同样要算进总数,不能因为它不在 :not() 里就漏掉。

为了把这套三级权重讲得更具体,可以用一个标准的记法:把 ID、类、标签的数量依次写成三位数字,比如 (1, 2, 0) 表示 1 个 ID、2 个类、0 个标签。带着这个记法把优先级完整地手算一遍:

1a { color: #333; }                          /* (0, 0, 1) 标签 */
2.nav a { color: #1677ff; }                  /* (0, 1, 1) 一个类 + 一个标签 */
3.nav .nav-item a { color: #1677ff; }        /* (0, 2, 1) 两个类 + 一个标签 */
4#nav a { color: red; }                      /* (1, 0, 1) 一个 ID + 一个标签 */
5a[href^="http"] { color: green; }           /* (0, 1, 1) 属性选择器算一个类 */
6a:hover { color: orange; }                  /* (0, 1, 1) 伪类算一个类 */

比较的时候永远从最高位开始看,ID 那一位只要有差异,后面两位不用再看。#nav a(1, 0, 1).nav .nav-item a(0, 2, 1),第一位 1 比 0 大,#nav a 直接赢,哪怕它标签那一位跟对方相等、类那一位是 0 比 2 处于绝对劣势也没用。只有当 ID 那一位打平,才轮到比较类那一位;类那一位也打平,才轮到标签那一位。这三个数字不会进位也不会换算——(0, 10, 0)(十个类叠加)依然干不过 (1, 0, 0)(一个 ID),不存在"类堆够了就能追上 ID"这种事。

优先级相同时才轮到"后写的覆盖先写的"这条规则,也就是层叠顺序,CSS 规范里管这个叫层叠(cascade)。层叠顺序考虑的其实不止"写的先后",还包括样式来源(用户代理默认样式、用户自定义样式、作者样式表)和 !important 的介入,但业务代码里几乎只会遇到"同为作者样式表、优先级完全相同"这一种情况,这时候才是纯粹按源码顺序比,位置在后面的 <link> 或者写在后面的规则块生效。很多人把"后写的覆盖先写的"当成第一原则去用,遇到覆盖不了的情况就往后面继续加规则,结果越加越长,其实问题从一开始就出在等级判断上,不是顺序问题——如果两条规则的三位数字有任何一位不相等,顺序根本插不上手。

ID 选择器为什么在业务样式里几乎绝迹

前面算过好几次,ID 选择器权重高到只要出现,类和标签怎么堆都追不上,这句话反过来看就是它的坏处:一旦某个页面里用 ID 写了样式,以后所有想覆盖这条规则的人都得跟着用 ID 或者 !important,没有第三条路。团队里现在的约定是业务样式一律不用 ID 选择器,id 属性留给 JS 逻辑或者锚点跳转用,样式一律走类名。

这条约定不是凭空定的,是踩过实际的坑。之前接手过一个页面,表单校验的错误提示样式写在 #login-form .error-message 上,后来这个表单被复用到另一个场景,需要在新场景里把错误提示的颜色改浅一点,新写的 .new-scene .error-message 无论怎么加类名都盖不掉原来的 ID 选择器,最后只能也用 ID 或者干脆把原来那条规则里的 ID 去掉换成类名,相当于回过头去重构别人的历史代码,比一开始就不用 ID 多出一轮返工。

算一下权重就能看出这个坑有多难绕:#login-form .error-message(1, 1, 0).new-scene .error-message(0, 2, 0),第一位 ID 数量上 1 比 0,直接决定胜负,不管第二个选择器往上叠多少个类都追不上,除非也用一个 ID 或者用 !important。这也是为什么排查这类问题时,只要发现对方选择器里带了 ID,基本可以放弃"叠类名去覆盖"这条思路,直接去改动或者移除那条 ID 规则本身,比继续加自己这边的类名要现实得多。

有一种误解是"ID 选择器性能更好,因为浏览器查找 ID 比查找类名快",这个说法在当年的 CSS 引擎设计里可能有一点依据,但现代浏览器渲染引擎对选择器匹配做了大量优化,实际业务代码的规模下,ID 和类选择器的匹配性能差异小到可以忽略,不值得为了这点微小的性能差异去承担"一旦用了 ID 就再也没法用普通类覆盖"的代价。选择器该用什么类型,权衡的核心应该是"以后要不要覆盖它""覆盖的成本能不能接受",而不是匹配速度。

还有一种常见的场景是把 id 属性同时当成 JS 钩子和样式选择器用,比如 #submit-btn 既在 JS 里被 document.getElementById 取到,又在 CSS 里被拿来写按钮的背景色。这种写法看着省事,实际上把两个完全不同的关注点绑在了一起:以后如果要改样式命中范围,得先确认这个 ID 有没有被 JS 逻辑依赖,改错了容易连带把点击事件绑定搞坏;反过来如果 JS 逻辑要换一种查找元素的方式(比如换成 ref 或者 data-testid),又得回头检查样式是不是还依赖着这个 ID。团队里现在的做法是给 JS 钩子专门加前缀区分,比如 js-submit-btn 或者用 data-* 属性承载,样式一律走独立的类名,两者不共用同一个选择器,出问题的时候至少能一眼看出改动会不会影响到另一侧。

这条约定推广起来比想象中顺利,因为它不需要额外的构建工具支持,纯粹是命名习惯上的改变,写代码的时候多敲几个字符而已,成本几乎可以忽略,收益却是长期的——以后不管是重构样式还是重构 JS 逻辑,都不用担心动了一处会连带影响到看起来毫不相关的另一处。

自然层叠顺序和特殊性不是一回事

排查"权重看起来更高却没生效"这类问题时,容易把两件事混在一起:一个是选择器的特殊性(specificity),也就是前面讲的 ID/类/标签三级权重;另一个是样式表本身在文档里的自然层叠顺序,包括 <link> 标签的先后、<style> 块的先后、以及同一个文件内规则的先后。这两者的关系是:先比特殊性,特殊性打平了才轮到自然顺序,特殊性没打平,顺序怎么调都没用。

之前调试过一个典型的反例:项目里全局样式表 base.css 和组件库样式表 element-ui.css 都在 index.html 里引入,base.css 写在前面,element-ui.css 写在后面。有同事发现自己在 base.css 里写的 .el-button { border-radius: 2px; } 一直不生效,第一反应是"把 base.css<link> 挪到 element-ui.css 后面",挪完确实生效了,但这只是误打误撞——因为组件库内部那条规则原本是 .el-button.el-button--default,两个类的权重比业务代码那条单类选择器高,就算把 base.css 挪到最后,遇到组件库里权重更高的规则依然压不住,之前能生效纯粹是运气好,凑巧组件库那部分用的也是单类选择器。这类"调整引入顺序来试图解决覆盖问题"的做法风险很大,因为它依赖的前提(两条规则特殊性相同)自己不一定意识到,一旦哪天组件库升级、内部选择器权重变了,覆盖关系又会跟着变化,而且没有任何征兆。

媒体查询(@media)和层叠顺序也有关系,很多人以为写在 @media 里的规则天然优先级更高,其实不是,@media 只是一个条件包裹,包裹在里面的选择器特殊性跟外面完全一样地计算:

1.button { color: #1677ff; }
2
3@media (max-width: 768px) {
4  .button { color: #333; }
5}

这两条规则特殊性相同(都是一个类),移动端下能生效是因为它在源码顺序里排在后面,而不是因为它被包在 @media 里就自动获得了更高优先级。如果反过来把移动端的规则写在前面、桌面端规则写在后面,即便媒体查询的条件仍然只在小屏幕下成立,只要两条规则的媒体查询条件在某个视口宽度下同时满足,谁在后面谁赢,这时候提前判断清楚"这两个规则会不会同时命中同一个视口"就很重要,不然容易出现"改了媒体查询里的样式却没变化"的情况,然后误以为是特殊性问题,其实是顺序问题——这跟前面说的反过来,同样是两个维度容易被混着排查。

组合选择器不是选择器本身升级

后代选择器和子选择器是两回事:

1.card p {}
2.card > p {}

前者选中 .card 内部所有层级的 p,后者只选中直接子元素。这两种写法命中范围差得很远,但在优先级计算上,二者的权重是一样的——组合符(空格、>+~)本身不参与权重计算,只有选择器序列里的 ID、类、标签数量才算数。这一点容易被忽略:有人以为子选择器"更精确"所以优先级也更高,其实优先级只看类型和数量,不看结构关系。

之前维护的一个页面里有这样一条规则:

1.page .card p {
2  margin: 0;
3}

当初写它是为了压掉卡片内段落的默认外边距,后来卡片里塞进了富文本内容,富文本渲染出来的所有 p 标签全被这条规则影响,段落间距全部塌陷。排查这个问题的时候第一反应是查权重——会不会是富文本内部又有一条更高权重的规则把间距重新撑开了,结果没有覆盖成功。打开 DevTools 才发现完全不是权重问题,.page .card p 权重是 (0, 2, 1),比富文本内容自身的默认样式(大多是浏览器 UA 样式表提供的 p { margin: 1em 0; },权重只有 (0, 0, 1))高得多,所以这条规则本来就稳赢,"没生效"从来不是它的问题,反而是它管得太宽——它把不该管的 p 标签也管住了。选择器命中范围太大,跟优先级无关,是语义设计的问题——如果只想控制某一处文本,应该给目标元素一个明确的类名,而不是靠结构层级去框定范围:

1.card > .card-description {
2  margin: 0;
3}

这个案例后来在内部技术分享里当反例讲过:排查样式问题时,"没生效"和"生效范围不对"是两类完全不同的原因,前者要查权重和层叠顺序,后者要查选择器本身命中了哪些元素,两者搞混了会往错误的方向排查很久。

并列选择器(逗号分隔)和多类选择器也不是一回事。.title.highlight 表示同一个元素同时具备两个类,权重是两个类叠加;.title, .highlight 是两条独立规则各自计算权重,互不影响。团队里状态类的写法习惯用叠加式,比如 .button.is-loading.card.is-selected,表达"同一个组件处于某种状态";共享样式的场景用逗号分组,比如 .empty-title, .error-title { font-weight: 600; }。这两种语义不一样,权重的计算逻辑也该分开想清楚,不能混着用。团队里 code review 时如果看到用逗号分组去表达"同一元素多状态叠加",或者反过来用多类叠加去表达"两个毫不相关元素共享一段样式",都会被指出来改掉,倒不是这样写会出错误的结果,而是权重计算的心智模型会因此变得含糊——别人读到这条规则时无法一眼判断这是"叠加"还是"共享",得回头查一下上下文才能确认,长期下来读代码的成本比写代码的成本还高。

兄弟选择器 +(相邻兄弟)和 ~(通用兄弟)在权重计算上跟后代选择器、子选择器完全一样的规则——组合符不计权重,只算序列里的类型和数量:

1.form-item + .form-item {}   /* (0, 2, 0) */
2.form-item ~ .form-item {}   /* (0, 2, 0) 权重跟上面相同 */

这两条规则命中的元素范围不一样(前者只选紧挨着的下一个兄弟,后者选后面所有同级兄弟),但权重完全一样,因为组合符本身从不参与计算。这条规则跟前面讲过的后代选择器和子选择器是同一个道理,放在一起看会更容易记住:决定优先级的永远只是选择器序列里出现了几个 ID、几个类、几个标签,组合符只决定命中范围,不参与权重计算,不管这个组合符是空格、>+还是 ~

属性选择器是另一个容易被低估权重的地方。[data-status="active"][disabled]a[href^="http"] 这类写法看起来像是在"匹配 DOM 属性",跟类选择器长得不一样,很容易让人觉得它权重更低或者干脆不参与计算,但规范里属性选择器和类选择器是同一等级,一个属性选择器就是一个类的权重:

1.button {}                 /* (0, 1, 0) */
2[data-variant="primary"] {} /* (0, 1, 0) 权重完全一样 */
3.button[data-variant="primary"] {} /* (0, 2, 0) 叠加 */

之前排查过一个案例:某个组件库导出的按钮组件默认样式写的是 button[type="submit"],业务代码想用一个简单的类 .btn-cancel 去覆盖它的背景色,怎么都压不掉。当时以为属性选择器"没那么正式",权重应该比类选择器低,实际上二者同级,button[type="submit"] 是一个标签加一个类的权重 (0, 1, 1),比单独的 .btn-cancel(0, 1, 0))还高出标签那一级,业务类选择器天然处于劣势,只能再加一层类名或者给目标元素一个更具体的类才能压过去。

用 DevTools 定位权重问题,不要靠肉眼数

选择器权重手算几层还行,真实项目里的选择器动辄五六层,混杂着类、属性选择器、伪类,靠肉眼数很容易数错,尤其是 Vue scoped 编译之后每条规则都多了一层看不见的属性选择器,源码里看着简单,编译产物里权重已经悄悄升级了。Chrome DevTools 的 Elements 面板里,选中元素之后右侧的 Styles 面板会把所有命中当前元素的规则按权重从高到低列出来,被更高权重覆盖掉的声明会加删除线,这是排查"为什么这条样式没生效"最直接的手段,比在样式表里翻半天要快得多。

具体排查的时候,通常先在 Styles 面板里确认这条声明是不是压根没出现在列表里——如果没出现,说明选择器本身没命中这个元素,问题出在选择器写错了,跟权重无关;如果出现了但被划了删除线,才是权重或者顺序的问题,这时候把鼠标移到划线声明的选择器上,DevTools 会在旁边标出这条规则来自哪个文件的第几行,方便直接跳过去看是谁在跟它抢权重。

之前排查一个 Vue 项目里的样式覆盖问题,用的就是这个办法:业务组件里写了 .form-item .el-input__inner { border-color: #d9d9d9; },想统一表单输入框的边框色,但打开页面发现某几个页面没生效。打开 DevTools 一看,划线的正是这条规则,压过它的是 Element UI 自带的 .el-input.is-focus .el-input__inner。业务这条规则源码里是 (0, 2, 0),但 Vue scoped 编译会给它拼上一层属性选择器,实际权重涨到 (0, 3, 0);组件库那条 .el-input.is-focus .el-input__inner 是两个类加一个类(is-focusel-inputel-input__inner 各算一层),也是 (0, 3, 0)。两条规则的三元组完全相等,第一位、第二位、第三位逐一比下来都打平,这时候权重已经决定不了胜负,真正拍板的是层叠顺序——两份样式表在 index.html 里谁的 <link> 排在后面,或者同一个入口里谁的 CSS 被打包工具排在后面,谁就生效。Element UI 的样式文件是在项目里全局引入的,引入时机比业务组件的 scoped 样式编译产物更早还是更晚,取决于构建工具处理 CSS 抽取的顺序,这一点业务代码完全无法从选择器本身看出来,只能去翻构建产物或者直接在 DevTools 里确认。想让业务样式稳定地压过组件库默认样式,与其纠结谁排在前谁排在后(这个顺序可能随构建配置调整说变就变),不如直接在权重上明确拉开差距,比如把业务选择器写成 .form-item.is-focus-visible .el-input__inner 这类多叠一层类名的形式,或者干脆放弃跟组件库拼权重,转而使用 Element UI 自身暴露出来的 CSS 变量或者主题定制机制去改边框色。

这个案例说明一件事:权重打平之后,"谁在源码或者构建产物里排得靠后"就成了唯一的裁判,而这个顺序对业务代码来说往往是不透明、不受控的——今天生效不代表组件库升级一个小版本、内部选择器结构一变,或者构建工具调整了 CSS 抽取顺序,覆盖关系就可能翻转。与其赌顺序,不如从一开始就让自己的权重明确高于对方,这样不管加载顺序怎么变,覆盖关系都不会被打乱。

这类问题排查熟练之后会发现一个规律:绝大多数"覆盖不掉"的报障,最后都能归到"对方选择器权重比想象中高"这一类原因,剩下极少数才是真的选择器没写对、命中不了目标元素。养成先看 DevTools 划线情况再动手改代码的习惯,能省掉很多"改了半天发现根本没找对问题"的返工。

Safari 的 Web Inspector 在这方面跟 Chrome 类似,规则面板同样会把被覆盖的声明标灰或者划线,只是不像 Firefox 那样直接给出数值角标,需要自己数选择器里的 ID、类、标签数量。公司移动端 H5 页面在 iOS 上出问题时,团队里排查样式经常要在 Safari 的 Web Inspector 里对照 Chrome 的表现,好在权重计算规则是标准里定义好的,三个浏览器在这一点上不存在差异,真正需要留意的是不同浏览器 UA 默认样式表本身的差异(比如表单控件的默认内外边距),这属于另一个话题,跟优先级计算无关。

有了这套排查思路之后,团队里逐渐养成一个习惯:新写一条样式规则之前,先想清楚这条规则大概率会跟谁竞争权重——如果是纯业务代码内部的规则,直接控制自己的选择器复杂度就行;如果要跟组件库、UA 默认样式或者别的团队维护的公共样式竞争,先打开 DevTools 看一眼对方现在的真实权重,再决定要不要跟着往上堆,还是换一种不依赖特殊性取胜的思路,比如前面提到的 CSS 变量或者 props 控制。这个习惯比等出了问题再排查要省事得多,也更容易把选择器权重维持在一个可控、可预期的范围内。

Firefox 的开发者工具在这方面还多一个直观的地方:选中元素后规则面板里每条规则前面会显示一个具体的特殊性数值角标,格式类似 (0,2,1),跟前面手算时用的三元组记法几乎一样,不需要自己心算就能直接确认两条规则谁的权重更高。团队里排查复杂的样式覆盖问题时,这个角标比 Chrome 那种"划删除线"的方式更省一步验证的功夫。

内联样式和 !important 是两个不同维度

内联样式(写在标签的 style 属性里)的优先级比任何选择器都高,包括 ID 选择器,因为它压根不参与选择器权重的计算体系,而是单独在权重之上再高一级。所以哪怕外部样式表里写了 #app .button { color: red !important; },只要没加 !important,内联的 style="color: blue" 依然赢。

!important 则是另一个维度:它不改变选择器本身的权重等级,而是让这条声明脱离正常的优先级比较,直接跳到最高层。两个都带 !important 的声明互相冲突时,才回到选择器权重的常规比较规则,包括内联样式也是——内联样式加了 !important,外部样式表的 !important 规则如果选择器权重更高,依然能覆盖它。这套关系经常被简化成"内联最大、!important 万能",实际上二者是分层叠加的关系,不是简单的数值大小比较。

!important 在业务代码里几乎不该出现,因为它一旦被写下,后面任何想覆盖这条规则的人都得跟着用 !important,权重的竞赛就升级了一个维度,且没有回头路——普通选择器无论怎么堆权重都覆盖不了它。它比较合理的使用场景是写工具类或者临时热修复,比如线上样式被某个第三方脚本污染,来不及排查根因,先用 !important 顶一下,但这种代码要留注释标记,方便后面清理。

!important 还有一条容易被忽略的细节:它只对普通声明生效,对动画(@keyframes 里的属性)不生效,浏览器会直接忽略动画帧上的 !important。之前有同事想用 !important 强行让一个过渡动画的中间帧颜色保持不变,调了半天发现完全没作用,后来查规范才确认这条限制——不是选择器权重的问题,是这条声明所在的上下文压根不认 !important

多个 !important 规则同时存在时,比较规则和普通声明完全一致:先比来源和层叠层级,同层级再比选择器权重,权重也相同才比源码顺序。团队内部现在的约定是:业务代码里出现 !important 一律要求 code review 时特别标注原因,方便以后清理,因为半年后再回来看这行代码,已经很难判断当初是为了绕过谁而加的。

Vue scoped 样式和优先级的关系

组件里用 <style scoped> 时,Vue 的编译器会给组件根节点和内部元素加上一个形如 data-v-7ba5bd90 的属性,同时把样式表里的每条选择器都拼接上对应的属性选择器:

1.button {
2  color: #1677ff;
3}
4/* 编译后大致变成 */
5.button[data-v-7ba5bd90] {
6  color: #1677ff;
7}

这一步经常被忽略的地方在于:属性选择器本身也计入优先级的"类"这一级,跟普通类选择器同等权重。也就是说 scoped 样式并不是单纯做了作用域隔离,它顺带给每条规则的优先级加了一级。如果一个组件内部同时存在普通类选择器和被 scoped 处理过的选择器,二者的权重差异比看起来的要大——.button[data-v-7ba5bd90] 实际上是两个类的权重,而不是一个。这也是为什么有时候想在父组件里用一个简单的类去覆盖子组件内部的 scoped 样式,会发现权重根本不够,因为子组件那条规则背后多了一层看不见的属性选择器。

要穿透 scoped 边界去改子组件内部的样式,Vue 2 项目里通常用 >>> 或者 /deep/,Sass/Less 这类预处理器里更常见的写法是 ::v-deep

1.card ::v-deep .el-input__inner {
2  border-radius: 4px;
3}

这行代码解决的是 Element UI 这类组件库内部结构不可控的问题——el-input 渲染出来的真实 DOM 藏在组件内部,业务代码没法直接改它的类名,只能靠深度选择器把样式"钻透"进去。但这里有个容易被漏掉的权重代价:::v-deep 本身不计权重,可它常常需要在前面叠加更多层级才能保证命中,比如 .form-item ::v-deep .el-input__inner 这种写法,等到项目里到处都是深度选择器时,权重堆叠的速度比看起来快得多,覆盖起来只会比普通样式更费劲。

把这条规则拆开算一下权重就能看出问题:.form-item ::v-deep .el-input__inner 编译之后大致变成 .form-item[data-v-xxx] .el-input__inner.form-item 那部分因为在当前组件作用域内会被拼上属性选择器,权重从一个类变成两个类,加上后面的 .el-input__inner 又是一个类,整条规则实际是 (0, 3, 0)。如果这层嵌套关系再往上加一级,比如页面里有个弹窗套了一层 .dialog-wrapper,为了保证只在弹窗内生效又加了一层前缀变成 .dialog-wrapper .form-item ::v-deep .el-input__inner,权重立刻涨到 (0, 4, 0)。等到这类深度选择器攒到十几条,每条权重都在 3 到 5 个类之间,团队里新人想再针对某个特殊场景做一次覆盖,往往要先在浏览器里翻好几层规则才能确认自己的新选择器权重是不是够用,排查成本比普通样式高出一截。

团队内部对这类深度选择器的共识是:能通过 Element UI 提供的 sizetype 等 props 控制样式的,优先用 props,实在绕不开才用 ::v-deep,并且尽量把作用范围收在一个具体的父级类名下面,不要图省事直接从根节点开始钻。另外一条约定是深度选择器尽量不要跨越两层以上的组件边界——如果 A 组件里用 ::v-deep 改了 B 组件内部的样式,B 组件自己又用 ::v-deep 改了它内部引用的 C 组件的样式,这种链式穿透一旦出问题,从最外层排查到最里层要经过好几个文件,比单层穿透费事得多。

这条约定落地之后,团队里新写的深度选择器基本都能控制在"业务组件直接改一层组件库内部结构"这个范围内,链式穿透的情况基本消失了,只有个别历史遗留代码还留着两层以上的穿透,暂时没到非改不可的地步,先留着观察。

:where() 这个新的伪类选择器可以把一批选择器的权重直接清零,无论括号里写多少层,最终这条规则的权重都按零计算:

1:where(.article-content) h2 {
2  margin-top: 32px;
3}

这个特性目前 Firefox 和 Safari 已经支持了,Chrome 这边还没跟上,暂时只能在能确认目标浏览器支持的场景下小范围试,公司这边面向普通用户的页面还不敢直接上。跟它经常被放在一起提的还有 :is(),作用是把多个选择器合并成一条、简化重复书写,比如把 .article h2, .article h3, .article h4 简化成 .article :is(h2, h3, h4),但权重按照括号内最高的那个计算,跟 :where() 完全不是一回事——:where() 永远按零权重算,:is() 按最具体的参数算。

举个更具体的对比:

1:where(.article-content, #legacy-content) h2 {}
2/* 权重恒为 0,不管括号里是 class 还是 id */
3
4:is(.article-content, #legacy-content) h2 {}
5/* 权重按括号内最高的那个算,这里是 ID,等价于 #legacy-content h2 的权重 */

:is() 括号里混进一个 ID 选择器,会让整条规则的权重直接跳到 ID 那一级,这跟大部分人写 :is() 图的"合并简化"初衷有点违背——本来只是想省事把几个选择器合并写,权重却因为括号里最高的那个选择器被悄悄拔高了。:where() 完全不存在这个问题,不管括号里塞多少个 ID 都不影响外层权重,这也是这两个提案分开设计、不能互相替代的原因。Chrome 这边还没跟上,公司的页面还不敢直接使用,但先弄清楚二者权重计算方式的差异,等 Chrome 也补上以后要选用哪个,心里能有底。

状态类组合起来之后权重会怎么变

业务组件里状态往往不止一个,一个按钮可能同时处于"加载中""禁用""选中"里的好几种组合,样式写法上常见的做法是给每种状态一个独立的类,然后用组合选择器去处理"两个状态同时存在"的情况:

1.button {}
2.button.is-loading {}
3.button.is-disabled {}
4.button.is-loading.is-disabled {}

最后这条 .button.is-loading.is-disabled 权重是三个类叠加,(0, 3, 0),比单独的 .button.is-loading(0, 2, 0))或者 .button.is-disabled(0, 2, 0))都高,这是刻意设计出来的——业务上"同时加载中且禁用"往往需要一个跟单一状态都不一样的样式(比如透明度更低、鼠标指针换成 not-allowed),如果不特意写这条组合规则,浏览器会按源码顺序取源码里排在后面的那条状态类,谁排在后面完全取决于 CSS 文件里的书写顺序,而不是业务逻辑上"这两个状态谁更该优先",这种依赖书写顺序的覆盖关系非常脆弱,写状态类样式的时候养成"两个状态可能同时出现就补一条组合选择器"的习惯,能省掉很多这类不可靠的覆盖问题。

之前团队里就出过一次这类问题:.is-loading.is-error 两个状态类分别由两位同事在不同的迭代里加的,谁都没考虑过对方会不会跟自己同时出现在同一个按钮上。上线之后某个接口报错又恰好卡在加载状态,按钮同时挂上了两个类,样式表现完全取决于这两条规则谁写在文件后面,排查的时候两个人都觉得"我这条规则是对的",最后翻 Git 历史才确认是源码顺序偶然决定了当前的表现,跟任何一个人的本意都没关系。这次之后团队补了一条约定:新增状态类之前,先看看这个组件已有的状态类列表,评估一下会不会跟自己新加的状态同时触发,如果会,就要么在评审里明确写清楚谁该覆盖谁,要么直接写一条组合选择器把优先关系钉死,不留给源码顺序去决定。

反过来,如果两个状态类分别来自不同开发者、约定不清楚谁该覆盖谁,用组合选择器明确写出来,比让两条单状态规则在文件里比谁写得晚更可靠,因为源码顺序会随着代码增删而变化,组合选择器的权重不会。团队 code review 时如果看到某条样式的生效与否说明写着"取决于这条规则在文件里的位置",一般都会要求补一条组合选择器把优先关系显式表达出来。

打印样式和暗色模式提醒:媒体特性也会牵扯出权重问题

年底这段时间团队里有个页面要适配打印导出,用到了 @media print,顺带又碰上一个和权重相关的小坑,记录一下。打印样式表里想把页面上的操作按钮全部隐藏掉:

1@media print {
2  .toolbar { display: none; }
3}

这条规则本身权重是 (0, 1, 0),跟普通的 .toolbar { display: flex; } 权重完全一样,@media 包裹并不会让它自动获得更高优先级,前面已经讲过这一点。这次踩到的坑是页面里另有一条规则 .toolbar.is-sticky { display: flex; position: fixed; },权重是 (0, 2, 0),比 @media print 里那条 (0, 1, 0) 高,打印页面里带着 is-sticky 状态的工具栏死活隐藏不掉。解决办法很直接,打印样式里也要跟着叠一层状态类权重:

1@media print {
2  .toolbar,
3  .toolbar.is-sticky {
4    display: none;
5  }
6}

这个案例说明一个容易被忽略的事实:媒体查询只是一个"什么时候生效"的开关,规则内部选择器的特殊性该怎么算还是怎么算,想在 @media 里覆盖一条已有规则,同样要保证自己的选择器权重不低于对方,不能指望"包在媒体查询里"这件事本身能带来额外的优先级加成。今年浏览器对 prefers-color-scheme 这个媒体特性的支持也逐步成熟起来,有同事在个人项目里试着写过暗色模式适配,思路上跟打印样式完全一样——@media (prefers-color-scheme: dark) 只是一个条件包裹,内部规则跟正常模式下的规则一样要靠特殊性和源码顺序去决定谁生效,没有变成一套单独的权重体系。

CSS 变量能不能替代一部分选择器复杂度

--custom-property 这套自定义属性今年在能看到的项目里陆续冒出来,Chrome、Firefox、Edge(新版基于 Chromium 之后)都已经支持得比较稳定,Safari 从 9.1 起也支持,唯独 IE11 完全不认,公司还有一部分后台页面要兼容 IE11,这就决定了 CSS 变量目前只能用在明确不需要兼容 IE 的项目里,比如内部工具或者移动端 H5。

用起来的思路是把原本要靠深层选择器才能改到的值,提前定义成变量挂在组件根节点上:

1.card {
2  --card-padding: 16px;
3  --card-radius: 4px;
4  padding: var(--card-padding);
5  border-radius: var(--card-radius);
6}
7
8.card.is-compact {
9  --card-padding: 8px;
10}

.card.is-compact 只是覆盖了一个变量的值,不需要再去堆一层更具体的选择器跟原来的 padding 声明拼权重,变量的"覆盖"发生在值的层面而不是选择器权重的层面。这对解决"选择器权重不够又不想升级到更高一级"的问题是个新思路——与其想办法让选择器变得更靠后、权重更高,不如把会变化的值抽成变量,用状态类去切变量本身。当然这只能覆盖"值会变"这一类场景,如果是想彻底改变某条规则要不要生效(比如显示或隐藏),CSS 变量帮不上忙,还是得靠选择器或者类名去控制。

变量还有一个跟层叠顺序相关的细节容易被忽略:var() 取值的时候是按"最近的祖先节点上定义的那个值"生效,这跟选择器权重完全是两套机制,不能拿优先级的思路去套。比如:

1:root {
2  --theme-color: #1677ff;
3}
4
5.sidebar {
6  --theme-color: #52c41a;
7}
8
9.link {
10  color: var(--theme-color);
11}

.link 如果嵌套在 .sidebar 内部,拿到的就是 #52c41a,不在 .sidebar 内部就落回 :root 上定义的 #1677ff——这是 DOM 树上的继承关系决定的,不是哪条规则选择器权重更高决定的。之前有同事把变量的覆盖关系和选择器权重的覆盖关系搞混,觉得"权重更高的规则里定义的变量值应该赢",实际上跟权重毫无关系,只看这个变量在 DOM 树上最近被谁定义过。排查变量取值不对的问题时,第一步应该是看 DOM 结构里变量的定义链条,而不是去比较定义变量那条规则的选择器权重。

变量的作用域也值得留意:一个组件内部定义的变量,如果没有挂在 :root 这类全局节点上,只在这个组件的 DOM 子树内有效,出了这个子树范围就取不到值,浏览器会退回属性的初始值或者继承值。这跟类选择器的作用域完全不是一回事——类选择器的"作用域"取决于选择器本身写没写限定的父级,只要选择器命中了,无论这个元素在页面哪个角落都会生效;变量的"作用域"却是完全基于 DOM 树结构的继承关系,跟选择器写法没有关系,哪怕选择器本身能命中所有页面元素,变量取值依然只看它在 DOM 树上离哪个定义点最近。这两套机制经常被放在一起类比,但背后的运作方式完全不同,排查问题时不能套用同一套思路。

变量的兼容性问题还有一个变通方案:给不支持 CSS 变量的旧浏览器写一条兜底声明在前面,支持的浏览器会因为后面那条 var() 声明覆盖掉前面的兜底值,不支持的浏览器直接忽略掉解析不了的 var() 声明,保留兜底值:

1.card {
2  padding: 16px;
3  padding: var(--card-padding, 16px);
4}

这条写法本质上是利用了"浏览器遇到无法解析的属性值会整条声明忽略、不影响同属性前面那条已生效的声明"这个特性,不是选择器权重带来的效果,但了解了这一点之后,在需要兼容旧浏览器又想尝鲜 CSS 变量的场景下能用得更放心,不用非得等团队全面放弃 IE11 兼容才敢用。

CSS Modules:绕开优先级战争的另一条路

除了靠命名约定管理权重,另一种思路是从根源上让"选择器冲突"这件事不可能发生——CSS Modules 的做法是构建时把每个类名编译成一个带哈希的唯一字符串,比如源码里写的 .title,打包后变成 .title_3Zjk9,不同文件里同名的 .title 各自哈希成不同的字符串,天然不会互相覆盖,也就没有优先级需要比较的问题。

1/* card.module.css */
2.title {
3  font-size: 16px;
4  font-weight: 600;
5}
1import styles from './card.module.css';
2// styles.title === 'title_3Zjk9'

这跟团队现在用的 Vue scoped 思路有点像,都是靠"让类名不再全局共享"来规避优先级问题,但实现方式不同:scoped 是编译时给选择器拼接属性选择器,类名本身还是全局唯一的,靠属性选择器做隔离;CSS Modules 是直接把类名字符串哈希化,类名本身就不重复,连属性选择器这一层权重都不需要引入。公司目前用 Vue 全家桶,scoped 已经是现成方案,暂时没有引入 CSS Modules 的动力,但如果以后有 React 或者纯组件化的项目,CSS Modules 这条路子值得考虑——它比命名约定更彻底,命名约定还是要靠人去遵守规则,CSS Modules 是构建工具保证类名不重复,出错的可能性更小。

两者在权重这件事上还有个细微差别:scoped 因为额外拼接了属性选择器,权重比不加 scoped 时天然高一级,这也是前面提到的"想从外部覆盖 scoped 内部样式时权重总是不够"的根源;而 CSS Modules 编译出来的类名权重跟普通类选择器完全一样,还是一个类的权重,只是名字变得又长又不可读(比如 title_3Zjk9),覆盖它的时候至少不用再额外承担 scoped 那层隐藏的权重代价,只是想要覆盖就得先想办法拿到这个哈希后的类名,通常要通过 :global() 语法跳出模块作用域,或者干脆通过组件暴露出来的 className prop 去传外部类名进去。

CSS Modules 目前主要活跃在 React 生态,webpack 的 css-loader 从很早的版本开始就内置支持,只要在文件名里加上 .module.css 后缀,或者在 css-loader 配置里打开 modules: true,就能让这套哈希机制生效,不需要额外装什么新工具,这也是为什么这条路子随时可以在需要的时候捡起来用,不算是一个陌生的技术栈跳跃。

Sass 嵌套写法容易在不知不觉中堆高权重

团队项目里 Sass 用得比较普遍,嵌套写法本身是个双刃剑:写起来方便,跟 DOM 结构对应关系直观,但每多嵌套一层,编译出来的选择器就多一层,权重跟着涨,而且涨的速度比手写 CSS 时更隐蔽,因为源码里看不出编译后到底是几层:

1.card {
2  .card-header {
3    .card-title {
4      font-size: 16px;
5
6      &.is-active {
7        color: #1677ff;
8      }
9    }
10  }
11}

编译出来是:

1.card .card-header .card-title { font-size: 16px; }
2.card .card-header .card-title.is-active { color: #1677ff; }

第一条权重是 (0, 3, 0),第二条是 (0, 4, 0)。写 Sass 的时候盯着缩进层级,很容易没意识到自己已经嵌套了三四层,等到需要在另一个文件里覆盖 .card-title 的字号,才发现对方权重已经堆到普通单类选择器完全够不着的地步。之前团队内部整理过一次样式规范,明确要求 Sass 嵌套不超过三层,超过三层的情况一律拆成独立的类名,用 BEM 那种命名方式表达层级关系,而不是继续往下嵌套——这条约定跟前面讲的"用命名规则替代嵌套"是同一个道理,只是在 Sass 里更容易被忽略,因为嵌套写法本身让人觉得"反正跟着 DOM 结构走没什么问题"。

Sass 的 & 符号也有个容易被忽略的权重陷阱。&.is-active 这种写法编译出来是 .card-title.is-active,两个类叠加,权重比单独一个 .is-active 高。如果只是想给某个通用状态类定义样式、希望它能在多个组件间复用,写成 &.is-active 反而把权重和当前组件的类名绑死了,以后想在别的组件里复用同一条 .is-active 规则,会发现它的权重跟这里绑定的组件类名脱不开关系。这种情况更适合把状态类样式单独提出来,不嵌套在具体组件的类名下面。

排查一整个样式表的权重分布,而不是一条条规则单独看

前面讲的都是单条规则之间怎么比权重,但项目做到一定规模之后,更实际的问题是整份样式表里权重的整体分布——如果大部分规则都停留在一到两个类的权重,偶尔出现一条五六层嵌套堆出来的高权重规则,这条规则往往就是以后所有覆盖问题的根源,因为团队里别的规则都没有跟它对齐的心理预期。

年底整理团队公共样式库的时候做过一次简单的审查,把项目里所有的 CSS 文件过了一遍,手动记录每条规则的三元组权重,发现大部分业务组件的权重集中在 (0, 1, 0)(0, 2, 0) 之间,符合"权重尽量控制在一个类"这条约定,但有十几条规则的权重冲到了 (0, 4, 0) 以上,几乎都出自两类来源:一类是 Sass 嵌套写深了没拆出来,另一类是历史遗留的 ::v-deep 深度选择器层层叠加。把这些高权重规则单独列出来之后,其中能拆的都拆成了扁平的类名,暂时拆不动的(主要是牵扯组件库内部结构、短期内没法换掉的那几条)留了注释标注原因,方便以后有精力再处理。

这次审查没有依赖什么专门的工具,纯粹是打开每个样式文件手动数,笔记本电脑跑起来倒也不算慢,因为公司项目规模还没到几百个样式文件那种量级。如果项目规模再往上涨一个数量级,纯手动排查权重分布的成本会变得不现实,届时可能得考虑写个小脚本或者找现成的 lint 规则去自动统计选择器权重,但眼下手动过一遍已经足够暴露问题、给团队定下"权重不能无节制往上堆"这条共识。

审查过程中还发现一个规律:权重异常高的规则,几乎都聚集在几个特定的老页面里,新页面基本都能控制在两层类以内。对照一下这几个老页面的开发时间,正好是团队还没定下"选择器权重尽量扁平"这条约定之前写的,说明约定定下来之后确实起了作用,不是空谈。这也是为什么把这类约定写进团队规范、在 code review 里当作一条检查项,比单纯口头强调"少写嵌套"更有效果——真正落地的约定是能在代码里验证出来的,而不是停留在文档里。

用命名规则降低优先级冲突,而不是靠嵌套

选择器链写得越深,短期看越精确,长期看维护成本越高:

1.page .content .list .item .title {}

这类选择器把权重和 DOM 结构强绑定,一旦页面结构调整一层,样式立刻失效;想覆盖它,还得在权重上跟它掰手腕。团队里现在写样式基本遵循一条简单原则:选择器权重尽量控制在"一个类"这一级,不靠嵌套层数去保证命中范围,而是靠命名本身表达清楚语义,类似 BEM 的命名习惯:

1.list-item-title {
2  font-size: 14px;
3  font-weight: 600;
4}

这个类名不需要依赖父级结构就能被识别出"这是列表项标题",权重只有一层,覆盖它也只需要一层。BEM 那套 block__element--modifier 的命名规则本质上就是在用命名约定替代嵌套选择器,让每条规则的权重都尽量扁平,不去堆积三层四层的组合选择器。一个完整点的例子:

1.list-item {}
2.list-item__title {}
3.list-item__title--highlighted {}
4.list-item__icon {}

list-item 是块(Block),__title__icon 是这个块内部的元素(Element),--highlighted 是某种状态的修饰符(Modifier)。三者都通过命名直接拼在一个类名里,不靠 .list-item .title.highlighted 这种嵌套 + 叠加去表达"高亮的列表项标题",选择器权重永远停留在一个类的量级,谁都不需要跟结构层级较劲。项目里没有严格照搬 BEM 的全部语法(比如没有强制要求每个修饰符都必须绑定一个元素类),但"用命名表达层级关系,而不是用选择器嵌套表达层级关系"这条思路,基本上已经成为写业务样式时的默认习惯。

这条思路和前面提到的 CSS 变量、CSS Modules 其实是同一件事的不同解法:都是想办法让"覆盖一条样式"这件事不需要动用更高的选择器权重。BEM 靠的是命名约定把语义摊平,CSS 变量靠的是把易变的值抽出来单独控制,CSS Modules 靠的是从根本上让类名不重复。选哪一种取决于项目现状——存量项目改造,命名约定成本最低,不需要动构建配置;新项目起步阶段,CSS Modules 能提供更彻底的保证;只是想让一部分样式更容易被状态类覆盖,CSS 变量最省事。

这三种手段还有一个共同点:都不依赖"选择器权重要不要升级"这道题去解决问题,而是从根源上避免权重成为需要竞争的资源。这跟前面反复出现的那条判断标准是一致的——遇到覆盖不了的样式,第一反应不该是"我该怎么把权重叠得比对方高",而是"这条规则原本是不是就不该跟对方拼权重"。

实际项目里这几种手段经常混着用,不是非此即彼的选择题。团队目前的中后台项目里,公共组件层用 BEM 风格的命名扁平化权重,业务页面里遇到需要频繁切换的视觉属性(比如卡片间距、圆角大小这种和主题相关的值)用 CSS 变量控制,遇到组件库内部结构不可控又必须改的场景才动用 ::v-deep。三种手段分别覆盖了不同的问题:BEM 解决"业务代码内部选择器怎么写不容易冲突",CSS 变量解决"同一条规则在不同状态下取值不同怎么表达",::v-deep 解决"第三方组件内部结构不受控制怎么办"。分清楚各自解决的问题,比生硬地规定"以后都用 BEM"或者"以后都用 CSS 变量"更实用,毕竟不是所有覆盖问题都属于同一类。

选择器最终是在做一件事:声明一段样式的影响范围和覆盖难度。范围选大了,后面加内容就容易被误伤;权重堆高了,后面想覆盖就得跟着堆得更高。写的时候多问一句"这条规则理想情况下应该只影响到哪一层",比事后去排查"为什么覆盖不掉"要省事得多。

把这篇里过的几条规则放在一起看,其实可以归纳成两条判断路径:遇到"样式没生效",先看它有没有出现在 DevTools 的规则列表里——没出现是选择器没命中,出现了但被划线是权重或者顺序的问题,权重打平了才看顺序,权重没打平顺序说了不算;遇到"样式生效了但影响范围不对",跟权重毫无关系,只能回去看选择器本身写宽了还是写窄了。这两类问题的排查方向完全不同,混在一起想很容易南辕北辙。

至于该用哪种手段去控制权重——命名约定、CSS 变量、CSS Modules、还是干脆避免嵌套——归根结底都是同一个原则的不同实现:尽量让每条规则的权重停留在能预期的范围内,需要覆盖的时候不必跟着往上加码。这条原则跟具体用什么工具链、什么框架都没关系,Vue、React 或者原生页面都一样适用,明年不管团队往哪个方向做技术选型,这套判断权重的基本功都还用得上。

.page .toolbar .button.primary 那条选择器最后还是留在了样式表里,只是把它前面权重更低的 .button 规则也一并整理了一遍,两条规则各自的职责划分清楚——通用按钮样式归 .button,主色调按钮样式归一个新增的独立类名 .button-primary,不再靠嵌套层级去表达"这是主要按钮",覆盖关系也不再需要靠数 ID、数类去猜谁能压过谁。