关于 web-styling-scss-modules
面向使用 SCSS Modules 编写组件样式的开发者,介绍局部作用域、Sass 模块组织、层叠层,以及构建时值与运行时自定义属性的区别。
主要功能
- 01局部作用域模块类
- 02使用 @use 和 @forward 组织 Sass
- 03用层叠层控制样式覆盖
- 04用属性选择器表示组件状态
- 05构建时值与运行时属性
- 06混入与有意设置的全局选择器
适用场景
- 01编写样式隔离的 UI 组件
- 02整理跨文件 Sass
- 03允许应用样式覆盖库样式
- 04通过自定义属性支持主题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-styling-scss-modules安装后在 Claude Code 中输入/web-styling-scss-modules确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.yamlreference.mdexamples/advanced.mdcore.mdmodules.mdpatterns.mdtheming.mdtokens.md详细介绍
SCSS 模块模式
快速指南:
*.module.scss文件是局部作用域样式表:其类名会在构建时重写,并通过导入的对象访问,因此组件的样式不会与其他组件冲突。将库样式包裹在@layer components {}中,这样应用就能通过未分层规则覆盖它们,无需比拼优先级。使用@use和@forward加载 Sass——@import已弃用,即将移除。使用属性选择器设置状态样式,而不是为每种状态创建一个类;任何必须无需重新构建即可更改的内容,都应放在自定义属性中,而不是 Sass 变量中。
详细资源:
- examples/core.md — 局部作用域和
styles对象、层叠层设置、分层组件样式、未分层应用覆盖样式 - examples/modules.md —
@use、@forward、命名空间、with配置、内置模块、从@import迁移 - examples/patterns.md — 模块文件结构、数据属性状态、混入、全局样式、图标尺寸
- examples/tokens.md — 在模块样式中读取设计令牌、组件作用域自定义属性
- examples/theming.md — 让模块不包含主题分支逻辑
- examples/advanced.md —
:has()、:global()、嵌套深度 - reference.md — 反模式及修正方法、
@use与@import的对比、Sass 弃用说明
<critical_requirements>
编写 SCSS Modules 代码前
通过导入的对象访问每个类。 样式表中的名称并非浏览器实际接收的 CSS 中的名称,因此字面量 className="button" 不会匹配任何内容——不会报错,没有样式,也没有线索。
将库组件样式放在 @layer components {} 中。 应用可以用普通的未分层规则覆盖它们,无需争夺优先级,也不受样式表加载顺序影响。
使用 @use 加载 Sass 文件。 它会为加载的内容添加命名空间,在每次编译中只评估每个文件一次,并将以下划线 _ 开头的成员设为私有——这些都是 @import 不具备的功能,而且自 Dart Sass 1.80.0 起,@import 已弃用,并将在 3.0.0 中移除。
必须在不重新构建的情况下更改的值,应保留为自定义属性。 Sass 变量和 darken() 会在编译时解析,因此运行时无法访问它们。
使用组件自身类上的属性选择器设置状态样式。 &[data-state="open"] 会将所有状态保留在样式表中;而为每种状态设置一个类,则会将组件外观分散到用于选择这些状态的标记中。
</critical_requirements>
自动检测: .module.scss、.module.css、styles. 类访问、@use、@forward、@layer components、:global()、@mixin、@include、!default、sass:math、math.div、sass:color、color.scale、Sass 中的 @import、sass-migrator
适用于:
- 在
*.module.scss中编写组件样式,并使用局部作用域类名 - 使用
@use和@forward组织跨文件 Sass,并从@import迁移 - 使用层叠层控制库样式与应用覆盖样式之间的优先级
- 判断哪些内容应使用构建时 Sass 值,哪些应使用运行时自定义属性
- 将重复声明提取为 mixin
- 使用
:global()有意跳出模块作用域
其他部分处理:
- 有哪些设计令牌、它们如何分层以及如何命名——模块样式通过
var()读取它们,但不会定义它们 - 如何在运行时选择、持久化和应用主题——模块的职责是不包含任何主题分支,因此无需接收指示即可适配
- 如何将一组组件属性转换为类列表——模块会_导出_类名;由什么来组合这些类名属于另一项工作
- 覆盖整个设计空间的生成式工具类——模块系统为手写 CSS 限定作用域,这是另一种权衡
作用域隔离正是关键。因为唯一性由构建过程负责,样式表可以使用简短直观的名称,无需命名约定来维系整个代码库——不需要 BEM 风格的前缀,而且删除组件时也会一并删除其样式。
由此得出两个结果,本技能的大部分内容都由此延伸。不会意外产生全局效果,因此任何真正的全局内容——重置样式、层声明、设计令牌——都应有意在模块之外编写。而且一个模块无法访问另一个模块的名称,因此共享决策应通过 @use 以 Sass 成员的形式传递,或通过层叠以自定义属性的形式传递,而不是依赖两个文件约定使用的类名。
核心模式
模式 1:局部作用域——类名会成为对象键
导入 *.module.scss 文件会得到一个对象,其键是文件中声明的类,其值是构建过程生成的全局唯一名称。这个对象是访问实际出现在 CSS 中的名称的唯一途径。
// button.module.scss
.button {
padding: var(--space-md);
}
.sizeSm {
padding: var(--space-sm);
}
// button.tsx
import styles from "./button.module.scss";
// styles.button is something like "button-module__x7f2q" — never "button"
<button className={`${styles.button} ${styles.sizeSm}`} />;
生成名称的格式由构建设置决定,因此任何内容都不得依赖它。键是编写时使用的类名:除非构建配置为添加 camelCase 别名,否则 .size-sm 应通过 styles["size-sm"] 访问——这也是样式表一开始就使用 camelCase 命名类的原因。
完整代码:examples/core.md
模式 2:级联层,实现可预测的优先级
只需声明一次层顺序,并将库的组件样式放在 @layer components 中。应用样式保持未分层状态;无论加载顺序如何,级联都将其排在所有层之上。
// layers.scss — loaded first, before anything it names
@layer reset, components;
// button.module.scss — a library component
@layer components {
.button {
background-color: var(--color-primary);
color: var(--color-primary-foreground);
}
}
// an application stylesheet — no layer, so it wins
.myCustomButton {
background-color: var(--color-accent);
}
完整代码:examples/core.md
模式 3:Sass 模块系统
@use 会以命名空间加载文件,每次编译仅加载一次。@forward 会通过另一个文件重新导出一个文件的成员,目录正是通过这种方式获得统一入口。
// styles/_index.scss — the public surface
@forward "layers";
@forward "design-tokens";
@forward "mixins";
// button.module.scss
@use "../../styles" as s;
@layer components {
.button {
padding: s.$space-md;
@include s.focus-ring;
}
}
@use "…" as * 会移除命名空间,仅适用于在各处使用的基础模块。使用 with 配置的模块会在整个编译过程中保留该配置——首次加载时的配置会生效。
完整代码:examples/modules.md
模式 4:构建时 Sass 值与运行时自定义属性
浏览器看到 CSS 时,Sass 变量早已不存在。任何需要响应主题、密度设置或媒体条件的内容,都必须使用自定义属性。
@use "sass:math";
// Build-time: a value computed once, that never changes at runtime
$column-width: math.div(100%, 12);
.grid {
grid-template-columns: repeat(12, $column-width);
// Runtime: reassigning this token elsewhere re-styles the element
gap: var(--space-md);
background: color-mix(in srgb, var(--color-primary), black 5%);
}
math.div() 替代 /,因为 / 可能与 CSS 的斜杠分隔符产生歧义(grid-template: 1fr / 2fr)。对于真正的构建时颜色计算,color.scale() 替代 darken() 和 lighten();但主题需要能够更改的颜色应使用自定义属性,并与 color-mix() 或相对颜色语法搭配。
完整代码:examples/modules.md
模式 5:读取令牌,绝不重新声明
模块通过 var() 使用设计令牌。只有在某个值需要在组件_内部_变化时,才声明自己的自定义属性——并且将其声明在组件自身的类上,使其作用范围限定在该子树中。
@layer components {
.button {
// Component-scoped: the variants below reassign it
--button-accent-bg: transparent;
padding: var(--space-md);
font-size: var(--font-size-body);
color: var(--color-text-default);
background: var(--button-accent-bg);
}
.outline:hover {
--button-accent-bg: var(--color-background-muted);
}
}
重新声明已经存在的令牌(在 --radius-sm 旁边写入 --card-radius: 0.5rem),会创建第二个值,使其不再跟随原来的值变化。
完整代码:examples/tokens.md
模式 6:用数据属性表示状态
一个类名用于标识组件,属性选择器用于标识组件状态。这样状态就能组合使用,无需为每种组合都创建一个类。
.dropdown {
&[data-open="true"] {
display: block;
}
&[data-size="large"][data-variant="primary"] {
padding: var(--space-xlg);
}
&[aria-invalid="true"] {
border-color: var(--color-destructive);
}
}
使用字符串值(data-state="open"),而不是没有值的布尔属性——属性值选择器对每种状态的读取方式都一致,而且 DOM 会显示组件当前的状态。
完整代码:examples/patterns.md
模式 7:用混入复用重复的声明块
当多个组件需要相同的声明块时,混入是合适的方案——尤其是无障碍相关声明,因为只复制其中一部分就会造成错误。
@mixin focus-ring {
&:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}
}
@mixin sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.button {
@include focus-ring;
}
等到第三处使用时再提取,而不是第一次使用时。只使用一次的混入,不过是多绕了一步的 CSS 代码块。
完整代码:examples/patterns.md
模式 8:与主题无关的组件样式
模块只定义角色,不定义模式。样式表只需编写一次,每个主题都通过模块已读取的令牌来应用样式。
@layer components {
.button {
background-color: var(--color-primary);
color: var(--color-primary-foreground);
// No `.dark &`, no theme conditional — the tokens carry the mode
&:hover {
background-color: var(--color-primary-hover);
}
}
}
模块中的 .dark & 选择器会将该模块与一种主题实现绑定,而每增加一种主题,就意味着每个包含此选择器的组件都要增加一个分支。
完整代码:examples/theming.md
模式 9:审慎处理转义和嵌套
:global() 可让选择器不受作用域限制,从而能够访问第三方类名。使用 & 嵌套可让修饰符紧跟其所修饰的对象。
.component {
padding: var(--space-md);
// Reach a class this module does not own
:global(.third-party-widget) & {
padding: 0;
}
// `:has()` styles a parent from its children — no JavaScript needed
&:has(input:focus) {
background: var(--color-background-muted);
}
}
嵌套层级尽量控制在约三层:每增加一层都会提高优先级,并让生成的选择器与标记中出现的名称相距更远。
完整代码:examples/advanced.md
<red_flags>
危险信号
在构建时失败:
- 在新的 Sass 中使用
@import— 自 Dart Sass 1.80.0 起已弃用,并将在 3.0.0 中移除。@use可替代它;sass-migrator module --migrate-deps可以完成大部分转换。 - 用
/做除法(100% / 2)— 已弃用,且容易与 CSS 的斜杠分隔符混淆。使用sass:math中的math.div()可明确表达意图。calc()中的/不受影响。 - 在样式规则后使用
@use— 它必须位于除@forward和@charset之外的所有内容之前,因此如果它上方有规则,就会导致编译错误。变量声明是文档明确说明的例外,因为使用with配置模块时,需要先在上方声明变量。 - 缺少层声明 — 必须在分层内容之前处理
@layer reset, components;,否则顺序就取决于各文件碰巧建立顺序。
静默地不起作用:
- 在标记中将类名写成字符串(
className="button")— 输出的名称是自动生成的,因此字面名称匹配不到任何规则。不会报错,元素也会以无样式状态渲染。 - 将带连字符的类名作为属性读取(
styles.size-sm)— 这会被解析为减法,而不是查找。类名使用 camelCase,或使用styles["size-sm"]。 - 在模块中声明类,但从未通过对象读取 — 该类的作用域限定在一个无人引用的名称上,因此会作为无用 CSS 打包,搜索类名也找不到它。
影响主题或覆盖能力:
- 组件样式没有层包装 — 优先级会取决于加载顺序,而应用要覆盖它,就需要使用特殊的优先级技巧,后续覆盖还必须胜过这个技巧。
- 将应用样式包装在
@layer components中 — 它们会处于库的优先级,因此无法再覆盖库,而覆盖库正是这些样式存在的目的。 - 对可切换主题的颜色使用 Sass 颜色函数(
darken($primary, 10%))— 结果在构建时就已确定,因此运行时切换主题无法影响它。color-mix()和相对颜色语法则会保持动态。 - 在模块中使用主题选择器(
.dark & { … })— 这会让组件绑定到一种主题机制,而每增加一种主题,每个使用该机制的组件都要增加一个分支。 - 硬编码颜色、间距和圆角 — 这些未追踪的值不会随令牌变更而变化,也不会被审计发现。
容易出乎意料的行为:
- 未分层的样式优先于所有层,不受加载顺序影响。这正是应用覆盖所依赖的机制,也因此,意外未分层的库规则很难覆盖。
- 在同一层内,仍按常规特异性规则处理——层只在彼此之间决定优先级。
- 使用
@use "…" with (…)配置的模块,即使被其他文件以未配置的方式加载,也会保留该配置。首次加载优先。 - 前缀为
_或-的成员是文件私有的,通过@use和@forward都不可见。 - 内置模块变量(例如
math.$pi)是只读的。 :global()会移除其包裹选择器的作用域,因此模块深处的:global块会悄然重新引入模块原本要避免的那些冲突。