CSS 可维护性:样式失控是边界问题,不是技术选型问题

样式一失控,团队里第一个冒出来的念头往往是“选型没选对”——是不是该上 Tailwind,是不是当初就该用 CSS Modules,是不是 Sass 的嵌套害的。换个工具、重写一遍,仿佛问题就没了。

但我见过太多例子,换了工具照样乱。真正的分界线不在用哪套技术,而在这段样式的归属和影响范围有没有被划清楚。一个 .title 到底属于谁、会波及谁、能不能删,如果没人答得上来,无论底下是 BEM、CSS Modules 还是 Tailwind,迟早都会糊成一片。所以判断一个项目的 CSS 健不健康,我不先看它用什么工具,先看它的归属划得清不清。

这条判断我是被一个近万行的样式文件砸明白的。年初接手一个后台项目,最扎眼的感受不是“写得丑”,而是没人敢删。一个 .title 同时影响首页卡片、弹窗标题和某个早已下线的活动页,改一处像拆线头,怕拉动一整片。那之后我才真信了:CSS 可维护性首先是边界问题,其次才是技术问题。

样式为什么天生容易脏

因为 CSS 默认就是全局生效、彼此影响的。只要你不主动去管这段样式该归谁、能影响谁,它自然会往下面这个方向滑:改一个类名波及多个页面;为了盖住旧规则不断提高优先级;新样式一层层叠补丁;组件样式和页面布局样式搅在一起;某段样式还有没有用没人说得清。

每一步单看都合理——都是在解决眼前那个具体问题。但它们叠在一起,就是耦合度一路走高。这不是谁写得菜,是全局作用域这个默认设定在没有归属约束时的必然走向。

最典型的反模式:再加一层覆盖

项目里的 CSS 常常演化成这样:

1.card-title {
2  color: #333;
3}
4
5.page-a .card-title {
6  color: #111;
7}
8
9.dialog .page-a .card-title {
10  color: #000;
11}

每次覆盖都在解决当下的问题,长期看却是在给整套样式加耦合。以后谁想动 .card-title,都得先全局搜一遍,确认有哪些地方在盖它。

覆盖越叠越多,就会有人祭出 !important

1.card-title {
2  color: #000 !important;
3}

这通常不是在解决问题,而是在宣布之前划的归属已经守不住了。!important 不是绝对不能用,但它该是最后手段,不是日常工具。一个项目频繁靠它救场,往往说明选择器层级、组件职责或第三方覆盖策略已经失控了——注意,失控的是这套归属划分,不是 CSS 这门语言。

判断样式属于哪一层,是治理的第一步

治理的起点,是把样式按职责拆开,让每段代码先回答“我属于哪一层”。我一般分这么几层:基础样式(重置、字体、颜色变量、间距变量)、组件样式(组件自身外观)、页面布局样式(页面结构与排列)、状态样式、工具类样式。

判断依据很实在:这段样式改了会不会影响别的组件?会,说明它站错层了。基础样式负责最底下的约定,组件样式只管组件自己,页面样式只负责把多个组件摆到一起、不该伸手进组件内部。

1:root {
2  --color-text: #1f2937;
3  --color-muted: #6b7280;
4  --space-4: 16px;
5}
6
7.article-card {
8  padding: var(--space-4);
9  color: var(--color-text);
10}
11
12.article-card__meta {
13  color: var(--color-muted);
14}

变量把设计约束收拢到一处,组件类名把样式的归属圈清楚。

层级这件事,现在还能用 cascade layers 显式表达出来。@layer 去年随主流浏览器落地,今年可以放心用了:

1@layer reset, tokens, components, utilities;
2
3@layer tokens {
4  :root {
5    --color-text: #1f2937;
6  }
7}
8
9@layer components {
10  .article-card {
11    color: var(--color-text);
12  }
13}

它解决的不是命名,而是“哪一层该压过哪一层”。判断要不要用它也有个取舍标准:如果你的项目里组件库、工具类、业务样式混在一起、优先级经常打架,@layer 会比不断堆选择器权重健康得多;如果项目很小、根本没有多来源样式,它就是过度设计。层叠层不是万能钥匙,是给“多来源优先级冲突”这个具体场景准备的。

命名不是洁癖,是在声明归属

很多人觉得命名规范只是团队洁癖,不是。命名说到底是在表达归属。

1.title {}
2.content {}
3.button {}

这些名字太泛,几乎注定和别的页面、组件撞车。带上下文的命名会清楚得多:

1.post-card {}
2.post-card__title {}
3.post-card__excerpt {}
4.post-card__action {}

它明说了这几个类属于 post-card。以后看到 .post-card__title,你大致就知道它不该被某个页面随手覆盖——归属写在了名字里。

用不用作用域工具,也是个判断归属的问题,而不是“谁更正确”。CSS Modules、Vue scoped CSS、CSS-in-JS 能减少冲突,但替不了你设计职责。我现在不纠结 BEM、CSS Modules、Tailwind 谁更对:BEM 强调命名上的归属,CSS Modules 提供文件作用域,Tailwind 把常见样式变工具类,它们解决的是不同层面的问题。真正要避免的,是一边用了作用域工具、一边继续写一堆全局覆盖——那等于花钱买了门,又自己把墙拆了。

页面别伸手进组件内部

一个高频的越界,是页面为了调间距直接改组件内部元素:

1.home-page .post-card__title {
2  margin-bottom: 24px;
3}

这样页面就知道了组件内部结构。组件哪天改名或换结构,页面样式就跟着坏。判断它越没越界很简单:页面在动组件“内部”的东西,就是越界了。

更稳的是让页面只管组件之间的排列:

1.home-page__list {
2  display: grid;
3  gap: 24px;
4}

组件内部的标题间距由组件自己定,页面只负责多个组件怎么排。

