Skip to content
This page has been auto-translated and may contain errors.View in English

自定义属性和现代 CSS

docs.scrimba.com

你选择一个品牌蓝色,将其输入四十条不同的规则中,然后发布网站。一个月后营销部门希望使用一个略有不同的蓝色。现在你必须查看每个样式表,希望能找到全部四十处,而不会漏掉那两个大写拼写的地方。自定义属性解决了这个问题:你给颜色命名一次,在任何地方使用这个名字,然后在一个地方修改它。这个单一习惯是主题化、设计令牌和能够经受住重新设计的样式表的开始。

声明和使用变量

一个**自定义属性**是一个你给它起名字以便重复使用的值。你用两个破折号写名字,将其设置为一个值,然后稍后用 var() 读回它:

css
:root {
  --color-brand: #2563eb;   /* 在一个名字下声明值一次 */
}

.button {
  background: var(--color-brand);   /* 在这里读回它 */
}

.link {
  color: var(--color-brand);        /* 在这里也读回,相同的值 */
}

--color-brand 是名字(两个破折号是必需的),var(--color-brand) 是你如何使用它。在 :root(即 <html> 元素,页面的顶部)声明它会使名字在任何地方都可用。修改 :root 上的那一行,每个读取它的规则都会更新。

一个**自定义属性**是一个用 -- 前缀声明并通过 var() 函数读回的命名值。人们也称它们为 CSS 变量,这两个名字意思相同:

css
:root {
  --color-brand: #2563eb;
  --color-brand-dark: #1e40af;
}

.button {
  background: var(--color-brand);
}

.button:hover {
  background: var(--color-brand-dark);
}

-- 前缀是分离自定义属性和普通属性的方式,它是区分大小写的,所以 --color-Brand--color-brand 是两个不同的名字。在 :root 上声明是常见的选择,因为它将值放在树的顶部,每个元素都可以看到它,这正是你的全局令牌应该放置的地方。你不仅限于颜色:任何属性接受的值都可以存在于自定义属性中,包括长度、阴影和字体堆栈。

一个**自定义属性**是一个用户定义的属性,其名字以 -- 开头,包含一个你通过 var() 函数读回的值。名字是区分大小写的,值几乎逐字存储:浏览器在声明时对它做的处理很少,一旦你看到它实际解析的位置,这就变得重要。

css
:root {
  --color-brand: #2563eb;
  --space-md: 16px;
  --shadow-card: 0 1px 3px rgb(0 0 0 / 0.12);
}

.card {
  padding: var(--space-md);
  box-shadow: var(--shadow-card);
}

:root 上声明是一个约定,不是一个要求。:root 匹配 <html> 元素的特异性略高于 html 类型选择器,由于自定义属性继承,在那里设置的值对每个后代都是可见的。这是要保持的心智模型:你不是在创建一个全局注册表,你是在根元素上设置一个属性,它恰好继承到整个文档。任何普通属性接受的都可以存储,你也会存储值的片段(一个颜色通道、一个无单位数字)并在稍后组装它们。

Juno声明和使用变量 一个自定义属性是一个带名字的值:用两个破折号写成像 --color-brand 这样的形式,然后用 var(--color-brand) 读回它。把它放在 :root 上这样整个页面都能使用它。修改那一行,每个读取这个名字的规则都会与它一起更新。
Juno声明和使用变量-- 前缀标记一个自定义属性,var() 读回它。在 :root 上声明将你的全局令牌放在树的顶部,每个元素都可以到达它们。名字是区分大小写的,任何属性接受的值都可以存在于其中,不仅仅是颜色。
Juno声明和使用变量 一个自定义属性是任何你设置的 --name,通过 var() 读回,几乎逐字存储直到它解析。:root 是一个约定:它是 <html> 元素,由于这些属性继承,那里的值到达每个后代。你也可以存储部分值,稍后组装它们,回退部分开始使用这个。

它们级联和继承

如果你在另一种语言中使用过变量,下一部分是个惊喜:自定义属性不是固定的。它遵循与任何其他 CSS 属性相同的规则,这意味着它**继承**到子元素,你可以在一个地方给它一个不同的值而不触及其余部分。

css
:root {
  --color-text: #1f2937;   /* 整个页面的默认值 */
}

