级联中的 CSS 自定义属性
已发表: 2022-03-10上个月,我在 Twitter 上讨论了“范围”样式(在构建过程中生成)和 CSS 原生“嵌套”样式之间的区别。 我问为什么,有趣的是,开发人员会避免 ID 选择器的特殊性,同时采用 JavaScript 生成的“范围样式”? 基思格兰特建议,区别在于平衡级联*和继承,即优先考虑接近性而不是特异性。 让我们来看看。
级联
CSS 级联基于三个因素:
- 由
!important
标志定义的重要性和样式来源(用户 > 作者 > 浏览器) - 使用的选择器的特殊性(内联 > ID > 类 > 元素)
- 代码本身的源顺序(最新优先)
任何地方都没有提到接近性——选择器各部分之间的 DOM 树关系。 下面的段落都将是红色的,即使#inner p
描述的关系比第二段的#outer p
更密切:
<section> <p>This text is red</p> <div> <p>This text is also red!</p> </div> </section>
#inner p { color: green; } #outer p { color: red; }
两个选择器具有相同的特异性,它们都描述了相同的p
元素,并且都没有标记为!important
——因此结果仅基于源顺序。
BEM 和范围样式
像 BEM(“Block__Element-Modifier”)这样的命名约定用于确保每个段落的“范围”仅限于一个父级,从而完全避免级联。 段落“元素”被赋予特定于其“块”上下文的唯一类:
<section class="outer"> <p class="outer__p">This text is red</p> <div class="inner"> <p class="inner__p">This text is green!</p> </div> </section>
.inner__p { color: green; } .outer__p { color: red; }
这些选择器仍然具有相同的相对重要性、特异性和源顺序——但结果不同。 “Scoped”或“modular” CSS 工具自动执行该过程,基于 HTML 为我们重写 CSS。 在下面的代码中,每个段落的范围仅限于其直接父级:
<section outer-scope> <p outer-scope>This text is red</p> <div outer-scope inner-scope> <p inner-scope>This text is green!</p> </div> </section>
p[inner-scope] { color: green } p[outer-scope] { color: red; }
遗产
接近不是级联的一部分,但它是 CSS 的一部分。 这就是继承变得重要的地方。 如果我们从选择器中删除p
,每个段落都会从其最近的祖先那里继承一种颜色:
#inner { color: green; } #outer { color: red; }
由于#inner
和#outer
描述了不同的元素,即我们的div
和section
,因此两个颜色属性的应用都没有冲突。 嵌套的p
元素没有指定颜色,因此结果由继承(直接父级的颜色)而不是cascade确定。 接近优先, #outer
#inner
但是有一个问题:为了使用继承,我们在我们的section
和div
中设置了所有的样式。 我们要专门针对段落颜色。
(重新)引入自定义属性
自定义属性提供了一种新的浏览器原生解决方案; 它们像任何其他属性一样继承,但不必在定义它们的地方使用。 使用纯 CSS,没有任何命名约定或构建工具,我们可以创建一个既具有目标性又具有上下文的样式,并且接近优先于级联:
p { color: var(--paragraph); } #inner { --paragraph: green; } #outer { --paragraph: red; }
自定义--paragraph
属性与color
属性一样继承,但现在我们可以控制该值的应用方式和位置。 --paragraph
属性的作用类似于可以通过直接选择(特异性规则)或上下文(邻近规则)传递给p
组件的参数。
我认为这揭示了我们经常与函数、mixin 或组件相关联的自定义属性的潜力。
自定义“功能”和参数
函数、mixin 和组件都基于相同的思想:可重用代码,可以使用各种输入参数运行以获得一致但可配置的结果。 区别在于他们对结果的处理方式。 我们将从一个条带梯度变量开始,然后我们可以将其扩展为其他形式:
html { --stripes: linear-gradient( to right, powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); }
该变量是在根html
元素上定义的(也可以使用:root
,但这会增加不必要的特殊性),因此我们的 striped 变量将在文档中的任何地方都可用。 我们可以在任何支持渐变的地方应用它:
body { background-image: var(--stripes); }
添加参数
函数像变量一样使用,但定义了用于更改输出的参数。 我们可以通过在其中定义一些类似参数的变量来更新我们的--stripes
变量,使其更像函数。 我将首先用var(--stripes-angle)
替换to right
,以创建一个角度改变参数:
html { --stripes: linear-gradient( var(--stripes-angle), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); }
我们可以创建其他参数,具体取决于函数的用途。 我们应该允许用户选择自己的条纹颜色吗? 如果是这样,我们的函数是接受 5 个不同的颜色参数,还是只接受 3 个像现在这样由外而内的参数? 我们是否也想为色标创建参数? 我们添加的每个参数都以简单性和一致性为代价提供了更多的自定义。
这种平衡没有普遍的正确答案——一些功能需要更灵活,而另一些需要更有主见。 抽象的存在是为了在你的代码中提供一致性和可读性,所以退后一步,问问你的目标是什么。 真正需要定制什么,应该在哪里实施一致性? 在某些情况下,拥有两个自以为是的功能可能比一个完全可定制的功能更有帮助。
要使用上面的函数,我们需要为--stripes-angle
参数传入一个值,并将输出应用到 CSS 输出属性,例如background-image
:
/* in addition to the code above… */ html { --stripes-angle: 75deg; background-image: var(--stripes); }
继承与通用
我出于习惯在html
元素上定义了--stripes
函数。 自定义属性继承,我希望我的函数在任何地方都可用,所以将它放在根元素上是有意义的。 这对于继承像--brand-color: blue
这样的变量很有效,所以我们也可以期望它也适用于我们的“函数”。 但是如果我们尝试在嵌套选择器上再次使用这个函数,它将不起作用:
div { --stripes-angle: 90deg; background-image: var(--stripes); }
新的--stripes-angle
被完全忽略。 事实证明,对于需要重新计算的函数,我们不能依赖继承。 这是因为每个属性值对每个元素(在我们的例子中是html
根元素)计算一次,然后计算值被继承。 通过在文档根目录定义我们的函数,我们不会让整个函数对后代可用——只有我们函数的计算结果。
如果您根据级联--stripes-angle
参数来构建它,这是有道理的。 像任何继承的 CSS 属性一样,它对后代可用,但对祖先不可用。 我们在嵌套div
上设置的值不适用于我们在html
根祖先上定义的函数。 为了创建一个可以重新计算任何元素的通用函数,我们必须在每个元素上定义它:
* { --stripes: linear-gradient( var(--stripes-angle), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); }
通用选择器使我们的函数在任何地方都可用,但如果我们愿意,我们可以更严格地定义它。 重要的是它只能在明确定义的地方重新计算。 以下是一些替代方案:
/* make the function available to elements with a given selector */ .stripes { --stripes: /* etc… */; } /* make the function available to elements nested inside a given selector */ .stripes * { --stripes: /* etc… */; } /* make the function available to siblings following a given selector */ .stripes ~ * { --stripes: /* etc… */; }
这可以使用任何不依赖继承的选择器逻辑进行扩展。
自由参数和备用值
在我们上面的例子中, var(--stripes-angle)
没有价值也没有回退。 与在调用之前必须定义或实例化的 Sass 或 JS 变量不同,CSS 自定义属性可以在不定义的情况下调用。 这将创建一个“自由”变量,类似于可以从上下文继承的函数参数。
我们最终可以在html
或:root
(或任何其他祖先)上定义变量来设置继承值,但首先我们需要考虑未定义值时的回退。 有几个选项,具体取决于我们想要的行为
- 对于“必需的”参数,我们不想要回退。 照原样,在定义
--stripes-angle
之前,该函数什么都不做。 - 对于“可选”参数,我们可以在
var()
函数中提供一个备用值。 在变量名之后,我们添加一个逗号,后跟默认值:
var(--stripes-angle, 90deg)
每个var()
函数只能有一个回退——因此任何额外的逗号都将成为该值的一部分。 这使得使用内部逗号提供复杂的默认值成为可能:
html { /* Computed: Hevetica, Ariel, sans-serif */ font-family: var(--sans-family, Hevetica, Ariel, sans-serif); /* Computed: 0 -1px 0 white, 0 1px 0 black */ test-shadow: var(--shadow, 0 -1px 0 white, 0 1px 0 black); }
我们还可以使用嵌套变量来创建自己的级联规则,为不同的值赋予不同的优先级:
var(--stripes-angle, var(--global-default-angle, 90deg))
- 首先,尝试我们的显式参数(
--stripes-angle
); - 如果可用,则回退到全局“用户默认值”(
--user-default-angle
); - 最后,回退到我们的“出厂默认值”
(90deg
)。
通过在var()
中设置后备值而不是显式定义自定义属性,我们确保对参数没有特异性或级联限制。 所有*-angle
参数都是“自由”的,可以从任何上下文中继承。
浏览器回退与变量回退
当我们使用变量时,我们需要牢记两条后备路径:
- 没有变量支持的浏览器应该使用什么值?
- 当特定变量丢失或无效时,支持变量的浏览器应该使用什么值?
p { color: blue; color: var(--paragraph); }
虽然旧浏览器会忽略变量声明属性,并回退到blue
——现代浏览器会同时读取并使用后者。 我们的var(--paragraph)
可能没有定义,但它是有效的并且会覆盖之前的属性,所以支持变量的浏览器会回退到继承的或初始值,就像使用unset
关键字一样。
起初这似乎令人困惑,但有充分的理由。 第一个是技术性的:浏览器引擎在“解析时间”(首先发生)处理无效或未知的语法,但直到“计算值时间”(稍后发生)才解析变量。
- 在解析时,语法无效的声明会被完全忽略——退回到之前的声明。 这是旧浏览器将遵循的路径。 现代浏览器支持变量语法,因此之前的声明被丢弃了。
- 在计算值时,变量被编译为无效,但为时已晚——先前的声明已被丢弃。 根据规范,无效变量值被视为与
unset
相同:
html { color: red; /* ignored as *invalid syntax* by all browsers */ /* - old browsers: red */ /* - new browsers: red */ color: not a valid color; color: var(not a valid variable name); /* ignored as *invalid syntax* by browsers without var support */ /* valid syntax, but invalid *values* in modern browsers */ /* - old browsers: red */ /* - new browsers: unset (black) */ --invalid-value: not a valid color value; color: var(--undefined-variable); color: var(--invalid-value); }
这对作为作者的我们也有好处,因为它允许我们为支持变量的浏览器使用更复杂的回退,并为旧浏览器提供简单的回退。 更好的是,这允许我们使用null
/ undefined
状态来设置所需的参数。 如果我们想将一个函数变成一个 mixin 或组件,这一点就变得尤为重要。
自定义属性“混合”
在 Sass 中,函数返回原始值,而 mixin 通常返回带有属性值对的实际 CSS 输出。 当我们定义一个通用的--stripes
属性,而不将它应用到任何视觉输出时,结果是类似函数的。 我们也可以通过通用定义输出来使其表现得更像一个 mixin:
* { --stripes: linear-gradient( var(--stripes-angle), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); background-image: var(--stripes); }
只要--stripes-angle
仍然无效或未定义,mixin 就无法编译,并且不会应用background-image
。 如果我们在任何元素上设置一个有效的角度,该函数将计算并给我们一个背景:
div { --stripes-angle: 30deg; /* generates the background */ }
不幸的是,该参数值将继承,因此当前定义在div
和所有后代上创建背景。 为了解决这个问题,我们必须确保--stripes-angle
值不会继承,方法是在每个元素上将其设置为initial
值(或任何无效值)。 我们可以在同一个通用选择器上做到这一点:
* { --stripes-angle: initial; --stripes: /* etc… */; background-image: var(--stripes); }
安全的内联样式
在某些情况下,我们需要从 CSS 外部动态设置参数——基于来自后端服务器或前端框架的数据。 使用自定义属性,我们可以安全地在 HTML 中定义变量,而不必担心通常的特殊性问题:
<div>...</div>
内联样式具有很高的特异性,并且很难被覆盖——但是对于自定义属性,我们还有另一个选择:忽略它。 如果我们将 div 设置为background-image: none
(例如)该内联变量将没有影响。 更进一步,我们可以创建一个中间变量:
* { --stripes-angle: var(--stripes-angle-dynamic, initial); }
现在我们可以选择在 HTML 中定义--stripes-angle-dynamic
,或者忽略它,直接在样式表中设置--stripes-angle
。
预设值
对于更复杂的值,或者我们想要重用的常见模式,我们还可以提供一些预设变量供选择:
* { --tilt-down: 6deg; --tilt-up: -6deg; }
并使用这些预设,而不是直接设置值:
<div>...</div>
这非常适合基于动态数据创建图表和图形,甚至可以布置日程表。
上下文组件
我们还可以将我们的“mixin”重新构建为一个“组件”,方法是将其应用于显式选择器,并使参数可选。 与其依赖--stripes-angle
的存在或不存在来切换我们的输出,我们可以依赖组件选择器的存在或不存在。 这使我们能够安全地设置后备值:
[data-stripes] { --stripes: linear-gradient( var(--stripes-angle, to right), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); background-image: var(--stripes); }
通过将回退放在var()
函数中,我们可以让--stripes-angle
未定义并且“自由”地从组件外部继承一个值。 这是将组件样式的某些方面暴露给上下文输入的好方法。 即使是由 JS 框架生成的“范围”样式(或在 shadow-DOM 中的范围,如 SVG 图标)也可以使用这种方法来公开特定参数以供外部影响。
隔离组件
如果我们不想暴露参数进行继承,我们可以用默认值定义变量:
[data-stripes] { --stripes-angle: to right; --stripes: linear-gradient( var(--stripes-angle, to right), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); background-image: var(--stripes); }
这些组件也可以与类或任何其他有效的选择器一起使用,但我选择了data-
属性来为我们想要的任何修饰符创建命名空间:
[data-stripes='vertical'] { --stripes-angle: to bottom; } [data-stripes='horizontal'] { --stripes-angle: to right; } [data-stripes='corners'] { --stripes-angle: to bottom right; }
选择器和参数
我经常希望我可以使用 data-attributes 来设置一个变量——CSS3 attr()
规范支持的一个功能,但尚未在任何浏览器中实现(请参阅资源选项卡以了解每个浏览器上的链接问题)。 这将使我们能够更紧密地将选择器与特定参数相关联:
<div data-stripes="30deg">...</div> /* Part of the CSS3 spec, but not yet supported */ /* attr( , ) */ [data-stripes] { --stripes-angle: attr(data-stripes angle, to right); }
<div data-stripes="30deg">...</div> /* Part of the CSS3 spec, but not yet supported */ /* attr( , ) */ [data-stripes] { --stripes-angle: attr(data-stripes angle, to right); }
<div data-stripes="30deg">...</div> /* Part of the CSS3 spec, but not yet supported */ /* attr( , ) */ [data-stripes] { --stripes-angle: attr(data-stripes angle, to right); }
同时,我们可以通过使用style
属性来实现类似的效果:
<div>...</div> /* The `*=` atttribute selector will match a string anywhere in the attribute */ [style*='--stripes-angle'] { /* Only define the function where we want to call it */ --stripes: linear-gradient(…); }
当我们想要包含除了要设置的参数之外的其他属性时,这种方法最有用。 例如,设置网格区域还可以添加填充和背景:
[style*='--grid-area'] { background-color: white; grid-area: var(--grid-area, auto / 1 / auto / -1); padding: 1em; }
结论
当我们开始将所有这些部分放在一起时,很明显自定义属性远远超出了我们熟悉的常见变量用例。 我们不仅能够存储值,并将它们限定在级联范围内——而且我们可以使用它们以新的方式操作级联,并直接在 CSS 中创建更智能的组件。
这要求我们重新思考过去依赖的许多工具——从 SMACSS 和 BEM 等命名约定,到“范围”样式和 CSS-in-JS。 其中许多工具有助于解决特殊性,或用另一种语言管理动态样式——我们现在可以直接使用自定义属性来处理这些用例。 我们经常在 JS 中计算的动态样式现在可以通过将原始数据传递给 CSS 来处理。
起初,这些变化可能被视为“增加了复杂性”——因为我们不习惯在 CSS 中看到逻辑。 而且,与所有代码一样,过度工程可能是一个真正的危险。 但我认为,在许多情况下,我们可以使用这种能力不增加复杂性,而是将复杂性从第三方工具和约定中移出,回到网页设计的核心语言中,并且(更重要的是)回到浏览器。 如果我们的样式需要计算,那么计算应该存在于我们的 CSS 中。
所有这些想法都可以更进一步。 自定义属性刚刚开始得到更广泛的采用,而我们才刚刚开始触及可能的表面。 我很高兴看到它的发展方向,以及人们还能想出什么。 玩得开心!
延伸阅读
- “是时候开始使用 CSS 自定义属性了,”Serg Hospodarets
- “CSS 自定义属性的策略指南”,Michael Riethmuller