假设你正在维护一个文件网站。首页的文章卡片有细边框、圆角与留白;旁边的版本提示,也有几乎一样的外框。你把那几行 CSS 复制过去,改了边框颜色,两个区域便各自成立。
几周后,设计调整了整站的容器间距。卡片改好了,提示区域却漏了一处。这时很容易想:如果当初抽成 mixin,就不会忘记了。
但也可能出现另一种情况:提示区域需要更紧凑,卡片却应该保留舒适的阅读空间。若两者已经绑在同一份抽象里,一次修改反而会波及不该改的地方。
重复的代码提供了一条线索,接下来仍要判断:这些样式是否代表同一个规则,未来是否应该一起改变? 原生 CSS @mixin 值得关注,正是因为它让我们有机会替一组样式规则命名,并在使用的地方传入差异。
先找出需要一起变动的规则
沿用这个假设中的文件网站,先把需求说得具体一点:文章卡片与版本提示都需要一层安静的外框,圆角一致;边框与标题使用相同的强调色,提示可以有比较紧凑的间距。
两种容器承载的内容不同,却共享一部分视觉规则。问题不只是如何消除那几行重复,而是哪些决定应该有共同来源,哪些应该留在各自的组件里。
例如,圆角代表整站的外观约定,可以集中管理;内边距则可能与内容密度有关。至于卡片要不要显示摘要、提示是否需要操作连结,都是各自的责任,没有必要为了共用外框一起收进来。
这个区分会决定抽象的大小。如果连「共用的是什么」都还说不清楚,先保留几行重复通常比较容易调整。等使用情境出现,才有依据把真正稳定的部分收在一起。
自定义属性与共用 class,已经能走多远?
先不用 mixin。以下 HTML 是后续范例共同的结构:
<article class="site-card">
<h2 class="site-panel-title">安装指南</h2>
<p>从项目的第一个页面开始。</p>
</article>
<aside class="site-notice">
<h2 class="site-panel-title">版本提醒</h2>
<p>升级前请先阅读变更纪录。</p>
</aside>只靠选择器分组与自定义属性,就能把外框集中起来。这段使用的是既有 CSS:
:root {
--panel-radius: 0.5rem;
}
.site-card,
.site-notice {
border: 1px solid var(--panel-accent, #777777);
border-radius: var(--panel-radius);
padding: var(--panel-space, 1.5rem);
}
.site-card > .site-panel-title,
.site-notice > .site-panel-title {
margin-block: 0 0.75rem;
color: var(--panel-accent, #777777);
}
.site-notice {
--panel-accent: #45687d;
--panel-space: 1rem;
}这里有两种复用:分组的选择器让不同元素共用声明,自定义属性则提供可替换的值。var() 的工作是在属性值里带入变数;它本身不会把一整组 border、padding 与嵌套规则插进另一条规则。自定义属性规格定义了这个替换机制。
如果能调整 HTML,也可以把共同规则改放在 .site-panel,分别使用 class="site-panel site-card" 与 class="site-panel site-notice"。共用 class 表达外框,另一个 class 表达组件自己的角色;自定义属性继续控制颜色与内边距。
对这个只有两种容器的小网站,我会先选这种做法。声明集中、差异看得见,也没有额外的调用关系需要追查。日后增加第三种容器,如果它确实共享这层外框,加入同一个 class 就很清楚。
当共用规则要在多个组件样式里组合、各处需要明确传入不同设定,或不方便替不同来源的 HTML 都加上共同 class 时,mixin 才提供另一种组织方式。它让应用的位置留在 CSS 中,透过名称表达组件选用了哪一组规则。
| 想复用的内容 | 可以先考虑 | 需要维护的关系 |
|---|---|---|
| 颜色、间距、圆角等值 | 自定义属性 | 变数的来源、继承与覆盖 |
| 一组完全相同的声明 | 共用 class 或选择器分组 | 哪些元素应该共同接受修改 |
| 带参数、可在不同规则中组合的样式区域 | 原生 mixin 草案 | 调用位置、参数与区域的责任 |
三者可以搭配使用。mixin 里仍然可以引用设计 token,共用 class 也可以是 mixin 的应用位置。选择的重点是让变更的责任更清楚。
用 @mixin 定义规则,在 @apply 的位置应用
把外框改写成 mixin,最小形式如下。这段与后续的 mixin 范例都属于草案语法:
/* 草案语法:定义与应用 */
@mixin --panel-frame() {
border: 1px solid #777777;
border-radius: 0.5rem;
padding: 1.5rem;
}
.site-card {
@apply --panel-frame();
}
.site-notice {
@apply --panel-frame();
}@mixin 定义可复用的区域,名称以 -- 开头;@apply 则指定应用的位置。依草案的定义与应用规则,mixin 定义不能放在一般样式规则内,@apply 则用在样式规则或其中的嵌套群组规则内。上例因此将定义放在顶层,把调用放进各自的选择器。
可以先把它理解成:在调用位置引入那组声明与规则。不过,这只是帮助阅读的模型;涉及参数、变数与作用域时,不能把它当成任意文字的复制贴上。
没有参数时,目前草案允许省略括号,例如 @apply --panel-frame;。本文保留括号,方便辨识调用,也让下一步增加参数时保持一致。这不是把 class 名称传给 @apply;这里引用的是定义好的 mixin 名称。
接著,把强调色与内边距列为可调整的部分。下例是取代上一版定义的完整 CSS,继续搭配前面的 HTML:
/* 草案语法:参数、默认值与嵌套规则 */
@mixin --panel-frame(
--accent <color>: #777777,
--space <length>: 1.5rem
) {
border: 1px solid var(--accent);
border-radius: 0.5rem;
padding: var(--space);
& > .site-panel-title {
margin-block: 0 0.75rem;
color: var(--accent);
}
}
.site-card {
@apply --panel-frame();
}
.site-notice {
@apply --panel-frame(#45687d, 1rem);
}--accent 接受颜色,--space 接受长度;冒号后面是省略实参时使用的默认值。卡片采用默认设定,提示则传入蓝灰色与较小的内边距。类型与默认值的写法沿用草案的参数语法,例如 1.5rem 符合 <length>,百分比则不属于这个类型。
这两个参数刻意保持少量,因为它们对应前面已经确认的差异。圆角仍是共用规则,标题仍跟随外框的强调色;读调用位置,就能看出这个组件选了哪种变化。
参数虽然透过 var() 读取,却不等于在元素上新增一般、公开的自定义属性。依目前的参数模型,同名参数会遮蔽外部值;它们的私有作用域也限制了哪些元素能取得值。上例只处理容器及其子元素;如果改成选取兄弟元素,就不能直接假设参数仍可用。
嵌套的 & > .site-panel-title 让「外框与标题共用强调色」成为同一份规则。这也建立了一项结构约定:标题必须是容器的直接子元素。若某些组件没有这个结构,就应该拆开标题与外框,而不是持续扩充选择器来包容所有情况。
用 @contents 共用条件,把内容留给调用者
现在加入另一项需求:画面变窄时,卡片与提示都要调整,但调整的内容不一样。卡片需要更紧密的排列,提示则要缩小内边距、加强侧边框。
如果这是整个页面共同采用的窄版条件,可以把条件命名,透过 @contents 留出放入样式的位置。以下草案范例接在上一节的 CSS 后面:
/* 草案语法:共用条件,各自提供内容 */
@mixin --compact-layout() {
@media (width <= 40rem) {
@contents;
}
}
.site-card {
@apply --compact-layout() {
display: grid;
gap: 0.75rem;
padding: 1rem;
}
}
.site-notice {
@apply --compact-layout() {
padding: 0.75rem;
border-inline-start-width: 3px;
}
}@apply 后面的区域是调用者交进来的内容,@contents 是放入该内容的位置。草案的 @contents 定义允许 mixin 接受这种区域,因此共用的是「何时进入窄版」,而各自如何改变,仍留在卡片与提示的样式里。
40rem 是这个假设案例选定的版面条件,不是通用的设备分类。若卡片与提示应该依不同容器的可用空间调整,硬共用同一个 viewport 门槛就会失去意义。此时应重新检查条件,而不是因为已经有 mixin 就继续使用。
@contents 也可以提供默认区域:只有调用者完全没传内容时才使用;传入空区域则表示有传内容,只是内容为空。本文的 --compact-layout() 没有默认内容,所以只调用它、却不提供区域,不会替组件增加任何声明。
对目前这两个组件,直接写一个 @media 并在里面列出两条规则,依然很容易理解。条件 mixin 的价值,要在多处确实共享同一个版面判断、又希望样式留在各自组件附近时才会出现。多一个名称,应该换来更清楚的意图。
抽象的成本,会出现在修改的那一天
假设接下来又增加促销卡片、错误提示与侧栏摘要。你可能想替 --panel-frame() 加上边框粗细、背景、图示位置、标题大小,再补几个开关控制特殊情况。
参数越来越多时,值得重新检查的是它们的关系。如果多数调用都要覆盖一半设定,或只有某个组件使用一组特殊参数,原本的「共用外框」可能已经变成数个需求的集合。此时,把规则拆小,甚至让某些组件保留自己的声明,都可能更好维护。
我会用三个问题检查这个界线:
- 修改会一起发生吗? 圆角调整应该影响所有容器,还是只影响某一种内容?
- 调用位置能说明意图吗? 颜色与内边距容易理解,一长串位置实参则需要反复查定义。
- 例外放在哪里最清楚? 只属于提示区域的侧边框,可以留在提示的规则中,不必升格为全站共用的选项。
抽成 mixin 后,维护者至少需要连起调用位置、定义与传入的值。若 mixin 里又调用其他 mixin,追查路径还会变长。集中修改带来的便利,必须和这个阅读成本一起考虑。
混入的声明也仍然需要与其他样式共同运作;应用了 mixin,不代表原本的 cascade 问题就消失。例如在这个简单案例中,可以把组件自己的圆角声明放在 @apply 后面,表达局部差异;若另一个来源又设定同一属性,仍要查看来源、layer、选择器权重与顺序。抽象不应让实际生效的值更难解释。
更重要的是,class 与 mixin 都会建立共同修改的关系。两处今天长得相似,并不足以证明它们永远应该同步。先替规则说出一个合理的名字,再选择实现工具,往往比先追求最少行数可靠。
现在值得学的是哪一部分?
如果你熟悉 Sass,这个概念不陌生。不过,Sass 的 mixin使用 @include 应用、以 $ 命名参数,由 Sass 处理后输出 CSS;本文的原生提案使用 @apply、-- 名称与 CSS 的值系统。理解用途上的相似,不能直接推导出语法、作用域或迁移方式相同。
同一份 CSS 草案中的 @function则处理值:例如依输入计算一个长度,供 padding 等属性使用。@mixin 处理的是样式区域,可以包含多项声明与嵌套规则。要复用的东西停在哪一层,会影响应该选择哪一种能力。
草案本身也仍在整理。本文依据第 5.1 至 5.4 节的定义撰写范例;第 5.5 节仍有待配合新私有属性模型更新的注记,并保留旧 @result 写法。因此,这篇文章没有把内部替换步骤当成已定案的行为,也没有把语法解说当作相容性保证。
回到一开始的文件网站,我仍会先采用共用 class 或选择器分组,让自定义属性承担明确的值差异。等规则真的需要跨组件组合、需要一个清楚的参数界面,再评估 mixin 的表达是否值得。
原生 @mixin 提案提供了一个值得理解的方向:CSS 的复用可以从单一值,延伸到有名字、可调整的一组规则。要让这个能力带来好处,我们仍得先回答那个最初的问题:下一次修改时,究竟有哪些样式应该一起改变?
