CSS 架构实践:从命名空间到 @layer,把优先级说清楚

要不要给一段样式加命名空间、要不要抽变量、要不要上 @layer——这些判断背后其实只有一条标准:这段样式的作用域说不说得清楚。说得清楚,项目多大都能改;说不清楚,加一个按钮都可能拖垮三个页面。

CSS 写乱的速度比大多数人想象得快。刚开始几个页面,随手写 class 完全没问题,选择器之间也不会打架。项目做到中型规模以后,全局选择器开始互相影响,组件样式被业务页面强行覆盖,颜色和间距散落在几十个文件里各写各的,最后想改一个按钮的内边距,得先搞清楚它会不会连带影响另外两个页面。

CSS 架构不是为了显得高级,是为了让这种"改一处、动全身"的情况尽量少发生。团队最近在整理内部组件库的样式规范,正好把这几年攒下的判断标准和今年刚落地的一个新工具都过一遍。

全局样式只放真正全局的东西

判断一段样式该不该放进全局文件,标准很简单:去掉它之后,是不是所有页面都会出问题。如果答案是"只有订单页会崩",那它就不该在全局文件里。

适合放全局的东西相对固定:

1/* reset / normalize,字体,CSS 变量,body 基础背景和文字 */
2* {
3  margin: 0;
4  padding: 0;
5  box-sizing: border-box;
6}
7
8body {
9  font-family: -apple-system, "PingFang SC", sans-serif;
10  color: var(--color-text-primary);
11}

不适合放全局的,恰恰是团队最容易图省事塞进去的那几类:某个页面的布局容器、某个组件的按钮样式、看起来通用实则宽泛的 .title.card

1.title {
2  font-size: 20px;
3}

这种类名在多人协作的项目里迟早会撞。上周有同事新开的一个营销页面,.title 被另一个人在详情页里定义过一次,颜色和字号完全不同,加载顺序一变,页面标题的样式就跟着乱。页面级样式至少要挂上一个命名空间前缀:

1.orderPageTitle {
2  font-size: 20px;
3}

命名空间不解决优先级问题,只解决"两个东西会不会被误认成同一个东西"的问题——这两件事经常被混为一谈,值得分开想清楚。

组件样式跟着组件走

组件样式该放哪,判断标准是组件被删除、被迁移的时候,样式会不会跟着一起走。如果不会,说明耦合关系写反了。

1Button.vue
2Button.module.css

或者单文件组件里用 scoped

1<style scoped>
2.button {
3  padding: 8px 16px;
4  border-radius: 4px;
5}
6</style>

这样组件搬到另一个项目、或者整个删掉重写时,样式该归到哪一目了然,不用满仓库搜索有没有漏网的选择器。

scoped 不是万能的。它能限制大多数选择器的作用域,但深度选择器(:deep())、第三方组件内部结构、挂载在 body 下的全局弹窗,仍然会绕开这层隔离。团队里有个真实的坑:某个下拉面板用 teleport 挂到了 body 上,写的人以为组件加了 scoped 就万事大吉,结果面板渲染到组件树外面之后完全不受 scoped 属性约束,被外层全局样式覆盖了字号。scoped 本质是靠给元素打属性选择器实现的,元素挂载位置一旦脱离组件本身的 DOM 子树,这层隔离自然就不生效了——这是机制层面的限制,不是用法问题。

命名要表达结构

BEM 这套命名思路老,但判断类名好坏的标准没变过:这个类名离开当前文件,还能不能看出它属于哪个块、扮演什么角色。

1.user-card {}
2.user-card__avatar {}
3.user-card__name {}
4.user-card--active {}

一眼能看出 avatarname 都是 user-card 的子元素,--active 是状态修饰。不需要严格套用双下划线双横杠的写法,但要避免完全没有上下文的类名:

1.left {}
2.content {}
3.active {}

这几个类名单独拎出来放进任何一个组件都成立,也正因为如此,它们才最容易在无意间被复用、被覆盖。CSS Modules 能从工具层面解决类名冲突(编译时自动加哈希后缀),但解决不了"这个类名本身有没有语义"这个问题——两者不是同一层面的事,一个管冲突,一个管可读性。

优先级战争的老办法:靠命名和顺序硬扛

在没有额外工具介入的情况下,团队处理样式优先级冲突基本靠三件事:选择器写得够具体、CSS 文件加载顺序排在后面、必要时加 !important 强行压过去。这三种手段有个共同问题——它们让"谁能覆盖谁"变成了一件需要靠记忆和约定维持的事情,而不是显式声明的规则。