.callout {
  --color-text: #92400e;   /* 一个不同的值,仅在 .callout 内部 */
  color: var(--color-text);
}

p {
  color: var(--color-text);   /* 通常是深灰色,在标注内是琥珀色 */
}

相同的 var(--color-text) 在大多数段落中给你深灰色,但在标注内是琥珀色,因为标注设置了它自己的值。这就是使主题化成为可能的原因,你在 CSS 如何工作 中看到了它背后的级联。

这是自定义属性与来自 Sass 等 CSS 预处理器的变量分道扬镳的地方。预处理器变量在浏览器看到它之前被编译掉,所以它是静态的查找和替换。自定义属性是一个真实的 CSS 属性,在浏览器中活动,所以它遵循级联并且它**继承**。

自定义属性的值在读取它的元素上查找,而不是在声明它的地方。 这个单一事实就是为什么组件主题化工作:

css
:root {
  --color-accent: #2563eb;   /* 默认情况下到处都是蓝色 */
}

.card--warning {
  --color-accent: #d97706;   /* 针对这个组件子树的覆盖 */
}

.card__badge {
  background: var(--color-accent);   /* 蓝色,或在警告卡内是琥珀色 */
}

徽章不知道或不关心 --color-accent 来自哪里。它读取对它自己有效的值,任何祖先都可能已经重新定义了它。你在容器上覆盖令牌,每个读取它的后代都重新设计,而不编辑一个组件规则。完整的级联和继承规则存在于 CSS 如何工作

将自定义属性与每个预处理器变量分开的行为是它们参与级联并**默认继承**。Sass 或 Less 变量在构建时解析,在浏览器加载文件之前就消失了。自定义属性是元素上的真实属性,由统治 colorfont-size 的相同级联来解析,这意味着 var() 是针对进行读取的元素的属性计算值来查找的。

css
:root {
  --color-accent: #2563eb;
}

.panel[data-tone="warning"] {
  --color-accent: #d97706;   /* 在子树根上设置 */
}

.panel__button {
  background: var(--color-accent);   /* 按元素解析,从面板继承 */
}

有两个值得记住的后果。首先,继承做主题化:在子树根上设置 --color-accent 传播到每个读取它的后代,所以容器覆盖重新设计整个组件树,没有对其规则的更改。其次,特异性仍然适用于令牌的设置位置,而不是 var() 本身。如果两个选择器都在同一个元素上设置 --color-accent,通常的级联决定哪个赢,完全就像对任何属性一样。应该停止继承的属性可以用 initial 重置,或用 @property 声明为非继承,下面的深入部分会涉及。级联规则在这里没有例外地应用。

Juno它们级联和继承 一个自定义属性不是冻结的:它继承到子元素,你可以在一个地方设置一个不同的值而不改变其余部分。这就是为什么相同的 var(--color-text) 可以是大多数段落中的灰色,在标注内是琥珀色。在容器上重新定义名字,其中的所有东西都会获取新值。
Juno它们级联和继承 与在构建时消失的 Sass 变量不同,自定义属性在浏览器中活动并遵循级联,所以其值在使用它的元素上读取。在容器上覆盖令牌,每个后代都重新设计,零编辑组件规则。那种在读取处查找的行为是主题化背后的全部技巧。
Juno它们级联和继承 自定义属性继承并级联,预处理器变量永远不会这样做,因为它在浏览器加载之前就消失了。var() 针对读取元素上的计算值解析,所以在子树根上设置令牌只通过继承为下面的所有东西设置主题。特异性控制令牌的设置位置,而不是 var() 调用本身。

回退值和 calc()

var() 可以取第二个值,仅在自定义属性从未设置时使用。这是**回退值**,它会在令牌缺失时保持规则工作:

css
.card {
  gap: var(--gap, 1rem);   /* 如果 --gap 存在则使用它,否则使用 1rem */
}

你也可以使用 calc() 对自定义属性进行数学运算。如果你有一个基础间距值,你可以从它构建一个更大的值,而不是硬编码第二个数字:

css
:root {
  --space-md: 16px;
}

.section {
  padding: calc(var(--space-md) * 2);   /* 32px,从基础派生 */
}

像这样派生值意味着对 --space-md 的一个改变会波及所有基于它的东西。