如果页面确实需要影响组件内部,正确姿势不是穿透,而是让组件主动开一扇门——暴露 CSS 变量、slot class 或明确的 variant:

1.post-card {
2  --post-card-title-gap: 8px;
3}

组件内部用这个变量,外部只能改被允许的部分。内部不是不能被外部影响,是要开一扇正规的门;页面直接穿透内部结构,短期最快,长期最脆。

关于选择器权重本身,这两年也多了个降耦合的手段。:where() 的权重恒为 0,用它包住基础层的选择器,业务侧想覆盖时不必再靠更长的选择器去拼权重:

1:where(.article-card) .title {
2  color: var(--color-text);
3}

这样 .article-card .title 这段基础样式的权重被压到 0,页面里一个普通 .title 就能干净地覆盖它,而不用写成 .home-page .article-card .title 去比谁更长。它和 cascade layers 解决的是同一类问题的不同侧面:@layer 管“哪一层压哪一层”,:where() 管“别让基础样式的权重高到没法覆盖”。判断用哪个,看你是要分层还是只想降权。

变量要分层:原始值和语义值分开

设计变量本身也该分清层级。刚开始大家习惯直接把颜色值塞进 :root,用久了会发现改一个品牌色要动一大片。更稳的是把变量拆成两层:底层是原始调色板,上层是语义 token,组件只碰语义层。

1:root {
2  /* 原始层:只描述“是什么颜色” */
3  --blue-600: #1677ff;
4  --gray-800: #1f2937;
5
6  /* 语义层:描述“用在哪” */
7  --color-primary: var(--blue-600);
8  --color-text: var(--gray-800);
9}
10
11.button--primary {
12  background: var(--color-primary);
13}

好处是换主题、做暗色模式时,只改语义层的指向,组件一行都不用动。这也是归属思路的延伸:组件不该知道“主色是哪个具体的蓝”,只该知道“这是主色”。 原始值一旦暴露给组件,就等于把设计决策泄漏进了业务代码,回头统一调整就无从下手。

状态样式要显式,别靠选择器猜

状态别用复杂选择器隐式推:

1.button.disabled {}

写清楚:

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

或者用 data 属性表达,让组件逻辑和样式直接对齐:

1<button class="button" data-state="loading">提交中</button>
1.button[data-state="loading"] {
2  cursor: wait;
3  opacity: 0.7;
4}

状态一显式,样式该在什么条件下生效就一目了然,不用靠选择器组合去猜。

敢不敢删,也是归属划得清不清的体现

CSS 难维护的另一半,是“不敢删”。项目里到处是看着没用、却没人敢确认的规则。降低这类风险,靠的还是把归属划清楚:组件样式跟组件文件放一起、命名带明确前缀、避免全局通用类名、删组件时同步删样式、评审时盯新增的全局样式。

工具能帮着找出一部分未使用样式,但动态类名、服务端渲染、CMS 内容都会让判断变难。根子还是减少不必要的全局影响——归属越清,能不能删就越好判断。

删样式前我固定做三件事:先搜类名引用,再跑关键页面截图或手工核对,最后把删除和功能改动分成两个提交。样式删除最怕混进大 PR,评审的人根本没法判断这段 CSS 是不是还被某个角落依赖着。

第三方组件覆盖要收口

中后台项目免不了覆盖组件库。最差的做法是各个页面里到处写:

1.page-a :deep(.el-table__cell) {
2  padding: 6px;
3}

页面越多,覆盖越散。升级组件库时,你根本不知道哪些选择器会失效。

我倾向把组件库覆盖收进一个明确目录,比如 styles/vendor/element-plus.css,只放团队认可的统一规则。页面级的特殊覆盖尽量少,非写不可也要写清原因。第三方样式越集中,升级和排查越省心——这同样是归属问题的一种:外部样式的修改入口,只该有一处。

让工具替你守规矩

归属靠人自觉守,迟早会松。人手一多、赶工一急,全局类名、!important、超深选择器就悄悄溜进来了。所以我会把几条硬规矩交给 stylelint 去挡,让它在提交时就拦下来,而不是等评审时才发现。

比如限制选择器最大嵌套深度、禁止 id 选择器、约束 !important 的使用,都能配成规则:

1{
2  "rules": {
3    "max-nesting-depth": 3,
4    "selector-max-id": 0,
5    "declaration-no-important": true
6  }
7}

规则严到什么程度是个判断题,太松形同虚设,太紧天天误报大家就绕过它。我的经验是先从“最痛的那一两条”开始——如果这个项目最大的问题是 !important 满天飞,就先只上 declaration-no-important,等大家适应了再加别的。工具不是来证明谁写得不规范,是把“属于谁、能不能覆盖”这类归属判断从口头约定变成可执行的检查。

评审时人也别缺席。机器能挡格式和权重,挡不住“这段全局样式该不该存在”这种需要理解业务的判断。我评审 CSS 时最关心两件事:有没有新增全局作用域的规则,有没有页面在穿透组件内部。这两样一旦放进来,就是给未来埋的耦合。

判断一个项目的 CSS 健不健康,我现在就问三个问题:这段样式属于谁、影响谁、能不能删。答不上来,就说明归属开始糊了。Tailwind、CSS Modules、Sass、CSS-in-JS 都只是工具,它们能帮你收敛一部分问题,也能在你没有归属意识时,写出另一种样子的混乱。

真正要长期守住的是几条纪律:基础、组件、页面布局、状态各司其职;命名表达归属;页面不穿透组件内部;第三方覆盖收口集中管理;删除和业务改动分开做。工具会换,这几条纪律不会过时——回头看年初接手的那份近万行样式表,真要有人早把它们立住,也不至于让一个 .title 改到没人敢动。