假设你正在维护一个文档网站。文章卡片需要宽松的内边距,版本提示要紧凑一点,但两者都应遵守同一套间距单位。你写下两次 calc(),分别乘以三和二,需求就完成了。
后来,侧栏也加入卡片,而且侧栏采用更小的间距单位。计算仍然简单,真正容易变得分散的是约定:哪些地方使用同一套倍率?单位从哪里来?修改一个组件时,是否会连带改变其他组件?
CSS @function 提供了为这段计算命名的方法。当不同组件共享计算规则,输入却各自不同时,把规则与输入分开,就能让修改的责任更清楚。 但如果只是给一次乘法多取一个名字,也可能让读者多跳一层才看懂样式。
先看普通 CSS 已经能做什么
沿用这个假设中的文档网站,两个组件的 HTML 如下。后续示例使用同一组 class:
<article class="site-card">
<h2>安装指南</h2>
<p>从项目的第一个页面开始。</p>
</article>
<aside class="site-notice">
<h2>版本提醒</h2>
<p>升级前请先阅读变更记录。</p>
</aside>先用自定义属性提供共用单位,直接在各组件中计算:
:root {
--space-unit: 0.5rem;
}
.site-card {
padding: calc(var(--space-unit) * 3);
}
.site-notice {
padding: calc(var(--space-unit) * 2);
}根字号为 16px 时,单位是 8px,卡片内边距是 24px,提示内边距是 16px。单位改变,两者一起更新;倍率仍留在各自的规则里。
这份 CSS 已经足够清楚。自定义属性负责提供值,calc() 负责运算,并没有因为出现两次乘法就需要重构。开始考虑函数的理由,应该是这套计算已经形成需要共同维护的规则,而不只是代码看起来相似。
用 @function 为计算命名
以下是可以替换上一段的完整 CSS。先通过显式实参传入倍率与单位,让调用位置看得出依赖:
:root {
--space-unit: 0.5rem;
}
@function --space(--steps <number>: 1, --unit <length>: 0.5rem)
returns <length> {
result: calc(var(--steps) * var(--unit));
}
.site-card {
padding: --space(3, var(--space-unit));
}
.site-notice {
padding: --space(2, var(--space-unit));
}函数名与参数名都以 -- 开头。定义写在顶层,使用时在属性值里直接写 --space(...);不是 var(--space(...)),也不需要 @apply。var(--steps) 与 var(--unit) 读取函数参数,result 描述输出的值。草案的函数定义列出了这些组成部分。
这个接口接受数字倍率与长度单位,并要求结果为长度。--space() 使用两个默认值,得到 0.5rem;--space(3) 得到 1.5rem;--space(3, 6px) 得到 18px。<length> 不包含百分比,类型选择应反映函数真正承诺的能力。
类型与默认值都可以省略,但这里保留它们,是为了让使用者知道传什么。默认单位是函数自己的 0.5rem;若希望它跟随 --space-unit,就像卡片示例一样显式传入。日后把根元素的 token 改成 0.75rem,也不会自动改写函数声明中的默认值。
自定义属性在哪里求值,会影响结果
容易误会的地方是:把计算放进根元素的自定义属性,不等于保存了一个会在每个子元素重新读取 token 的函数。
将下面这段 CSS 单独搭配前面的 HTML,先观察卡片:
:root {
--space-unit: 0.5rem;
--card-space: calc(var(--space-unit) * 3);
}
.site-card {
--space-unit: 0.25rem;
padding: var(--card-space);
}根字号为 16px 时,卡片内边距仍是 24px。--card-space 在根元素解析其中的 var(--space-unit),再被子元素继承;卡片上的 0.25rem 不会倒回去重新解析根元素的这段变量引用。这是自定义属性的求值与继承规则,不是变量失去了响应变化的能力。
不使用函数也能修正:把 calc(var(--space-unit) * 3) 放在卡片本身即可。函数的价值,是当很多地方都需要这套规则时,仍能集中定义。
下面演示另一种接口:倍率是显式输入,单位则从调用的元素读取。这个函数与前面的 --space() 刻意使用不同名称,避免把两种依赖方式混在一起。
:root {
--space-unit: 0.5rem;
}
@function --local-space(--steps <number>: 1) returns <length> {
result: calc(var(--space-unit, 0.5rem) * var(--steps));
}
.site-card {
--space-unit: 0.25rem;
padding: --local-space(3);
}
.site-notice {
padding: --local-space(2);
}现在卡片内边距是 12px,提示仍是 16px。函数内没有名为 --space-unit 的参数或局部变量,因此会读取调用元素上的自定义属性;参数、局部变量与外部属性的关系见草案的作用域规则。
| 写法 | 卡片的单位取自哪里 | 本例卡片内边距 |
|---|---|---|
继承根元素的 --card-space | 根元素解析变量时的 0.5rem | 24px |
--space(3, var(--space-unit)),卡片单位设为 0.25rem | 调用处显式传入 | 12px |
--local-space(3) | 函数读取卡片上的 token | 12px |
显式传参比较容易追踪与测试;读取外部 token 则让调用更短,但增加了隐式依赖。若 --local-space() 是设计系统的一部分,就需要把它会读取 --space-unit 写进使用约定。同名参数或局部变量也会遮蔽外部值,命名时应避免意外冲突。
result 是声明,条件不会提前返回
现在增加一项假设需求:宽度不超过 40rem 时,两个组件的间距都缩为原本的四分之三。这是文档网站的设计选择,不是通用的手机分类。
以下完整 CSS 用局部变量保存倍率,条件成立时覆盖它,再由同一份公式输出值:
:root {
--space-unit: 0.5rem;
}
@function --responsive-space(--steps <number>: 1) returns <length> {
--density: 1;
@media (width <= 40rem) {
--density: 0.75;
}
result: calc(var(--space-unit, 0.5rem) * var(--steps) * var(--density));
}
.site-card {
padding: --responsive-space(3);
}
.site-notice {
padding: --responsive-space(2);
}根字号与默认字号均为 16px、视口为 800px 时,结果是 24px 与 16px;视口改成 600px,结果是 18px 与 12px。--density 留在函数内,不会因此成为元素上可供其他 CSS 读取的 token。
这里仍然遵循声明式模型。result 并不是 JavaScript 的 return:以下函数即使满足窄屏条件,结果也一直是 1rem。
@function --always-one() returns <length> {
@media (width <= 40rem) {
result: 0.75rem;
}
result: 1rem;
}满足条件的声明参与结果选择,后面那个 result 仍然覆盖前面的值,不会在第一个结果处停止执行。条件与执行模型解释了这项差异。把基准值放前面、条件覆盖放后面,或者像上一例集中输出结果,都比较容易阅读。
不过,若卡片与提示不该共用同一个窄屏条件,就应将各自的媒体查询留在组件规则里。给条件起一个共用名称,也是在建立未来一起修改的约定。
类型检查不代表会退回上一行
先用没有默认倍率的函数观察失效:
@function --strict-space(--steps <number>) returns <length> {
result: calc(var(--steps) * 0.5rem);
}
.site-card {
padding: 24px;
padding: --strict-space(red);
}在支持自定义函数的浏览器中,red 不符合 <number>,这个调用会在计算值阶段失效。此时 padding 得到初始值 0,不会回头选取上面的 24px。省略必要实参而调用 --strict-space() 也没有可用倍率。普通属性的失效结果要依其继承行为判断,不能把本例的 0 套到所有属性。计算值阶段失效说明了为什么层叠不会重新执行。
还要区分“没有默认值”与“有默认值”。前面的 --space() 为倍率提供了 1,因此依实参解析规则,传入不符合类型的倍率会尝试使用默认值:--space(red) 的结果是 0.5rem,而不是整段必然失效。这也表示默认值可能掩盖调用错误,开发时仍应检查结果。
类型只约束值的种类,不等于完整的设计限制。负数也属于 <number>;函数算出的负长度可以用在 margin,却不适合 padding。接口的有效范围与最后使用的属性,都要一起考虑。
选择复用方式,要看共同维护的是什么
这几种能力并不互斥。对本例而言,我会先保留 token 与直接计算;当间距规则需要在更多组件中共同调整,才抽出函数。
| 想共同维护的内容 | 可以先考虑 | 需要注意 |
|---|---|---|
| 间距、颜色等可覆盖的值 | 自定义属性 | 声明位置、继承与依赖 |
| 一次运算或限制范围 | calc()、min()、max()、clamp() | 直接写通常最容易读 |
| 接受不同输入的共用计算 | 原生 @function | 参数、调用位置、浏览器支持 |
| 构建时的数据处理与生成 CSS | Sass function | 编译工具与输入数据 |
| 一组相同样式声明 | 共用 class 或选择器分组 | HTML 与样式的组织 |
| 带参数的样式块 | 原生 @mixin 草案 | 草案成熟度及样式块的责任 |
Sass function由编译器执行;原生 CSS 函数参与浏览器的样式求值。Sass 仍可输出包含 var() 或 calc() 的 CSS,让浏览器继续处理,因此不能简化成“Sass 都是固定值,原生才会变化”。
@function 输出的是值,不能用来插入一整组 border、padding 与嵌套选择器。需要理解样式块层级的复用,可以接着阅读本站的原生 CSS @mixin 导读。两者虽在同一份规范里,实现成熟度不能混为一谈。
目前采用,先保留可用的基准样式
截至查证日,兼容性原始数据记录如下。这是功能支持状态,不是使用率,也不是本站访客分布。
| 浏览器 | @function 支持状态 |
|---|---|
| Chrome/Chrome Android | 139 起 |
| Edge | 139 起 |
| Firefox | 尚未支持 |
| Safari/iOS Safari | 数据标记为 preview,不能视为稳定版已普及 |
对需要跨浏览器阅读的网站,可以保留原本能运行的 calc(),用 @supports at-rule(@function)隔开增强版本。下面是可独立搭配开场 HTML 的完整 CSS:
:root {
--space-unit: 0.5rem;
}
.site-card {
padding: calc(var(--space-unit) * 3);
}
.site-notice {
padding: calc(var(--space-unit) * 2);
}
@supports at-rule(@function) {
@function --space(--steps <number>: 1, --unit <length>: 0.5rem)
returns <length> {
result: calc(var(--steps) * var(--unit));
}
.site-card {
padding: --space(3, var(--space-unit));
}
.site-notice {
padding: --space(2, var(--space-unit));
}
}这是保守的增强方式。at-rule() 查询本身也需要支持;即使浏览器能执行函数,若不认识这个查询,仍会留在基准版本。这里两条路径得到相同的外观,代价是暂时维护两份计算,因此值得先评估引入收益。
也不要用 @supports (padding: --space(3)) 判断函数是否存在且能正确执行。声明能被解析,不代表某个调用一定会成功;本次实测中,即使换成未定义的函数名,支持自定义函数的引擎仍返回 true。at-rule() 也只检查规则能力,不替你检查每个实参。
本文示例已在本机 Chromium 153.0.8010.12 与 Playwright WebKit 26.6 验证计算结果,并在 Chromium 关闭 CSSFunctions 后确认基准样式。这不是 Safari 稳定版或 Firefox 的实际运行验证;Firefox 测试进程在本机启动失败,支持表依前述来源列出。
回到卡片与提示区域,值得集中的是已经形成共识的计算规则。函数名应能让维护者看懂这份共识,参数则让差异留在使用的位置。如果抽取之后必须追踪更多隐藏的 token,才能知道一个 padding 为何如此,继续直接写那行 calc(),可能仍是更好的选择。
