用Sass Map与@each自动生成CSS工具类,告别手写百个类名 📅 发布时间:2026/9/19 21:40:12 👁 浏览次数: 做后台管理系统做到第三个月我实在忍不了了。设计稿上间距就分了8档字号6档颜色十几个色系每个都要手写对应的classp-4、pt-8、text-sm、text-primary……一天下来复制粘贴上百个类名体感比写业务代码还累。后来我把思路彻底换了一下用Sass的Map把设计稿里的数值全部整理成配置再用each遍历Map自动生成工具类。这个方案让我的CSS工具类从手写变成“编译”想增减直接改配置编译一跑所有类名自动产出。下面把整套玩法的完整思路、代码实现和踩过的坑一次讲清楚。这套方法特别适合三类人被重复工具类折磨的CSS开发正在搭后台或组件库样式体系的同学以及用了Tailwind但想知道它底层原理、想自己掌控生成逻辑的人。1. 从手写一百个类名到自动生成工具类的痛点与破局1.1 后台项目的工具类困境一版设计稿一千个类名先还原一下让我崩溃的场景。一个电商后台项目设计规范里间距刻度就有8档从4px一直到64px再加上padding、margin各自的top、right、bottom、left方向光间距类名就需要8乘5等于40个字号从12px到28px6档每档还有普通、大号、小号三种weight变体又是近20个颜色更夸张主色、辅助色、状态色加起来15个左右每个还要分默认、浅色、深色三种形态这就是45个类名。这还没算flex布局辅助类、文字对齐类、圆角类、阴影类。我数了一下当时光工具类就手写了200多个。手写两百多个类名的过程不是简单的体力活它还会伴随着大脑的重复劳动。写完.mt-1发现后面还有.mt-2复制粘贴改个数值改到.mt-8的时候前面.mt-3的数值写错了还要回头找。这种工作极其消耗耐心而且越到后面越容易出错。更要命的是这些类名分散在十几个页面里任何一个类名的数值改动都意味着全局搜索、逐个确认。1.2 手写方案的三个死穴重复、联动、错漏手写方案有三个绕不开的死穴。第一个是重复。同样的一个4px在.p-1里写一次在.mt-1里写一次在.gap-1里又要写一次。跨文件搜索的时候你能看到十处一模一样的4px但它们彼此之间没有任何联系出了一处bug就得全局搜索替换改完还胆战心惊怕漏掉某处。这种重复还带来一个隐藏问题新人进来看到代码根本分不清哪个4px是哪个设计规范只能靠猜。第二个是联动。设计规范不是一成不变的产品经理过来说“间距刻度从4px起步改成2px起步”所有工具类的值全部要变。手写状态下的改动成本是一次全局替换你永远不知道会不会替换错地方也不知道替换完会不会影响某些特殊页面。联动还包括新类名的联动今天新增一个颜色手写方案要把这个颜色的所有形态逐个写出来写漏一个是常态。第三个是错漏。人不是机器手写两百多个类名不可能保证每个都正确。我实际项目中就出现过.p-5和.pt-5的数值不一致的情况检查样式时怎么找都找不出原因最终发现是手写时复制错了上一行的值。这种错漏在代码评审里极难被发现因为两段代码隔着几百行评审人不可能一个个去比对数值。破局的思路其实很朴素既然类名是有规律的数值是从同一个刻度体系里取的那为什么不把这些类名当成数据来管理Sass的Map恰好就是干这个的。把设计规范的刻度存进Map用each遍历Map让编译器帮我把类名自动生成出来。2. 把设计稿变成Sass Map工具类生成的数据基础2.1 Sass Map的语法与语义键值对如何表达设计刻度Sass的Map是一种键值对集合语法上类似JSON对象。在Dart Sass现在的主流Sass编译器里Map用括号包裹键和值之间用冒号隔开键值对之间用逗号隔开。比如$spacing-scale: ( 0: 0, 1: 4px, 2: 8px, 3: 12px, 4: 16px, 5: 24px, 6: 32px, 8: 48px, );这个Map表达的语义是“间距刻度”键是类名的后缀值是实际的CSS像素值。为什么用“1”、“2”这种字符当键而不直接用像素值因为类名里不可能写.p-4px虽然也可以但可读性差用抽象刻度名更方便团队记忆也方便后续整体调整值的时候只改一处。如果你愿意键也可以是sm、md这类语义化命名完全取决于设计规范怎么定。Map最核心的价值在于它把“名称”和“值”绑定成了一个整体。在CSS世界里“叫什么”与“值是多少”通常是分散的——类名写在HTML里值写在样式表里中间没有任何逻辑关联。而Map把这对关系集中到了一起这就为自动生成提供了基础。换句话说Map是工具类生成逻辑的“数据源”没有这个数据源生成逻辑就是无米之炊。2.2 从设计变量到数据契约Map让设计稿变成可配置资源进一步讲当页面里有多个工具类体系时可以做一个更大的Map把间距、字号、颜色全部纳入$design-tokens: ( spacing: ( 0: 0, 1: 4px, 2: 8px, 3: 12px, 4: 16px, 5: 24px, 6: 32px, 8: 48px, ), font-size: ( xs: 12px, sm: 14px, base: 16px, lg: 18px, xl: 22px, 2xl: 28px, ), color: ( primary: #409eff, success: #67c23a, warning: #e6a23c, danger: #f56c6c, info: #909399, ), );这个$design-tokens在项目里就是一份“数据契约”UI设计规范是什么样Map就长什么样Map长什么样生成的工具类就是什么样。从这个角度来看Map不只是存数据的容器它成了连接设计稿、CSS生成器和团队成员认知的中间层。你不再需要翻设计稿查某个间距值直接打开这个Map就能知道全貌。相比手写变量这种组织方式有几个明显优势我列个表维度手写变量Map配置组织方式变量散落各处$primary: #409eff满文件飞集中嵌套按类组管理遍历生成无法直接遍历变量集合原生支持each遍历语义表达单个变量只有一个值键值对同时携带名称与值扩展成本新增类名要复制一遍声明新增一个键自动生成对应类这个阶段还没有出现任何CSS类名它只是完成了“把设计规律变成数据”这一步但这恰恰是最关键的一步。数据处理好了后面生成工具类就是水到渠成的事。这也是为什么我在任何项目里都会先花时间整理设计令牌再考虑具体写法。3. each循环一行遍历产出整套间距工具类的核心实现3.1 第一个版本用each生成间距类有了Map剩下的问题就是怎么把Map里的每一对键值变成CSS类。Sass提供了each指令专门用来遍历列表和Map。语法非常直白each $key, $value in $spacing-scale { .p-#{$key} { padding: $value; } .m-#{$key} { margin: $value; } }这段代码干了一件手写时代需要循环复制粘贴几十次的事遍历$spacing-scale里的8组键值每取到一组就用字符串插值#{$key}拼出类名再用$value作为属性值。编译之后产物是.p-0 { padding: 0; } .p-1 { padding: 4px; } .p-2 { padding: 8px; } .p-3 { padding: 12px; } .p-4 { padding: 16px; } /* ... */ .m-0 { margin: 0; } .m-1 { margin: 4px; } .m-2 { margin: 8px; } /* ... */这里有个细节需要理解透彻#{$key}的作用不是普通的拼接字符串而是把Sass变量以字符串形式插入选择器或属性中这种语法叫插值interpolation。Sass里变量默认只能用在属性值的位置如果直接写.p-$key编译会报错因为选择器名不是合法的变量上下文。插值语法就是专为这种场景设计的类似的用法还有属性名插值后面会看到。3.2 用mixin封装生成逻辑避免每个工具类都重写一遍第一个版本完成了padding和margin两个属性但项目中还会有font-size、color、border-radius等一堆属性。如果每个属性都写一段类似的each代码就开始冗余了。这时候应该用mixin把“遍历Map生成工具类”这个逻辑抽象成一个公共能力mixin generate-utility($class-name, $property, $value-map) { each $suffix, $value in $value-map { .#{$class-name}-#{$suffix} { #{$property}: $value; } } } include generate-utility(p, padding, $spacing-scale); include generate-utility(m, margin, $spacing-scale); include generate-utility(text, font-size, $font-size-scale); include generate-utility(rounded, border-radius, $radius-scale);这个mixin接收三个参数类名前缀、CSS属性名、值Map。内部逻辑固定为“遍历Map用前缀和后缀拼类名用属性名和值拼声明”。这样一个mixin就能覆盖绝大多数单属性工具类的场景后面想新增一个工具类体系一行include搞定不用再写循环体。为什么值得封装成mixin因为工具类生成的逻辑其实就两件事从数据中取出“名字”从数据中取出“值”然后按固定模板拼装。这个模板是全项目统一的把它收敛成一个mixin等于把“生成规则”集中管理。以后想在每个工具类里统一加!important或者统一加hover变体改这一个mixin就能全局生效而不是去几十个地方逐个改。3.3 插值语法与属性名拼接理解#{$key}背后的编译过程这一节把编译过程展开说一下。很多人第一次写each生成类名时会困惑为什么$key可以用在类名里而$property用在属性名那里要用#{}包起来。Sass中变量的使用位置有两种。第一种是常见的属性值位置比如padding: $valueSass直接把变量的值塞进去。第二种是选择器名、属性名、字符串中间这类“代码结构”位置这时候必须用#{$var}告诉编译器这里要插入变量的值否则Sass会把它当作字符串的一部分。比如.p-#{$key}如果不加插值写成.p-$key编译结果会是一个类名叫.p-$key的CSS选择器而不是你想要的.p-1。这个区别初学阶段特别容易踩坑我总结了一个口诀看到CSS类名、属性名前缀、媒体查询条件这些位置一律用#{}只有在冒号右边的值那里才能放心裸用变量。提示当选择器或属性名里需要插入Sass变量时绝对不能省略#{}否则Sass会把它当作普通字符串的一部分这类bug在编译产物里很难一眼发现排查起来非常隐蔽。除了插值合理使用Sass内置的Map函数也能让生成逻辑更健壮。比如map-get($map, $key)取指定键的值map-has-key($map, $key)判断键是否存在。这两个函数在后面的嵌套Map方案里是主角。4. 嵌套Map与断点遍历让工具类生成器长成工程级方案4.1 嵌套Map一份配置同时驱动多个CSS属性上面的方案解决了“单属性工具类”的生成但现实项目里还有一种高频需求一个类组名下面要驱动多个属性。最典型的就是margin和padding的四个方向。.px-1要同时设置padding-left和padding-right.pt-1设置padding-top.mx-auto要设置margin-left和margin-right且值都是auto。要处理这种需求Map就得升级成嵌套结构属性值的位置可以存放字符串或者列表$utilities: ( p: ( property: padding, values: ( 0: 0, 1: 4px, 2: 8px, 3: 12px, 4: 16px, ), ), pt: ( property: padding-top, values: ( 0: 0, 1: 4px, 2: 8px, 3: 12px, 4: 16px, ), ), px: ( property: (padding-left, padding-right), values: ( 0: 0, 1: 4px, 2: 8px, 3: 12px, 4: 16px, ), ), text: ( property: font-size, values: ( xs: 12px, sm: 14px, base: 16px, lg: 18px, xl: 22px, ), ), );对应的生成逻辑要能处理“属性是字符串”和“属性是列表”两种情况mixin generate-utilities($utilities) { each $class-key, $config in $utilities { $property: map-get($config, property); $values: map-get($config, values); each $suffix, $value in $values { .#{$class-key}-#{$suffix} { if type-of($property) list { each $prop in $property { #{$prop}: $value; } } else { #{$property}: $value; } } } } } include generate-utilities($utilities);嵌套Map的结构是最外层键是类名前缀值是一个包含property和values的Mapvalues又是一个Map键是类名后缀值是实际CSS值。遍历的时候用map-get从内层Map里取出配置项再套一层each去遍历values。这种“两重遍历”看起来复杂但逻辑链条很清晰先拿到这个类组的配置再遍历这个类组的所有取值。这套结构的好处是一个$utilitiesMap就完整描述了“项目里有哪些工具类、每个工具类作用于什么属性、可取哪些值”相当于一份可编译的文档。新同学接手项目只要看这个Map就能对全局工具类体系一目了然不需要去翻几十个样式文件。4.2 断点Map用media query批量生成响应式版本工具类方案走到这个阶段自然会遇到响应式需求。移动端要.p-2桌面端要.p-4传统写法是在媒体查询里重写一遍规则。如果工具类都是自动生成的那响应式版本也完全可以自动生成。做法是先定义一个断点Map$breakpoints: ( sm: 576px, md: 768px, lg: 992px, xl: 1200px, );然后在原来的生成逻辑外面再包一层断点遍历mixin generate-responsive-utilities($class-key, $config, $breakpoints) { each $bp, $width in $breakpoints { media (min-width: $width) { each $suffix, $value in map-get($config, values) { .#{$bp}\\:#{$class-key}-#{$suffix} { if type-of(map-get($config, property)) list { each $prop in map-get($config, property) { #{$prop}: $value; } } else { #{map-get($config, property)}: $value; } } } } } }注意类名里我用了md\:p-2这种带冒号的写法这是很流行的响应式工具类命名风格。在CSS选择器里冒号是伪类前缀所以要加反斜杠转义写成.#{$bp}\\:#{$class-key}-#{$suffix}。生成出来的CSS是media (min-width: 768px) { .md\:p-2 { padding: 8px; } }注意带冒号的类名在CSS文件里写法是md\:p-2但在HTML里使用时写classmd:p-2即可不需要反斜杠。初学者很容易在HTML里多打一个\导致类名匹配不上。这个设计真正的价值在于断点Map定义清楚之后响应式工具类的数量和断点数量成正比。新增一个断点所有工具类自动多出一个响应式版本不需要额外写业务样式。这里也有一个性能权衡值得说明。响应式工具类生成之后CSS文件中会出现大量相同声明但不同媒体查询的规则。比如.p-2和.md\:p-2前者作用于所有屏幕后者只在768px以上生效。浏览器在匹配样式时只影响选择器匹配效率对实际渲染影响不大因为声明本身很简单。但如果断点很多、工具类很多CSS文件体积会显著增加需要在生成前想清楚哪些工具类需要响应式版本哪些不需要后面第5章会详细讲。4.3 妙用map-get与function动态取色与透明度变体工具类生成不仅是机械地“键值对变成CSS”还可以和Sass的函数结合在一起做一些动态计算。最常见的场景是颜色系统。项目里定义了一组主色我们希望自动生成text-、bg-、border-*三种形式的颜色工具类而且每个颜色还要附带一个10%透明度的hover版本。如果没有Map遍历这个需求意味着每个颜色都要写6个类名三四种颜色就是二十多个类。用Map加function实现只需要定义颜色再写一个函数负责算透明度$colors: ( primary: #409eff, success: #67c23a, warning: #e6a23c, danger: #f56c6c, ); function color-variant($color, $alpha) { return rgba($color, $alpha); } each $name, $base-color in $colors { .text-#{$name} { color: $base-color; } .bg-#{$name} { background-color: $base-color; } .border-#{$name} { border-color: $base-color; } .text-#{$name}-hover:hover { color: color-variant($base-color, 0.85); } .bg-#{$name}-hover:hover { background-color: color-variant($base-color, 0.85); } }这里的color-variant就是一个function接收颜色和透明度内部调用Sass内置的rgba()返回新的颜色值。相比把所有颜色类一个个手写出来这种做法的核心优势是当设计稿调整了主色值整个体系的颜色工具类在重新编译后全部自动更新连hover透明度这种细节都不用手动维护。5. 生产环境实战编译异常、体积膨胀与命名冲突排查手记5.1 问题一编译输出量超出预期初始CSS体积暴涨我先说一个最典型的生产环境事故。我上一版方案引入了完整的$utilities嵌套Map里面有padding、margin、文字大小、颜色、圆角、阴影六个类组每个类组平均8个值再加上4个断点的响应式版本。一开始很爽所有类名都是自动出的但发布之后性能报告显示CSS主文件从18KB涨到了46KB加载耗时多了近一倍。排查链路是这样的先看编译后的产物grep了一下生成规则发现.p-1、.p-2这些基础类被重复生成了很多次——基础版本生成一遍每个断点里又生成一遍。这其实是符合预期的问题在于响应式版本里很多类在项目中根本没人用。比如.xl\:text-xs谁会在超大屏上用超小字号但编译器不知道它只会忠实地把配置里的所有组合都编译出来。根因是“生成规则没有做使用场景过滤”解决思路有两个方向。一是在配置层面裁剪values只放实际会用到的最小集合二是在生成逻辑里只对少数几个高频工具类开放断点版本其他保持不动。最终我采用的做法是把断点版本单独拆出一个$responsive-utilitiesMap里面只保留padding、margin、text-align、display这几个响应式需求最强的属性。这样CSS体积恢复到22KB同时响应式能力并没有明显缺失。这个坑给我们的教训是自动生成工具类的本质是用编译期算力换开发期效率但如果生成范围不控制代价会转嫁到生产环境。每次往Map里加值之前先问自己一句这个值真的会被用到吗5.2 问题二类名冲突导致覆盖链混乱第二个坑出现在引入开源组件库之后。项目里用了某UI组件库它的主题变量里也有--primary这类名称而我自己也定义了text-primary工具类。表面上看类名不冲突但组件库内部的样式优先级和工具类的优先级产生了竞争。某个按钮组件的颜色是组件库默认色我试图用text-primary覆盖它结果怎么都覆盖不上。排查的过程是这样的先打开开发者工具看到组件按钮的color来源是组件库的一条带.el-button类选择器的规则我的.text-primary优先级只有一层类选择器两者权重相同但组件库规则在DOM结构中离元素更近因为按钮内部嵌套所以我的覆盖失败。定位到原因之后我做了两个调整。第一工具类的选择器统一采用“一层类名”的写法没问题但要尽量不去覆盖组件库的内部元素而是通过组件库自己的变量体系去定制第二对需要强制覆盖的场景在生成mixin里加了一个开关允许个别工具类输出时带上!important。这个开关不是默认开启的而是在生成的规则里加了条件判断mixin generate-utility($class-name, $property, $value-map, $force: false) { each $suffix, $value in $value-map { .#{$class-name}-#{$suffix} { if $force { #{$property}: $value !important; } else { #{$property}: $value; } } } } include generate-utility(text, color, $color-map, $force: true);这个案例给我的启发是工具类生成器不是孤立运行的它嵌在整个项目的样式系统里。设计命名时就要考虑到和现有组件库的边界能通过变量定制就不要用工具类硬扛实在要扛就做好优先级管理。5.3 问题三Dart Sass与旧版Ruby Sass的Map兼容性差异再记录一个编译级的坑。公司的老项目原先用的是node-sass搭配Ruby Sass时代的一些语法我引入Map遍历写法后在CI上直接编译失败。报错信息大致是Error: expected (指向的是Map定义那行。原因是旧版Sass对Map的语法支持不完整某些旧版本不认each $key, $value in $map这种解构遍历需要写成each $pair in $map然后配合map-get去取键和值。排查完之后我统一把编译链切换成了Dart Sass并且在Sass文件开头用use sass:map;显式引入map模块。Dart Sass是现在官方维护的编译器Map遍历和类型判断这些特性都支持得很完整。这个坑在团队里特别容易发生本地用新版编译没问题CI用的是老镜像一跑就挂。所以任何涉及Sass新特性的项目都要提前确认全链路的Sass版本一致最好锁死版本号避免“本地好好的CI上就炸了”这种尴尬。5.4 排查方法论让each的每一步都被看见最后一个建议是调试方法论。Map遍历的生成过程对初看的人来说像是黑盒——写了几行代码编译器输出一堆CSS中间发生了什么只能靠推断。实际排查时用Sass的调试函数能节省大量时间。debug可以打印变量的值warn可以输出警告信息error可以主动抛出编译错误。在Map遍历里插一行debug能直接看到当前循环到哪一组键值each $key, $value in $spacing-scale { debug generating class: .p-#{$key}, value: #{$value}; .p-#{$key} { padding: $value; } }编译时控制台会逐行打印生成信息配合map-has-key做判断基本能定位绝大多数生成异常。我把项目里遇到过的几类典型问题整理成了排查速查表现象定位手段根因解决方案编译产物缺失某个类检查Map里是否有对应键值each遍历范围不对用debug打印循环值CSS体积暴涨统计产物体积对比响应式工具类全量生成拆分响应式Map按需裁剪页面样式覆盖失败开发者工具看优先级类名与组件库内部样式冲突统一变量定制按需加!importantCI编译失败查看报错信息定位到Map行Sass版本不一致锁死Dart Sass版本记住一个原则不要用脑子模拟编译器要让编译器告诉你每一步发生了什么。6. 工具类自动化的边界思考什么时候该用Map什么时候该放下6.1 理念对比Sass Map生成器与Tailwind其实是一家人走到这一步如果你用过Tailwind CSS会发现这套Map遍历生成工具类的思路和Tailwind的配置化思想几乎同构。Tailwind的核心配置文件里就是各种类组、值、变量然后框架在构建时按需生成工具类。Sass的Map方法本质上是“手写一个极简版Tailwind”只是没有默认的完整配置而是从零到一按自己的设计规范来定义。维度Sass Map生成器Tailwind配置方式自定义Map框架配置文件依赖零依赖需要安装构建插件生成策略全量编译可用裁剪控制默认JIT按需生成上手成本需要理解Sass特性学习框架的工具类命名可控性完全可控受框架约束这种对比对我个人很有启发原子化CSS并不是某个框架专属的概念底层逻辑就是“把设计令牌数据化再用代码自动生成重复的类名”。你完全可以不依赖任何框架用Sass原生能力实现符合自己项目的工具类体系。好处是零依赖、产出可控、和既有工程链无缝集成坏处是要自己维护Map配置和生成逻辑如果项目里组件样式本来就很少配置成本可能大于收益。6.2 团队协作配置即文档命名即原则工具类自动化带来的另一个隐性价值是团队协作方式的变化。以前设计规范更新要口头通知前端每人改各自的样式现在只要有人改Map配置编译产物全量同步所有页面用到对应工具类的地方自动跟着变。Map成了事实上的“设计规范代码版本”。正因如此命名约定变得异常重要。类名一旦生成HTML里到处都在用改名的成本是指数级上升的。我的建议是类名前缀遵循“短、语义、不歧义”的原则比如text表示字号和文字颜色这种歧义场景要分开命名text-sm管字号、text-primary-color管颜色避免后人看到text-primary时分不清是字号类还是颜色类。这里还有一个容易忽略的点Map里键的命名风格要统一。有人用sm有人用small同一个项目里就乱了。我建议在团队规范里明确约定键名的语义范围最好与设计稿的标注名称保持一致这样设计师和开发对同一个类名能达成共识。6.3 我的个人建议三用三不用根据我在多个项目里的实践工具类自动生成这件事有明确的适用边界我把它总结成“三用三不用”。三用设计规范刻度清晰且成体系的场景后台管理系统这类需要大量标准类名的场景团队已有设计令牌且期望代码与设计规范同步的场景。三不用一次性页面或极小型项目直接写内联样式更高效样式体系极其复杂、组合逻辑远超配置能表达的场景老老实实写业务CSS团队没有统一设计规范、各写各的情况下引入工具类自动生成只会放大混乱。关于“该不该用”的判断标准我最后的体会是工具类生成解决的是“数量多且规律强”的重复劳动问题而不是“样式复杂”的问题。用错了场景配置Map的时间比手写还久用对了场景一天的工作十分钟搞定。每次我重构工具类体系时都会回头验证一个原则改动一处Map配置全站样式同步变化这带来的不仅是效率提升更是把设计规范的变更风险从“有没有漏改”转移到了“配置本身的正不正确”上。这个转移本身就是巨大的收益。最后分享一个我在实际项目中高频使用的小细节。生成响应式工具类时类名里用md\\:这种带反斜杠的写法在HTML里写起来其实不好看很多人会写错。后来我干脆额外封装了一个小函数专门负责拼接带转义的选择器名代码里就不用再手写反斜杠了既减少出错又提升可读性。工具类自动化的终点是把一切重复脑力劳动都交给代码让人只做决策不做搬运。