我上一次手写一大段自定义 CSS,是在给一个维护了两年的后台项目做样式重构的时候。当时我打开项目的style.css,发现里面躺着三千多行样式,光是命名混乱的 class 就占了三分之一,什么box、box2、box-right-final,还有一堆!important互相打架。那次重构让我彻底想明白了一件事:最好的写样式方式,就是不写样式代码。
这个结论听起来反直觉,尤其对刚入行的朋友来说,仿佛是在说“你不用学 CSS 了”。其实恰恰相反:我说的不写样式代码,是指不再手写那些随项目不断膨胀、可维护性越来越差的自定义样式,而是把样式工作前置到设计系统、工具类和组件层,让界面在“少写 CSS 甚至不写 CSS”的前提下依然稳定、一致、可维护。这篇文章我想分享的就是这套理念的完整落地方案:为什么值得做、怎么做、踩了哪些坑,以及什么情况下我依然会老老实实手写 CSS。它适合被重复的样式问题折磨过的前端开发者,也适合正准备在团队里推行新样式方案的技术负责人。
1. 从一次样式重构说起:我为什么下定决心不手写 CSS
开头提到的后台项目,实际上是个典型的“样式失控”样本。我要在这里先把问题拆开来看,因为只有先看清手写 CSS 的代价,你才会真正接受“不写样式代码”这套思路,而不是把它当成一句口号。
1.1 命名、级联与特异性:三个问题凑在一起有多可怕
我接手那个项目时,最直观的痛点是 class 命名。老代码里有大量含义不明的类名,为了不破坏原有效果,我只能不断叠加新的规则。比如页面上有个按钮需要变红,我查了半天,发现它同时被三个规则作用:.btn定义基础尺寸,.btn-primary定义主题色,而.highlight在某个角落把背景色强制覆盖成橙色。我想改颜色,得先理清这三层规则的优先级,稍有不慎就“改了没反应”或者“改了别的地方也变了”。
这种问题的根源,是传统 CSS 的三重设计:全局级联(cascade)、选择器特异性(specificity)和共享命名空间。它们本身是 CSS 的能力,但在大型项目里就会变成隐形税。类名一旦多了,你可以靠规范约束,但人不是机器;你可以在 Code Review 里提醒,但样式分散在十几个文件里,谁都没法保证全局不碰撞。
我用一个很简单的比喻来向同事解释这个问题:手写全局 CSS 就像在公厕墙上刷漆。你想刷红色,先得知道前人刷过什么颜色、哪面墙的油漆还没干;刷完之后,还得祈祷所有人都按你的颜色习惯来补漆。结果往往是墙面五颜六色,谁都不敢再动刷子。
1.2 样式漂移与死代码:项目越大越失控
那次重构里我还做了一项统计:把项目里所有标记过color: red、font-size: 14px这类重复规则的代码块汇总起来,发现至少有四成的规则没有在有效 DOM 上命中。它们都是历次改版留下的“死样式”,不报错、不提醒,就安静地躺在样式表里拖慢编译、增加维护成本。
这就是我常说的样式漂移:视觉上看着差不多的按钮,可能来自五套不同的 class 组合;某个页面看似正常,其实依赖的是一个你已经准备删除的.wrapper样式。项目越大,参与的人越多,这种漂移就越严重。改一个基础组件的样式,常常要靠“全局搜索、逐页排查、线上对比”这种原始手段来验证。
最后让我彻底放弃传统写法的,是一次阴影事故:我想给卡片加一层阴影,随手写了个.card { box-shadow: 0 2px 8px rgba(0,0,0,0.15) },结果页面里一半卡片变成“悬浮状态”,因为它们在别处已经有一个.shadow-hover类。那一刻我终于承认:在复杂项目里,把样式当作文本来手写维护,就是在给自己制造永无止境的维护债务。
1.3 先区分一个核心概念:不写样式代码,不等于不重视样式
在继续往下讲之前,我必须先把概念说清楚。很多人听到“不写样式代码”会误以为是不学 CSS、不用样式、让页面裸奔,这是完全错误的理解。
我的意思是:把样式决策从“运行时/编码时”尽量前置到“设计时/构建时”。换句话说,让颜色、间距、字体、阴影这些都变成可复用的、语义明确的 token 和工具类,让开发者在拼装界面时像搭积木一样组合它们,而不是每次都为某个新组件重新定义一套全新的样式规则。当你不写自定义样式时,你其实在写更高层的东西:你在用有限的原子类,组合出无限的结构。
这套理念在工业界已经有很成熟的名词,比如 utility-first(工具类优先)、Atomic CSS(原子化 CSS)。它们代表的并不是“消灭 CSS”,而是“统一管控 CSS 的生成方式”。我后面所有的实践和经验,都是围绕这个认知展开的。
2. “不写样式代码”的本质:把样式工作前置到系统里
如果你认同上面说的痛点,接下来最关键的问题就是:不写样式代码,到底靠什么来渲染界面?答案其实不神秘,靠的是三层前置系统:设计令牌(Design Token)、工具类层、组件层。这一节我会把每一层拆开讲清楚,并解释为什么这套结构能根治命名、级联、死代码这些老毛病。
2.1 第一层:设计令牌,把颜色和间距变成“有意义的符号”
我踩过最普通的坑,就是项目里出现十几种看起来差不多的灰色,以及几乎等宽的 paddings。造成这种情况的根源是:每个人都在按“眼感”填值,而不是按“语义”取值。
设计令牌要解决的就是这个问题。我们预先定义一套颜色、字体、间距、圆角、阴影、透明度等基础值,然后给它们起语义化的名字,比如primary、on-surface、space-md、radius-lg。开发者在写界面时,只能用这些令牌来取值,不允许直接使用魔法数字。这么做有几个立竿见影的好处:
- 全站视觉一致性有保障:换肤只需要改变量,全局都会跟着变。
- 沟通成本降低:设计师说“用 primary 色”,开发不用猜是哪个蓝。
- 新成员上手快:不用翻历史代码找“曾经通用的那个边距”。
有人会问:这不还是写样式代码吗?是,但和手写color: #334455; margin: 16px有本质区别:你写的不是一条孤立的规则,而是在一个受约束的系统里做组合。底层样式的生成可以交给框架,你要做的是调用和组合。
2.2 第二层:工具类,把“样式”变成“积木”
如果说设计令牌是原料,工具类就是积木。它把每一类样式封成一个短类名,比如flex、mt-4、text-sm、bg-surface。你在组装界面时,不需要去 CSS 文件里新建规则,也不需要给元素起一个和视觉效果毫无关系的类名,而是直接在标签上组合这些工具类。
刚开始接触这种写法的人,最不适应的点是 class 变长变多。我的经验是:这种不适感来自习惯,而不是方案本身的缺陷。当你看到一段像下面这样的代码,你会觉得“真啰嗦”,但请再想想,你会一眼就知道它长什么样、要不要换行、有没有加圆角:
<div class="flex items-center gap-4 rounded-lg bg-surface p-4 shadow-sm"> <span class="text-lg font-semibold text-primary">标题</span> <span class="text-sm text-muted">一段辅助说明</span> </div>这段代码没有写一行自定义 CSS,但任何一个熟悉这套体系的人,打开这个文件不到三秒就能脑补出完整界面。它没有命名问题,没有级联污染,没有特异性冲突。更关键的是:删除这段代码,不会有什么样式残留在全局文件里等着下一次改版。
2.3 第三层:组件层,把重复的组合“沉淀下来”
工具类好用,但直接暴露在业务代码里也会有重复和冗长的问题。比如一个卡片组件,如果每次使用都要贴十几串工具类,那其实又制造了一种新的“复制粘贴”。
解决办法是组件层。无论是用 Vue、React 还是其他框架,我们都可以把一套固定的工具类组合封装成一个独立组件,对外只暴露必要的属性。业务方使用组件,不接触内部样式实现。这样做的好处很明显:修改组件内部的工具类,只会影响这个组件,不会扩散到全局;业务方也不需要在每个页面里重复堆样式。
到这里,你可能已经察觉到这套体系是怎么工作的了:预期所有样式都沉淀在系统和组件内部,业务代码里写的大部分是结构、数据和有限的“个性化参数”。样式代码不是被写出来,而是被组装出来的。这就是我标题那句话的真实含义。
2.4 不同样式方案的取舍:一张表看懂各自的边界
肯定有人会问:“那 CSS Modules、CSS-in-JS、预处理器这些方案,跟不写样式代码冲突吗?”我的看法是,它们和工具类方案并不矛盾,甚至可以共存,但定位不同。我做了一个小小的对比表,记录了我接触各种方案后的感受:
| 方案 | 维护成本 | 样式隔离 | 运行时开销 | 上手成本 | 适合场景 |
|---|---|---|---|---|---|
| 手写全局 CSS | 高 | 无 | 无 | 低 | 小站、原型、无团队协作场景 |
| CSS Modules | 中 | 好 | 无 | 中 | 中型应用,组件边界清晰 |
| CSS-in-JS | 中 | 好 | 有 | 中高 | 需要动态样式的场景 |
| 工具类优先 | 低 | 自然隔离 | 无 | 中 | 中大型应用、多团队协作 |
我目前的默认选项,几乎都是工具类优先:它把样式的“作用域”天然锁死在一个个元素上,不需要在 JS 里维护样式对象,也没有额外的运行时开销。CSS Modules 和 CSS-in-JS 更适合那些有大量动态计算样式的场景,但那属于例外,不是默认。
3. 落地实操:在真实组件里完全不写自定义样式
理念说得再美好,也得能落地。这一节我以一个非常常见的卡片组件为例,带你完整走一遍“不写样式代码”的开发流程,包括响应式、交互状态、暗色模式这些实际场景怎么做。
3.1 一个经典卡片:从传统写法改成工具类组合
假设我们要实现一个用户卡片,包含头像、昵称、签名和一个关注按钮。传统写法大概是新建一个 CSS 文件,定义.user-card、.user-card__avatar、.user-card__info,再处理 hover、布局等。这套写法的坏处我在前面已经说过,这里直接看工具类方案:
<div class="flex max-w-sm items-center gap-4 rounded-xl border border-border bg-surface p-4 transition hover:shadow-md"> <img class="h-12 w-12 rounded-full object-cover" src="avatar.png" alt="用户头像" /> <div class="min-w-0 flex-1"> <p class="truncate text-base font-bold text-strong">用户名</p> <p class="truncate text-sm text-muted">一句话签名</p> </div> <button class="rounded-full bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:bg-primary-hover"> 关注 </button> </div>仔细看这段代码里的几个细节:max-w-sm约束卡片宽度,truncate处理长文本省略,flex-1让信息区域撑满剩余空间,transition hover:shadow-md实现悬浮反馈。整个过程中我没有写任何新的样式规则,但该有的设计、状态和布局都有了。
如果你的同事质疑这种 class 串“太丑”,我会提醒他:丑和美是代码习惯问题,而可维护性才是长期成本问题。刚上手的人嫌丑很正常,真正要评估的是“改需求时谁更痛苦”。
3.2 响应式:同一套 class,自动适配不同屏幕
响应式设计是传统 CSS 里最容易写乱的地方之一:一会儿媒体查询,一会儿min-width,不同项目之间还互相矛盾。工具类方案把断点做成了前缀,让你能在标签里直接看到响应式策略。
比如下面这段代码,移动端上下排列,桌面端变成水平排列:
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div class="flex items-center gap-4"> <span class="text-xl font-bold">模块标题</span> <span class="hidden sm:inline text-sm text-muted">这里是筛选状态说明</span> </div> <button class="w-full md:w-auto rounded-md bg-primary px-6 py-3 text-white">执行操作</button> </div>这里的关键点有三个。一是flex-col md:flex-row,将移动端到桌面端的布局变化直观地写在元素上;二是hidden sm:inline,用可见性类替代了“在某断点下隐藏或显示”的旧逻辑;三是我在按钮上做了w-full md:w-auto,让它在窄屏占满整行,在宽屏自动收缩。你不再需要去样式表里找媒体查询块,所有断点逻辑就地陈列。
3.3 交互状态与暗色模式:伪类前缀和主题前缀怎么用
前端最常写的交互样式,其实就是 hover 和 focus。传统写法要在文件里找到对应元素,再用:hover、:focus写一遍;工具类方案直接在标签上加前缀即可:
<button class="bg-primary py-2 px-4 text-white transition hover:bg-primary-hover focus:ring-2 focus:ring-primary focus:outline-none active:scale-95"> 提交 </button>hover:对应悬浮态,focus:ring-2用外圈光晕做焦点提示,active:scale-95让按钮在按下时轻微缩放。这套伪类前缀把样式状态和组件绑定在一起,不用再满天飞!important。
暗色模式现在几乎是标配了。工具类方案通常用dark:前缀来切换主题令牌,比如text-strong dark:text-strong-dark。它本质上不是靠 JS 去覆盖样式,而是通过框架内置的.dark祖先选择器实现令牌切换。只要你把颜色都定义成 token,一套 HTML 就能适配两个主题,完全不需要额外写媒体查询和规则覆盖。
3.4 自定义设计哈希:配置文件里的预处理
从上面的实操你会发现,所有工具类背后其实都对应一个预设值。那如果设计稿突然要求一个特殊的间距或者颜色,怎么办?如果你恪守“不写自定义样式”的原则,答案是在配置文件里扩展主题,而不是在组件里写内联样式。
假设我们需要一个space-13的间距,在传统 CSS 里你会直接写padding: 13px;而在工具类体系里,你可以在配置里新增一个间距单位,然后继续用p-13这样的类名调用。这个配置还能同时输出到 TypeScript 类型定义、设计文档甚至设计师用的组件库里,从源头上保证前端实现和视觉稿一致。
换句话说,你把“写样式”从业务代码里解放出来了,把它变成了一个集中维护的设计 token 工程。业务代码永远只消费这些 token,这样就不会再出现凌驾于设计体系之上的魔法值。
4. 那些让我差点放弃的瞬间:踩坑记录与解决过程
任何前端方案都有学习曲线,工具类优先也不例外。我不是一开始就顺畅上手的,中间踩过不少坑。我把这些经历写出来,是希望你在遇到同样情况时不至于怀疑自己选错了路。
4.1 第一个坑:class 列表越来越长,模板可读性崩了
刚开始我坚决不在组件层做封装,结果每个按钮都带着十几二十个 class,模板代码看起来像一堆压缩包。后来我意识到,这不是工具类方案的锅,而是我缺少“组件边界意识”。
解决方法是:凡是重复出现两次以上的工具类组合,就抽成组件或局部样式组合。在 React 里,我把它封装成<Button>、<Card>;在 Vue 里就是具名组件。对外暴露的 props 尽量语义化,比如variant、size、disabled,内部再映射到工具类。不在业务页面里堆长串 class,才是这套方案的正确用法。
真正合理的代码,应该像这样:业务侧尽量轻薄,组件侧集中管理细节。当你发现自己的页面模板里满屏工具类时,就该反思是不是抽取粒度不够。
4.2 第二个坑:为了不写样式,过度使用内联配置,结果模板更挤了
有些同事学了这个方案后,喜欢把不常用的偏移量、自定义颜色也硬塞进工具类里,导致 class 列表再次膨胀。我记得有个人为了一个特殊间距,搞出一长串mt-4 mr-2 mb-3 ml-5,可读性差到极点。
这个问题的本质是:工具类的粒度要照顾“高频组合”和“低频个体”。高频出现的基础样式用标准工具类;低频的、一次性的布局微调,真的没必要硬抠成工具类。我逐渐接受一个折中做法:主体样式用工具类,个别特殊场景可以使用少量自定义样式,但必须有明确注释并且封装成局部组件。
这里的衡量标准是维护成本:如果这段样式只影响当前文件里一个元素,写在局部不会污染全局;如果会影响多个页面,就必须进系统。死守“绝对不能写一条 CSS”的教条,反而会背离初衷。
4.3 第三个坑:和旧项目共存时,样式重置与作用域冲突
把老项目慢慢迁移到新方案时,最闹心的是全局样式并不干净:可能有全局 reset、老旧的布局类、第三方 UI 库的样式,它们会和新打出的工具类互相覆盖,产生诡异的样式错乱。
我最后用的方案是双轨迁移:老模块保留老样式,新模块引入新的工具类体系,同时开启“作用域隔离”。具体来说,就是在构建配置里给新工具类加上一层命名空间,或者在布局上给新功能区域套一个不类名互斥的容器。等迁移完成后再逐步去掉旧的全局样式。这个过程中我最大的心得是:不要试图一次性翻完三干行老 CSS,那是给自己挖坑;按模块切分、逐片替换,反而更稳妥。
4.4 第四个坑:设计令牌没约束好,又要品味“自由”了
工具类体系的威力很大一部分来自约束,但约束也意味着“用起来没那么自由”。初期团队里总有人忍不住在外层组件里直接写死十六进制颜色和像素值,理由是“先实现再说”。
我后来通过两个方式解决:一是代码评审时直接拦截,看到魔法值就打回;二是为工具类建立可视化文档,团队成员一眼就能看到有哪些颜色和间距可选,避免因为不知道内容而擅自另起炉灶。另一个重要改进是给配置文件加了类型提示:如果你在 IDE 里输错了 color token,它会立刻标红。这让“不写样式代码”变成一种被工具保障的规范,而不是靠自觉的君子协议。
5. 什么情况下我仍然会手写 CSS:别把教条当原则
聊了这么多“不写样式”,我也想坦诚聊聊它的边界。再好的方案也不是万能的,总有一些场景需要写自定义 CSS。我自己的经验是:凡是“一次性、强交互、伪元素深入”的场景,手写 CSS 仍然是必要手段。
第一类是复杂动画。工具类可以覆盖简单的过渡和闪烁,但像路径动画、关键帧逐阶段变化、元素入场顺序这类效果,用工具类拼起来不仅笨重,而且难以阅读。我在项目里会显式地创建一个.animation局部样式文件,把关键帧集中管理,然后用自定义 class 去触发它。这条样式路径不存在命名爆炸风险,因为它是为特定组件服务的。
第二类是伪元素和特殊选择器。比如自定义 checkbox、开关、带气泡角标的悬浮框,它们依赖::before、::after和相邻兄弟选择器,工具类很难简洁表达。我对这些组件的做法是:把伪元素逻辑封装进组件自身的局部样式里,组件对外依然保持单一 class 接口。这样既保留了工具类体系外部接口的统一性,也提升了内部复杂样式的可读性。
第三类是覆盖第三方组件内部样式。很多 UI 库会把内部样式渲染到深层节点上,你无法通过业务组件的工具类直接触达,必须依靠穿透选择器或者部分自定义覆盖。这时候不用有心理负担,直接写一段带注释的自定义样式,明确标注覆盖原因和作用范围,反而比试图用工具类硬试要高效得多。
我给你的建议是:把“不写样式代码”当作默认值,而不是铁律。它在你需要快速迭代、团队协作、统一视觉时最有效;一旦遇到上面这些特殊场景,该手写就手写,只要把自定义样式限制在小范围即可。真正优秀的代码,从来不排斥必要的例外,它排斥的只是无意义的重复。
6. 团队落地:如果要推广这套方案,我的协作规范在这里
经常有朋友问我:“我认可这套思路,但怎么让团队里的其他人也接受?”说实话,技术选型本身远不是瓶颈,人的行为和协作方式才是。我把团队落地中亲自用过且有效的规范整理成几条,它们不一定适合所有团队,但一定能给你提供一些抓手。
6.1 新项目直接用默认方案,老项目渐进迁移
实践经验告诉我,新项目最适合全面引入工具类优先,因为没有什么历史包袱。老项目则不要搞大爆炸式重构,而是挑一些边界清晰的页面模块逐个迁移,比如先把筛选区、卡片区这些相对独立的界面抽离出来,在它们内部使用新体系。等迁移过来的模块足够多,再清掉旧样式文件。
我在推行时给自己的时间预期是一个季度到半年,而不是一天。每次迁移完成后做一次视觉回归,确保 UI 效果没变化。这套节奏给团队成员留足适应时间,也给了代码库一个稳定的过渡窗口。
6.2 Code Review 时的硬性拦截点
我总结了一张简单的评审检查表,专门用于新方案下的代码评审。它不需要特别复杂的工具,但能让团队避免走回头路:
| 检查点 | 要求 | 命中后的处理 |
|---|---|---|
| 魔法颜色值/像素值 | 禁止 | 改为 token 类或配置扩展 |
| 新写全局 CSS 规则 | 禁止,除非有明确理由 | 说明范围,封装局部组件 |
| class 串超过 20 个类 | 建议拆分 | 抽取子组件或组合样式 |
| 引入第二种样式方案 | 禁止混用 | 统一改为团队默认方案 |
| 覆盖第三方组件样式 | 允许,但必须有注释 | 注明覆盖原因和触发条件 |
这份清单看起来简单,但落地的意义很大:它能把基于审美的争论,变成基于规范的检查。当我说“这里不行”的时候,背后是一份全体认可的规则,而不是我个人的口味偏好。
6.3 用文档和工具降低学习成本
再好的规范也没有长眼睛,无法保护那些不熟悉这个体系的新同学。我不会发一条“大家都遵守一下”的消息就结束,而是做一份简短的工具类速查表,里面列出团队最常用的那些工具类和对应的视觉场景。这份速查表可以直接在 IDE 里通过提示显示,也可以做成团队内部的静态页面,任何人拿起来就能用。
我还会在项目里加入构建时的环境校验,比如某些命名不符合规范时直接警告。这个做法的收益很大:它把规范和工具绑定在一起,新同学在提交代码前就能得到反馈,不用等到评审时才被教育。长期下来,团队里几乎没有人再私自发明魔法值,组件质量稳定了很多。
结尾
最后再分享一个小经验:在推行这套方案的头几周,团队里一定会有反对的声音,比如“class 太长”“学了这么多年 CSS 却用不上”“感觉被框架束缚了”。我当时的处理方式,不是说服,而是耐心等待一次真实需求变更。因为只有当你需要改一个公共组件的样式,并且只改一处就全站生效的时候,那批朋友才会自己体会到“不写样式代码”到底有多省事。
现在的我,已经很少会打开样式文件去新增一条规则了。更多时候,我会先打开设计令牌配置,确认颜色和间距在系统里有没有现成定义;有新组件时,先想清楚它的接口粒度;有奇奇怪怪的交互需求时,才考虑单独写一段局部样式。这个习惯坚持一年之后,我发现自己和团队都不再为样式问题加班了。这个标题听起来像句夸张的玩笑,但对我来说,它已经是我日常开发里最真实的工作方式。