var() 接受第二个参数,作为**回退值**:var(--gap, 1rem)--gap 设置时解析为 --gap,在它未设置时解析为 1rem。回退值值得在任何可能在给定子树上未定义的令牌上使用,所以组件在被放在其令牌未提供的地方时仍能理智地渲染。

css
.card {
  gap: var(--gap, 1rem);
  padding: var(--space-md, 16px);
}

自定义属性与 calc() 一起发挥作用,因为那是你如何从另一个派生一个值而不是存储一堆魔法数字的方式:

css
:root {
  --space-md: 16px;
}

.section {
  padding: calc(var(--space-md) * 2);        /* 32px */
  margin-bottom: calc(var(--space-md) * 3);  /* 48px,一个信息源 */
}

一个要注意的事情:回退值可以是包含逗号的完整值,所以 var(--shadow, 0 1px 2px black) 是有效的,第一个逗号之后的所有内容都被视为回退值。查看 尺寸单位 了解 calc() 内的单位如何解析。

var() 接受可选的回退值作为其第二个参数:当 --gap 在读取元素上未设置时(或设置为保证无效值时),var(--gap, 1rem) 会产生 1rem。**回退值**贪心地获取第一个逗号之后的所有内容,所以它本身可以是包含逗号的值或嵌套 var(),这让你链接默认值:var(--gap, var(--space-md, 1rem))

css
.card {
  gap: var(--gap, var(--space-md, 1rem));   /* 链式回退值 */
}

:root {
  --space-md: 16px;
}

.section {
  padding: calc(var(--space-md) * 2);   /* 32px,派生而非硬编码 */
}

calc() 的配对是自定义属性在缩放系统中赚得一席之地的地方,它带有精确规则。自定义属性可以包含无单位数字,你在 calc() 内将单位乘以它:

css
:root {
  --scale: 1.5;   /* 无单位,所以它可以乘以任何单位 */
}

.title {
  font-size: calc(1rem * var(--scale));   /* 1.5rem */
  line-height: calc(var(--scale) + 0.2);  /* 纯数字运算 */
}

你存储 --scale 没有单位的原因是 calc(var(--scale) * 16px) 工作,而存储 --scale: 1.5rem 并尝试 calc(var(--scale) * 16px) 乘以两个长度是无效的。保持原始比率无单位并在使用点附加单位。这些单位如何解析为像素在 尺寸单位 中涵盖。

Juno回退值和 calc()var(--gap, 1rem) 在令牌存在时使用它,不存在时回退到 1rem,所以当值缺失时规则保持工作。而 calc() 让你从令牌构建值,像 calc(var(--space-md) * 2) 一样加倍基础间距。改变基础,所有基于它的东西都会跟随。
Juno回退值和 calc()var() 的第二个参数是回退值:var(--gap, 1rem) 涵盖令牌从未设置的情况。将自定义属性与 calc() 配对以派生值而不是硬编码魔法数字,所以一个令牌驱动整个缩放。注意回退值吞噬第一个逗号之后的所有内容,这对多部分值很方便。
Juno回退值和 calc()var(--gap, 1rem) 贪心地回退,所以你可以嵌套像 var(--gap, var(--space-md, 1rem)) 这样的默认值。存储原始比率无单位,因为 calc(var(--scale) * 16px) 工作而乘以两个长度不工作,并在使用点附加单位。这让一个令牌驱动整个类型或间距缩放。

主题化

因为自定义属性可以在树的下方重新定义,你可以通过在一个类下改变令牌来构建暗色主题,并将每个组件规则单独留下:

css
:root {
  --color-bg: #ffffff;
  --color-text: #1f2937;
}

.theme-dark {
  --color-bg: #0f172a;      /* 相同的名字,新值 */
  --color-text: #e2e8f0;
}

body {
  background: var(--color-bg);
  color: var(--color-text);
}

class="theme-dark" 添加到页面,每个读取 --color-bg--color-text 的规则都会翻转到暗色值。你从未接触过 body 规则,或任何组件,来做主题切换。这是将 颜色 命名为令牌而不是直接写入十六进制代码的回报。

主题化是令牌级联的直接奖励。你定义你的颜色一次作为令牌,对令牌名字写每个组件,然后主题不过是那些相同名字的第二组值在一个类或媒体查询下:

css
:root {
  --color-bg: #ffffff;
  --color-text: #1f2937;
  --color-surface: #f8fafc;
}

