Back

使用 @property 处理带类型的 CSS 变量

使用 @property 处理带类型的 CSS 变量

CSS @property at 规则为自定义属性赋予类型。一旦完成注册,浏览器将在每次赋值时进行验证,在动画过渡期间对值进行插值计算,并在输入无效时回退到预定义的初始值。@property 填补了 CSS 变量在验证和插值方面的空白——但它以一种大多数开发者意想不到的方式改变了失败模式:用静默回退取代了明显可见的错误值,且不抛出任何错误。

本文涵盖以下内容:三个描述符及其必填要求、支持的类型注册表及具体示例、静默回退行为及其触发时用户实际看到的效果、超越常规旋转演示的带类型属性动画、等效的 CSS.registerProperty() JavaScript API、当前浏览器支持情况,以及何时不值得使用注册机制的判断标准。

核心要点

  • 未经类型化的 CSS 自定义属性本质上是字符串;@property 赋予其类型,使浏览器能在每次赋值时进行验证。
  • syntaxinherits 描述符始终为必填项;只要 syntax 不是 "*"initial-value 也是必填项——这是 CSS Properties and Values API Level 1 规范的要求。
  • 当已注册属性接收到与其声明的 syntax 不匹配的值时,浏览器会静默回退至 initial-value,不产生任何控制台错误,也不提供任何回退触发的视觉提示。
  • 带类型的属性可在过渡和动画中进行插值;未经类型化的自定义属性则不行,因为浏览器将其视为两个不透明字符串,无法计算数值中间点。
  • @property 自 2024 年 7 月 9 日起已成为 Baseline 新可用特性——2024 年之前参考资料中的”实验性”说明已经过时。

问题所在:未经类型化的自定义属性只是字符串

标准 CSS 自定义属性在被替换到实际属性之前,保存的是未经解析的字符串。浏览器无法判断 --accent 究竟是颜色、长度还是关键字。它不会在声明处进行验证,无法在动画期间对两个值进行插值,也不会在值的结构与预期用途不符时给出任何反馈。

第三个缺陷在实践中影响最大。以下是一个在 text-shadow 中使用未经类型化属性的例子:

.card {
  --accent: red;
  text-shadow: 4px 2px 5px var(--accent);
}

/* 在其他地方,误操作 */
.card {
  --accent: 20px;
}

text-shadow 声明在替换时变为无效,阴影随之消失。在将 --accent 设置为 20px 的地方不会产生任何警告,因为在那个时刻它仍只是一个字符串。浏览器并不知道这个属性本应是颜色。MDN 自定义属性指南描述了这一替换模型:自定义属性的值只有在通过 var() 引用时才会被解析。

来自 CSS Properties and Values API Level 1 规范的 @property 为属性本身添加了类型。一旦注册,浏览器便知道 --accent<color> 类型,并在每次赋值时强制执行该约束,而不仅仅是在替换时。

语法:三个描述符及其必填要求

@property at 规则包含三个描述符:syntaxinheritsinitial-valuesyntaxinherits 描述符始终为必填项;只要 syntax 不是 "*"initial-value 也是必填项——在带类型的注册中省略它会导致整个 @property 块无效并被忽略。

@property --accent {
  syntax: "<color>";
  inherits: false;
  initial-value: #586de7;
}
  • syntax——描述所接受类型的字符串,取自规范定义的一组固定支持名称(详见下一节)。
  • inherits——布尔值(truefalse),控制属性是否沿 DOM 树向下继承。这与任何 CSS 属性的继承行为相同;显式设置此项可使带类型属性在嵌套组件中的行为可预测。
  • initial-value——当没有其他有效值适用时所使用的值,也是属性在接收无效输入时的回退值。

CSS Properties and Values API Level 1 规范第 3.1 节对此要求有精确定义:若缺少 syntaxinherits@property 规则无效;若缺少 initial-value,规则同样无效——除非 syntax 为通用的 "*"。部分现有教程将 initial-value 描述为无条件必填;规范将其与 syntax 的值绑定,而 inherits 与此条件无关。无效的 @property 规则会被丢弃——注册不会发生,属性将恢复为未经类型化的行为。

CSS @property 类型注册表