典型场景是主题变量覆盖第三方组件库样式。用了 Element Plus 之后,业务页面经常要微调组件的默认外观:

1.page :deep(.el-button) {
2  padding: 0 12px;
3}

偶尔这么写没问题,写多了就会遇到"我明明覆盖了,为什么没生效"——通常是组件库自身某条内部规则的 specificity 比这条更高,或者两条覆盖规则的加载顺序反了。排查这类问题的老办法是打开 DevTools,一条条数选择器的类名数量、id 数量、标签数量,再看 Sources 面板里两个文件谁先加载。这套流程能解决问题,但每次都要重新推理一遍,团队里新来的同学光是理解"为什么这条规则没生效"就要花小半天。

@layer 落地之后,优先级问题第一次有了显式声明的方式

今年 3 月 Chrome 99 把 CSS 层叠层(@layer)落了地,Firefox 97、Safari 15.4 前后跟进,主流浏览器基本到齐了。这东西解决的正是上面那个问题——它让"谁覆盖谁"从"靠 specificity 数字和加载顺序隐式决定"变成了显式声明的层级关系。

1@layer reset, base, components, utilities;
2
3@layer reset {
4  * { margin: 0; padding: 0; }
5}
6
7@layer components {
8  .el-button {
9    border-radius: 4px;
10  }
11}
12
13@layer utilities {
14  .mt-0 { margin-top: 0 !important; }
15}

@layer 声明的顺序决定了层与层之间的优先级,层越靠后优先级越高,而且跨层比较时完全不看 specificity——哪怕 reset 层里写了一条 #app .title.active 这种高优先级选择器,只要它在 reset 层里,照样会被 components 层里随便一个 .title 覆盖。这一点和平时理解的选择器优先级规则是反的,第一次看文档时我特意在本地写了个例子验证:

1@layer a, b;
2
3@layer a {
4  #test { color: red; }
5}
6
7@layer b {
8  .test { color: blue; }
9}

<div id="test" class="test"> 最终渲染成蓝色——id 选择器输给了 class 选择器,只因为它在更早声明的层里。这个反直觉的地方值得在团队内部提前讲清楚,不然升级到用 @layer 之后第一次踩坑会很懵。

拿这个特性去套前面 Element Plus 覆盖的场景,思路会变成:把组件库的默认样式扔进一个专门的层,业务覆盖样式放进优先级更高的层,不再需要靠 :deep() 叠加更多层选择器去硬撑 specificity:

1@layer vendor, business;
2
3@layer vendor {
4  /* element-plus 默认样式,或者 import 进来的第三方样式表都可以用 @import url(...) layer(vendor) 的写法整体归入这一层 */
5}
6
7@layer business {
8  .page .el-button {
9    padding: 0 12px;
10  }
11}

@layer 目前只在内部工具链和一个非核心业务页面上小范围试了,还没有铺到主站——毕竟这东西刚落地不到半年,团队里还没人对它的各种边角情况摸得很透,比如层和媒体查询嵌套、层和 CSS Modules 生成的哈希类名混用时的表现,都得再观察一阵子。但作为解决优先级问题的思路,它已经比"数 specificity、猜加载顺序"靠谱得多。

变量管理颜色和间距

项目里到处写裸色值,是样式失控最常见的起点:

1color: #333;
2margin-bottom: 13px;

判断要不要抽变量的标准不是"这个值出现了几次",而是"这个值背后有没有一个业务概念"。#333 背后其实是"主文字色"这个概念,只是没有被显式命名出来。至少先抽一层 CSS 自定义属性:

1:root {
2  --color-text-primary: #1f2329;
3  --color-text-secondary: #86909c;
4  --space-4: 16px;
5  --radius-base: 4px;
6}

使用起来很直接:

1.title {
2  color: var(--color-text-primary);
3  margin-bottom: var(--space-4);
4}

变量不是越多越好,先覆盖颜色、字号、间距、圆角这几类高频设计决策就够了。业务代码里不应该每次都重新发明一个"看起来差不多"的灰色——这也是 CSS 自定义属性比 Sass 变量多出来的一个实际好处:它是运行时的,可以在媒体查询或者暗色主题切换时直接在 :root 上重新赋值,不需要重新编译整份样式表。