.theme-dark {
  --color-bg: #0f172a;
  --color-text: #e2e8f0;
  --color-surface: #1e293b;
}

在容器上切换 theme-dark 重新设计其中的所有东西,因为组件读取令牌名字,而不是固定颜色。同样的方式适用于媒体查询所以主题遵循操作系统设置:

css
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0f172a;
    --color-text: #e2e8f0;
    --color-surface: #1e293b;
  }
}

使这个成立的纪律是将原始十六进制代码完全保留在组件规则之外,并将它们命名为 颜色令牌 代替。这也是令牌命名约定的回报,组织 CSS 深入其中。

主题化是自定义属性在超越单一值重用之外重要的原因,它工作因为主题不过是你的令牌层的级联覆盖。组件读取令牌名字;主题在树的高处重新定义那些名字。组件层中的没有东西知道主题存在。

css
:root {
  --color-bg: #ffffff;
  --color-text: #1f2937;
  --color-surface: #f8fafc;
}

.theme-dark {
  --color-bg: #0f172a;
  --color-text: #e2e8f0;
  --color-surface: #1e293b;
}

@media (prefers-color-scheme: dark) {
  :root:not(.theme-light) {
    --color-bg: #0f172a;
    --color-text: #e2e8f0;
    --color-surface: #1e293b;
  }
}

在生产中成立的模式是一个两层令牌系统。第一层包含原始值(--blue-600: #2563eb),第二层包含引用它们的语义令牌(--color-accent: var(--blue-600))。组件仅读取语义层,所以主题重新分配语义令牌,调色板层保持不动,这使切换很小且意图可读。将类覆盖与 prefers-color-scheme 媒体查询配对,如上所示,让你有一个系统默认加上手动切换而不复制组件规则。保持这个可维护的命名纪律属于你的其余 文件和令牌组织,原始值本身是 颜色

三个更多的东西属于自定义属性的高级视图,因为它们改变了什么是可能的而不仅仅是添加打磨。

自定义属性在**计算值时**解析,意味着浏览器在计算样式时按元素解析 var(),而不是在文件被解析时。这正是为什么主题化工作以及为什么预处理器变量无法做到的原因:值是活动的,所以它响应级联、继承和在运行时做出的更改。

运行时是第二个点。因为这些是真实属性,JavaScript 可以读和写它们,每个读取令牌的规则立即更新:

css
:root {
  --sidebar-width: 280px;   /* 从 JS 在运行时读和设置 */
}

一个脚本可以调用 document.documentElement.style.setProperty('--sidebar-width', '320px') 并且布局针对新值重流,Sass 变量在页面加载很久之前编译掉时永远无法提供的东西。你也可以通过在任何元素而不是根上设置属性来限定这个到子树,这将改变限制到树的那个分支。

第三个点是对属性本身的控制。@property 让你用类型、初始值和是否继承来注册自定义属性,这解锁了自定义属性的流畅动画(普通自定义属性动画作为开/关跳跃,因为浏览器无法插值无类型值)。与它一起,现代值函数自然地从令牌读取:clamp(min, preferred, max) 选择首选值但从不离开边界,min() 返回其参数中最小的,max() 返回最大的,所以 width: min(100%, var(--max-width)) 在没有媒体查询的情况下限制流动宽度在令牌。

Juno主题化 主题是相同的令牌名字在像 .theme-dark 这样的类下有不同的值。设置 class="theme-dark",每个读取那些令牌的规则都翻转,不编辑一个单一组件。这就是为什么你将颜色命名为令牌而不是直接在每个规则中输入十六进制代码。
Juno主题化 因为令牌级联,主题不过是相同名字的第二组值,在一个类或 prefers-color-scheme 媒体查询下。保持原始十六进制在组件规则之外并读取令牌名字代替,整个子树从一个覆盖重新设计。切换类或匹配操作系统设置,每个读取那些令牌的东西都跟随。
Juno主题化 自定义属性在计算值时解析,所以它们是活动的:JavaScript 可以在运行时设置 --sidebar-width 并且每个读取它的规则重流,任何预处理器变量都不能做到。两层设置,原始调色板令牌在语义令牌下,保持主题切换到少数几个重新分配。而 @property 加上 clamp()min()max() 让那些令牌动画和保持有界而不需要媒体查询。