syntax 描述符接受 CSS Properties and Values API Level 1 规范第 2 节所定义的支持语法组件名称,包括 <color><length><percentage><integer><angle><image><custom-ident>,以及用于扩展的乘法符(+ 表示空格分隔列表,# 表示逗号分隔列表)和联合语法(<color> | <length>,用于合法接受多种类型的属性)。这是一个固定的支持名称列表,并非对任意 CSS 类型关键字开放。

syntax接受拒绝initial-value 示例
"<color>"任何有效颜色(#f00rebeccapurpleoklch(...)长度值、darkpink 等关键字#586de7
"<length>"pxrememvw裸数字、百分比20px
"<percentage>"50%长度值、裸数字100%
"<integer>"整数(121.5、长度值12
"<angle>"degradturngrad裸数字0deg
"<image>"url(...)、渐变颜色值、长度值url(bg.png)
"<custom-ident>"作者自定义标识符数字、带引号的字符串none
"*"任意值(无类型直通)无——接受一切可选

三种语法扩展可拓宽单个属性所接受的范围:

/* "+" — 空格分隔的长度列表 */
@property --insets {
  syntax: "<length>+";
  inherits: false;
  initial-value: 0px;
}

/* "#" — 逗号分隔的颜色列表 */
@property --stops {
  syntax: "<color>#";
  inherits: false;
  initial-value: black;
}

/* "|" — 联合类型:接受长度值或关键字 "auto" */
@property --gap {
  syntax: "<length> | auto";
  inherits: false;
  initial-value: auto;
}

+ 乘法符表示空格分隔列表;# 表示逗号分隔列表,详见规范的支持语法字符串章节| 联合语法允许属性接受多种类型——适用于确实需要同时接受长度值或关键字等情况。"*" 通用语法完全禁用类型检查;这是 initial-value 为可选的唯一情况,因为没有类型需要默认值。关于各类型的详细定义,可参考 MDN CSS 值类型参考CSSWG 类型索引

验证:出乎意料的静默回退

当已注册属性接收到与其声明的 syntax 不匹配的值时,浏览器会丢弃该赋值,并使用 initial-value 渲染元素。在当前浏览器中,此回退不产生任何控制台错误,也不在渲染页面上留下任何视觉提示——页面不会崩溃,但也不会告知你任何出错信息。

@property --hue {
  syntax: "<angle>";
  inherits: false;
  initial-value: 90deg;
}

.card {
  --hue: 220deg;            /* ✅ 有效,使用该值 */
  --hue: #f00;              /* ❌ 类型无效,被忽略——回退至 90deg */
  background: oklch(70% 0.15 var(--hue));
}

background 始终解析为有效颜色。在无效赋值之后,--hue 不会变成 #f00,也不会变为空——无效值被丢弃,属性解析为其注册的 initial-value,即 90degMDN @property 页面将此行为描述为属性在”计算值时变为无效”,并解析为已注册的初始值。

这确实优于明显损坏的布局,但同时也引入了一种新的故障类型。未经类型化的自定义属性会明显失败——依赖它的声明会崩溃,你能看到问题所在。带类型的属性则会静默失败:JavaScript 主题切换器写入了格式错误的颜色,用户提供的值无法解析,设计令牌使用了错误的单位——组件会以默认状态渲染,且不留下任何错误痕迹。DevTools 在检查元素时会显示计算后的回退值,但运行时控制台中不会出现任何提示。

这正是会话回放(session replay)工具所擅长捕获的 bug 类型。当带类型的自定义属性在生产环境中接收到无效输入——来自用户输入、配置错误的令牌或运行时主题切换——浏览器会静默回退至 initial-value,不触发任何 JavaScript 错误,标准错误监控工具也不产生任何信号。唯一的部署后证据是视觉层面的:组件以错误的颜色或尺寸渲染。对这类实现的会话回放往往能直接还原出异常状态,在错误值被赋值的那一刻捕获渲染后的 DOM——而仅依赖控制台的工具对此一无所知。

动画:对带类型属性进行插值

已注册的自定义属性在动画中可进行插值;未注册的属性则只能离散切换。这是类型化最具实用价值的结果。由于浏览器知道 --hue<angle> 而非字符串,它可以在过渡过程中对 0deg360deg 之间进行插值——这对于未经类型化的自定义属性是不可能实现的,因为浏览器将其视为两个不透明字符串,无法计算数值中间点。CSS Transitions 规范定义插值操作于带类型的值之上;未注册的自定义属性没有类型,因此浏览器会离散切换而非平滑过渡。

其他教程通常用 transform: rotate() 来演示这一点。以下是一个更具说明性的案例——对 oklch() 颜色的色相通道进行动画处理,展示类型化如何让你对函数内部的值进行插值,而不仅仅是独立属性:

@property --hue {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.swatch {
  width: 200px;
  height: 200px;
  border-radius: 12px;
  background: oklch(65% 0.2 var(--hue));
  animation: hue-cycle 6s linear infinite;
}

@keyframes hue-cycle {
  to {
    --hue: 360deg;
  }
}

色块会平滑地循环遍历完整色相轮,因为浏览器将 --hue0deg 插值到 360deg,并在每一帧重新计算 oklch(65% 0.2 var(--hue))CSS Color Level 4 规范定义 oklch() 的色相参数接受 <angle> 类型,这正是我们所注册的类型。移除 @property 块后,动画将失效:--hue 变为未经类型化的字符串,浏览器无法对其插值,色块将从起始值直接跳转到结束值,而非平滑循环。这一前后对比是说明注册对动画效果重要性的最直观示例。

JavaScript 等效方案:CSS.registerProperty()

CSS.registerProperty()@property at 规则的命令式等效方案。它在运行时通过 JavaScript 注册带类型的自定义属性,接受一个包含 namesyntaxinherits 和可选 initialValue 的对象:

window.CSS.registerProperty({
  name: "--hue",
  syntax: "<angle>",
  inherits: false,
  initialValue: "0deg",
});

注意 JS API 中使用驼峰命名的 initialValue,而 CSS 描述符使用连字符形式的 initial-valueMDN CSS.registerProperty() 参考文档详细说明了参数名称和行为。两种注册方式在效果上完全等同;通过任一方式注册的属性都会进行相同的类型化和验证。

默认情况下优先使用 at 规则——它与其他样式放在一起,是声明式的,无需 JavaScript 执行即可生效。仅在注册必须是动态的情况下才使用 CSS.registerProperty():例如属性的 syntaxinitialValue 依赖于运行时条件,或者某个库需要在初始化过程中以编程方式注册属性。需要注意的是,通过 CSS.registerProperty() 注册的属性无法重新注册,因此需防止重复调用。

浏览器支持

截至 2024 年 7 月 9 日,@property 已成为 Baseline 新可用特性——在 Chrome、Firefox 和 Safari 的当前版本中均受支持——旧版参考资料中的”实验性”说明已经过时。Firefox 在 128 版本中添加了支持(2024 年 7 月发布),至此完成了跨浏览器支持;Safari 在 16.4 版本中发布了该特性;Chrome 自 85 版本起便已支持。web.dev Baseline 公告确认了该日期和状态。如需精确的版本数据,请参阅 caniuse。2024 年中期之前发布的教程将其支持状态描述为”实验性”或”即将推出”,这些说法已不再成立。

实际应用模式

@property 最具价值的使用场景有一个共同特点:属性需要动画处理、接受外部输入,或需要显式控制继承行为。

全局定义,局部使用

在全局令牌层中统一定义 @property 块;消费组件照常通过 var() 引用变量。大多数参考教程将 @property 直接声明在使用它的规则上方,这对设计系统工作而言具有误导性——实际模式应将注册与使用分离:

/* tokens.css — 在文档根节点加载一次 */
@property --brand-hue {
  syntax: "<angle>";
  inherits: true;
  initial-value: 250deg;
}

@property --surface {
  syntax: "<color>";
  inherits: true;
  initial-value: #1a1a1a;
}
/* card.css — 组件无需引用注册声明 */
.card {
  background: var(--surface);
  border-color: oklch(60% 0.1 var(--brand-hue));
}

--surface 的任何赋值——来自主题切换器、媒体查询或用户输入——都会经过验证。设置 inherits: true 可使令牌级联至后代元素。

使用自适应色面进行颜色主题化

带类型的颜色令牌可让单个 --brand-hue 通过 oklch() 驱动整套色面调色板;格式错误的色相值会回退至注册的初始值,而不会破坏整个调色板:

html:has(#dark:checked) {
  --surface: oklch(20% 0.1 var(--brand-hue));
}
html:has(#light:checked) {
  --surface: oklch(95% 0.04 var(--brand-hue));
}

滚动驱动的进度指示器

带类型的 <percentage><length> 可清晰地作为进度值读取,并在由动画驱动或通过 JavaScript 更新时平滑插值:

@property --progress {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}

.progress-bar {
  width: var(--progress);
  transition: width 0.2s linear;
}
function onScroll() {
  const pct = (scrollY / (document.body.scrollHeight - innerHeight)) * 100;
  document.querySelector(".progress-bar")
    .style.setProperty("--progress", `${pct}%`);
}

--progress 类型化为 <percentage> 意味着误传入的非百分比值会回退至 0%,而不会破坏 width 属性。

何时不使用 @property

对于从不参与动画、从不接受外部输入、也不需要显式继承控制的自定义属性,可跳过 @property 注册。三个描述符会增加语法开销,对于纯静态令牌而言没有任何运行时收益。满足以下至少一个条件时,注册才是合理的:

  1. 值需要动画或过渡。 插值需要已注册的类型。
  2. 值接受可能无效的外部输入。 主题切换器、用户输入或可能格式错误的构建时令牌,都能从静默回退保障中受益。
  3. 需要显式控制继承行为。 当你需要在嵌套组件中锁定属性的级联行为时。

对于静态间距比例、z-index 层级或只设置一次、从不参与动画也不接受外部输入的 font-family 字符串,在 :root 中使用普通自定义属性更为简洁,完全能胜任工作。在这些情况下添加 @property 只会带来三个需要维护的描述符,却不会获得任何额外行为。对需要动画或接受输入的属性进行类型化;其余的保持字符串形式即可。

带类型的自定义属性将浏览器变成了验证器和插值引擎,但代价是一种静默的失败模式:无效输入会回退至 initial-value,不留下任何错误痕迹。请为需要动画或接受运行时输入的属性进行注册,设置合理的初始值,并将这种回退行为视为生产环境中需要主动监控的现象,而非掩盖问题的安全网。

常见问题

@property at 规则是顶层 at 规则,需独立声明,不能嵌套在 :root 或任何选择器内部。你可以在样式表的任意位置编写 @property 块来全局注册类型,然后像使用普通自定义属性一样,在 :root 或任何选择器中设置属性的值。无论值在何处赋值,注册均在文档范围内生效,因此将 @property 放在全局令牌文件中、在 :root 中赋值是标准做法。

两者注册的带类型自定义属性在运行时行为上完全相同,但 @property 是声明式 CSS,无需 JavaScript 即可生效;而 CSS.registerProperty() 在运行时以命令式方式执行。默认情况下优先使用 @property,因为它与样式放在一起,无需脚本执行。仅在注册必须是动态的情况下才使用 CSS.registerProperty(),例如 syntax 或 initialValue 依赖于运行时条件时。需注意 CSS.registerProperty() 使用驼峰命名的 initialValue,无法重新注册属性,且对同一名称调用两次会抛出错误。

不能。未经类型化的自定义属性以不透明字符串形式存储,因此浏览器将其视为两个没有数值中间点的字符串,只能离散切换而非插值。通过 @property 注册属性并赋予类型后,浏览器便能理解该类型,从而在过渡和关键帧中实现插值。例如,未注册的角度值会从起始值直接跳转到结束值,而注册为 <angle> 的同一属性则会平滑过渡。类型注册是实现插值的前提条件。

浏览器会丢弃无效赋值,并使用已注册的 initial-value 渲染元素。这一过程是静默的,不产生任何控制台错误,也不在渲染页面上留下任何回退触发的视觉提示——规范将此行为描述为在计算值时变为无效,并解析为已注册的初始值。页面不会崩溃,但也不会发出任何出错信号,这使得此类回归问题对标准错误监控工具不可见。只有直接在 DevTools 中检查元素时,才能看到计算后的回退值。

Truly understand users experience

See every user interaction, feel every frustration and track all hesitations with OpenReplay — the open-source digital experience platform. It can be self-hosted in minutes, giving you complete control over your customer data. . Check our GitHub repo and join the thousands of developers in our community..

OpenReplay