1@media (prefers-color-scheme: dark) {
2  :root {
3    --color-text-primary: #e5e6eb;
4  }
5}

Sass 变量做不到这一点,编译期就把值写死进了产物里。团队内部工具后台最近加暗色模式,就是靠这套自定义属性直接切的,没有额外引入什么运行时方案。

覆盖第三方组件要集中

很多项目用了组件库之后,到处零散地写覆盖样式,判断这种覆盖该不该收敛的标准是:同样的覆盖逻辑,团队里有没有人在两个不同的地方各写了一遍。如果有,说明该抽出来了。

更稳妥的方式是集中管理主题变量,或者封装一层业务组件:

1<AppButton type="primary" />

内部再包一层组件库的 Button,业务页面不需要直接深度覆盖第三方内部 DOM 结构。这样组件库升级版本、改了内部 DOM 结构,只需要改这一层封装,不用满仓库找 :deep()

如果确实必须直接覆盖,范围要窄,原因要写清楚(哪怕只是一行注释)。不要写影响全站的 .el-button,那等于把组件库的默认外观全局劫持了,后面谁都不知道这条规则是为了解决什么问题写的。

!important 是兜底,不是习惯

!important 一旦在项目里变多,样式优先级就彻底失控了——它是唯一一种不受层叠层、不受 specificity 约束、简单粗暴排到最前面的机制,滥用起来排查成本极高。

它适合极少数场景,比如覆盖第三方内联样式、或者工具类库(类似 .mt-0)里故意要强制生效的场景,但不应该成为日常处理优先级冲突的第一反应。

遇到样式不生效,排查顺序大致是:

选择器是否真的命中了目标元素;样式文件的加载顺序;两条规则的 specificity 高低;是否处于不同的 @layer 层级里;scoped 或 CSS Modules 是否影响了实际生成的类名;是否被状态类(.active.disabled)按顺序覆盖了。

按这个顺序查一遍,大多数"样式不生效"的问题都能在两三分钟内定位到,不需要靠加 !important 试错。

几个顺手记下的延伸点

整理规范的过程里,还有几个和优先级判断相关的细节值得记一笔。

:is():where() 这两个去年随 Chrome 88 落地的选择器,今年团队已经在日常写选择器时放心用了,尤其是 :where() 的 specificity 恒为 0 这个特性,拿来写一些"兜底样式"很合适——它不会因为选择器写得复杂就意外提高优先级:

1:where(.card, .panel, .modal) h3 {
2  margin-top: 0;
3}

这条规则不管嵌套多深、选择器列表写多长,specificity 都不会变,后面业务里随便一个 .title 类都能轻松覆盖它,不用担心这条保底规则反而变成了最难覆盖的那一条。

:has() 选择器是另一个值得关注但还不能用的东西——Safari 15.4 三月份就支持了,能实现"根据子元素状态改变父元素样式"这种以前只能靠 JS 补的效果,但 Chrome 目前还得手动打开 chrome://flags 里的实验开关才能用,离默认支持还有距离,内部工具链暂时没敢往这上面靠。

另外听说 Chrome 马上要支持容器查询,团队里私下讨论过等它落地之后要不要拿来替代一部分基于视口宽度的媒体查询——现在很多"组件在窄侧边栏里应该收窄"的场景,只能靠视口宽度硬猜,不是真正意义上"看组件自己的宽度"来响应,但这个东西目前还没到手,只能算是个念想。

Vite 3 今年 7 月刚发布,团队内部工具链评估要不要跟进升级,顺带也把 CSS 相关的构建配置理了一遍——@layer 和 PostCSS 目前配合起来没有冲突,但个别老的 PostCSS 插件对层叠层语法还不认识,升级前得先跑一遍全量样式回归。

规范落地之后

这次组件库样式规范整理完,团队先在内部工具链和那个非核心业务页面上按 vendor/business 分层的思路把 @layer 用了起来,.title 那种撞车的事情目前没再复发。规范文档也没写成一份孤立的检查表,而是把上面这几条判断标准——命名空间什么时候该加、变量什么时候该抽、覆盖第三方样式要不要收拢到一处、@layer 什么时候能替掉硬扛 specificity——直接摊开写在对应的小节里,新人接手一个页面时对照着想一遍,比死记一条条规则更容易上手。剩下没验证的是 @layer 和媒体查询嵌套、和 CSS Modules 哈希类名混用时的边角情况,这些得等铺到主站之后才能一条条摸清楚。