我为什么对原生CSS变量感到兴奋
几周前,CSS变量,更准确的说是CSS自定义属性——发布在Chrome Canary版的Experimental Web Platform Features flag。
Chrome的工程师@Addy Osmani首次在推特发布时,遭遇到了惊人的否定,敌意及怀疑。至少我很吃惊,鉴于我对这个属性是如此兴奋。
快速浏览完大众的答复,明显地99%的讨论都集中在这两件事上:
- 语法丑而冗长
- Sass已经有了变量,为什么还要关注原生CSS变量?
我承认的确不喜欢这个语法,但理解它不只是随意的选择很重要。CSS工作组的成员讨论过语法的长度,他们必须选择与CSS语法兼容的东西,且不会与将来添加的语言冲突。
关于CSS变量和Sass变量,我认为最大的误解在于:
原生的CSS变量并不是试图复制CSS预处理器早已经实现的功能。事实上,如果读过最初的设计讨论,就知道原生CSS变量的目的是为了实现预处理器不能实现的功能。
CSS预处理器是很好的工具,但其变量是静态的,限定了语法作用域。另一方面,原生CSS变量是一种完全不同类型的变量:动态的,作用域是DOM。事实上,称它们为变量会令人迷惑。它们实际上是CSS属性,这为它们提供了一组完全不同的能力集,用来解决一系列完全不同的问题。
在本文中,我将讨论一些可用CSS自定义属性实现但不能用预处理器变量实现的事情,还会演示一些自定义属性能实现的新设计模式,最后讨论为什么我会认为以后很有可能一起使用预处理器变量和自定义属性达到两全其美。
**注意:**这篇文章并不是介绍CSS自定义属性。如从没听过或对它们如何运作不熟悉,建议先看 getting yourself acquainted。
预处理器变量的局限性
在继续之前,先强调一下我对CSS预处理器的喜爱,我在所有的项目中都使用它们。预处理器可以做一些相当神奇的东西,甚至你知道它们最后只是输出原生CSS,但仍时不时感到神奇。
话虽如此,和任何工具一样,它们各自有各自的局限性,有时动态力量的出现能使那些局限取得出乎意料之外的效果,尤其对于新用户来说。
预处理器变量不是实时的
也许令新手惊讶的是,预处理器局限性最常见的情况是Sass无法在媒体查询中定义变量或使用@extend
。由于本文主要讲变量,所以重点讲前者:
$gutter: 1em;
@media (min-width: 30em) {
$gutter: 2em;
}
.Container {
padding: $gutter;
}
上面代码将编译为:
.Container {
padding: 1em;
}
如你所见,媒体查询块被丢弃,变量赋值被忽略。
尽管理论上Sass可能使条件变量声明生效,但这样做是有挑战性的,需要枚举所有的排列。这将指数级增加CSS文件的最终大小。
由于无法在匹配@media
规则的基础上改变变量,所以唯一的选择是为每个媒体查询分配一个唯一的变量,并单独编写每个变体。稍后会有更多的介绍。
预处理器变量不能级联
每当使用变量,作用域的问题就不可避免的出现。这个变量应该设置为全局变量吗?是否应该限定其范围为文件或模块?是否应该限制在块中?
由于CSS最终目的是为HTML添加样式,事实证明还有另一种有效的方法给变量限定作用域:DOM元素。但由于预处理器不在浏览器中运行并且无法看到标记,它们不能这样做。
假设有一个网站,面对偏好较大文字的用户,就向<html>
元素添加类user-setting-large-text
。当设置了这个类时,应当应用较大的$font-size
变量赋值:
$font-size: 1em;
.user-setting-large-text {
$font-size: 1.5em;
}
body {
font-size: $font-size;
}
但同样,就像上面的媒体块示例,Sass完全忽略了该变量的赋值,这意味着这是不可能发生的。编译后的代码如下:
body {
font-size: 1em;
}
预处理器变量不继承
虽然继承严格说来是级联的一部分,之所以把它单独分出来讲,是因为多次想调用这个特性却不得。
假设一种情况,要在DOM元素上基于其父元素应用的颜色而设置样式:
.alert {
background-color: lightyellow;
}
.alert.info {
background-color: lightblue;
}
.alert.error {
background-color: orangered;
}
.alert button {
border-color: darken(background-color, 25%);
}
上面的代码并不是有效的Sass(或CSS),但你应该明白它想达到什么目的。
最后一句声明试图在<button>
元素从父元素.alert
元素继承的background-color
属性使用Sass的darken
函数。如果类info
或error
已经加在了.alert
上(或如果background-color
已通过JavaScript或用户样式设置),button
元素能据此作出相应的响应。
显然这在Sass中行不通,因为预处理器不知道DOM结构,但希望你清楚的认识到为什么这类东西是有用的。
调用一个特定的用例:出于可访问性的原因,在继承了DOM属性上运行颜色函数是极其方便的。例如,确保文本始终可读,并充分与背景颜色形成鲜明对比。 有了自定义属性和新的CSS颜色函数,很快这将成为可能。
预处理器变量不可互操作
这是预处理器相对明显的一个缺点,提到它是因为我觉得它重要。如果你正使用PostCSS来构建网站,想使用只能通过Sass实现主题化的第三方组件,那你真是不走运了。
跨不同的工具集或CDN上托管的第三方样式表共享预处理器变量是不可能(或至少不容易)的。
原生的CSS自定义属性可以与任何CSS预处理器或纯CSS文件一起使用。反之则不然。
自定义属性如何与众不同
你或许已猜到,上面罗列的限制不适用于CSS自定义属性。但比不适用更重要的是为什么不适用。
CSS自定义属性就像普通的CSS属性,它们以完全相同的方式操作(明显的不同是它们并不设置任何样式)。
和普通CSS属性一样,自定义属性是动态的。在运行时可改变,在媒体查询中可更新或向DOM中添加新类,可使用选择器内联(在元素上)或在常规CSS中声明中分配,可使用级联的所有的常规规则或使用JavaScript进行更新或覆盖。也许更重要的是,自定义属性可继承,当被应用到一个DOM元素中时,它们被传递给该元素的子元素。
为了更简洁,预处理器的变量被限制在语法作用域,并且编译之后是静态的。自定义属性的作用域是DOM,是实时的,动态的。
现实生活的例子
如果你仍不确定什么是自定义属性可以做到而预处理器变量不能做到的,我准备了一些例子:
为了证明其价值,有很多很好的例子我想展示,但为了不让篇幅太长,只选择了两个。
之所以挑选这些例子是因为他们不只是理论,而是以前遇到的实际挑战。我对于想使用预处理器却不能实现的情景历历在目。有了自定义属性,成为了现实。
响应式属性与媒体查询
很多网站都会使用一个gap
或gutter
变量定义布局中项目之间的默认间距及页面上所有不同部分的默认padding
。大多数时候,设置的gutter
值根据浏览器窗口的大小而有所不同。在大屏幕上,项目间的间距会很大,但在较小的屏幕上空间不足不能承受太多空白,所以gutter
值需要设置为较小值。
如上所述,Sass变量在媒体查询中不起作用,因此必须单独编写每个变体。
下面的示例定义了变量$gutterSm
, $gutterMd
和$gutterLg
,然后为每个变体声明了单独的规则:
/* Declares three gutter values, one for each breakpoint */
$gutterSm: 1em;
$gutterMd: 2em;
$gutterLg: 3em;
/* Base styles for small screens, using $gutterSm. */
.Container {
margin: 0 auto;
max-width: 60em;
padding: $gutterSm;
}
.Grid {
display: flex;
margin: -$gutterSm 0 0 -$gutterSm;
}
.Grid-cell {
flex: 1;
padding: $gutterSm 0 0 $gutterSm;
}
/* Override styles for medium screens, using $gutterMd. */
@media (min-width: 30em) {
.Container {
padding: $gutterMd;
}
.Grid {
margin: -$gutterMd 0 0 -$gutterMd;
}
.Grid-cell {
padding: $gutterMd 0 0 $gutterMd;
}
}
/* Override styles for large screens, using $gutterLg. */
@media (min-width: 48em) {
.Container {
padding: $gutterLg;
}
.Grid {
margin: -$gutterLg 0 0 -$gutterLg;
}
.Grid-cell {
padding: $gutterLg 0 0 $gutterLg;
}
}
使用自定义属性实现完全一样的效果,只需定义一次样式。可以使用单个--gutter
属性,当匹配的媒体改变时,就更新--gutter
的值,其他的都会随之响应。
/* Declares what `--gutter` is at each breakpoint */
:root {
--gutter: 1.5em;
}
@media (min-width: 30em) {
:root {
--gutter: 2em;
}
}
@media (min-width: 48em) {
:root {
--gutter: 3em;
}
}
/*
* Styles only need to be defined once because
* the custom property values automatically update.
*/
.Container {
margin: 0 auto;
max-width: 60em;
padding: var(--gutter);
}
.Grid {
--gutterNegative: calc(-1 * var(--gutter));
display: flex;
margin-left: var(--gutterNegative);
margin-top: var(--gutterNegative);
}
.Grid-cell {
flex: 1;
margin-left: var(--gutter);
margin-top: var(--gutter);
}
即使语法冗长,完成相同的事情所需的代码量也大大减少。而这只考虑了三个变量的情况。变量越多,节省的代码更多。
以下的demo使用了上述的代码构建一个基本的网站布局,当视口宽度改变时自动重定义gutter
值。请在支持自定义属性的浏览器中查看它是如何工作的。
根据上下文编写样式
根据上下文编写样式(根据元素在DOM的位置编写样式)是CSS中颇具争议的话题。一方面,这是来自大多数备受尊重的CSS开发者的警告。而另一方面,这是大多数人每天都做的事情。
@Harry Roberts就对此写了一篇博文。
如果需要根据所处的位置改变UI组件的装饰,那么你的设计系统是失败的。东西应该被设置为无知的,应该被设置为我们总是有这个组件,而非只有在某个类里面才有这个组件
虽然我在这方面支持Harry(和大多数事情),我认为大多数人在这些情况下走捷径的事实也许说明了一个更大的问题:CSS的表现力有限,大多数人并不满意现有的“最佳实践”。
以下的示例显示了大多数人在CSS中如何使用后代组合器处理上下文编写样式。
/* Regular button styles. */
.Button { }
/* Button styles that are different when inside the header. */
.Header .Button { }
这种方法存在很多问题(在我的文章CSS架构中有解释)。识别这种模式是否有代码异味的一种方法,是它违反了软件开发的开放/封闭原则,修改了封闭组件的实现细节。
软件实体(类,模块,函数等)应当利于扩展,不允许被修改。
自定义属性以一种有趣的方式改变了定义组件的范式。有了自定义属性,我们第一次可以编写真正对扩展开放的组件。下面是一个例子:
.Button {
background: var(--Button-backgroundColor, #eee);
border: 1px solid var(--Button-borderColor, #333);
color: var(--Button-color, #333);
/* ... */
}
.Header {
--Button-backgroundColor: purple;
--Button-borderColor: transparent;
--Button-color: white;
}
自定义属性和后代组合器的示例之间的区别微妙而重要。
当使用后代组合器时,我们声明在header
中的button
看起来将是这样,而这种方式不同于按钮button
组件定义自己的方式。这样的声明是独裁的(借用Harry的话)而且header
中的button
在不需要看起来这样的异常情况下不利于复原。
另一方面,使用自定义属性,button
组件仍然对上下文环境一无所知,并且对头部组件完全解耦。其声明只是说:我将根据这些自定义属性来编写自己的样式,不管它们在不在我的现状中。而头部组件只是说:我要设置这些属性值,由我的后代决定是否使用和如何使用它们。
主要区别在于,扩展由button
组件选择性加入,并且在异常的情况下容易撤销。
以下的demo对网站头部和内容区域的链接及按钮都对上下文样式进行了说明。
制定例外
为了进一步说明在这个范式中怎样更容易制造异常,想象一下,如果.Promo
组件被加到了头部中,而.Promo
中的button
需要看起来像普通button
,而不是头部的button
。
若使用后代组合器,则必须为头部button
写一堆样式,然后为promo
里面的button
撤销这些样式,这样做既混乱又容易出错,并随着组合器的数量增加而易于失控。
/* Regular button styles. */
.Button { }
/* Button styles that are different when inside the header. */
.Header .Button { }
/* Undo button styles in the header that are also in promo. */
.Header .Promo .Button { }
使用自定义属性,可以轻松更新所需的button
属性,或重设回到默认样式,且无需管异常的数量,改变样式的方法总是一样的。
.Promo {
--Button-backgroundColor: initial;
--Button-borderColor: initial;
--Button-color: initial;
}
从React中学习
当我第一次通过自定义属性探索上下文样式的想法时,我是持着怀疑的态度的。如我所说,我倾向于上下文无关的组件,定义自己的变量而不是适应从父元素继承的任意数据。
但帮助我改变看法的是通过对比CSS的自定义属性和React中的props
。
React的props
同样是动态的,DOM作用域变量,可继承,允许组件依赖上下文。在React中,父组件传递数据给子组件,子组件定义愿意接受的属性和如何使用它们。这种架构模型通常称为单向数据流。
尽管自定义属性是一项新的,未经测试的领域,React模型的成功使我相信一个复杂的系统可以建立在属性继承的基础上,此外,DOM作用域变量是一种有效的设计模式。
副作用最小化
CSS自定义属性默认继承。在某些情况下可能导致组件以它们不想要的方式设置样式。
如上所示,可以通过重置单个属性值来防止这种情况,从而防止未知值被应用到元素的子元素上。
.MyComponent {
--propertyName: initial;
}
尽管还不是规范的一部分,但--property
已经被讨论过,它可以用于重置所有的自定义属性。如果想将几个属性列入白名单,可以将它们单独设置为继承,这将允许它们继续正常操作。
.MyComponent {
/* Resets all custom properties. */
--: initial;
/* Whitelists these individual custom properties */
--someProperty: inherit;
--someOtherProperty: inherit;
}
管理全局名称
如果你留意到我是如何命名自定义属性的,你可能已注意到我把组件-特定属性及组件自身的类名作为前缀,如:--Button-backgroundColor
。
和CSS的大多数名称一样,自定义属性是全局的,总有可能与团队中其他开发人员使用的名称存在冲突。
一种避免这个问题的简单做法是坚持命名约定,正如我在这所做的那样。
对于更复杂的项目,你可能需要考虑使用CSS模块,它定位了所有的全局名称及近来表现出支持自定义属性的兴趣。
总结
如果阅读本文前不熟悉CSS自定义属性,希望你已被说服给它一个机会,如果你是对其必要性持怀疑态度的人之一,希望你已改变主意。
自定义属性为CSS带来了一套新的动态的、强大的功能,我相信最大的优势还有待发掘。
自定义属性填补了预处理器变量不能填补的空白。尽管如此,在许多情况下预处理器变量仍是易用与优雅的选择。正因如此,我坚信未来许多网站会结合使用两者。自定义属性用于动态主题,预处理器变量用于静态模板。
我不认为这是一个二选一的情况。将它们作为竞争对手相互抗衡对双方都不利。
特别感谢@Addy Osmani 和 @Matt Gaunt审阅本文,感谢@Shane Stephens优先解决了一个Chrome的bug让Demos得以运行。
本文根据@Ohans Emmanuel的《Why I'm Excited About Native CSS Variables》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://philipwalton.com/articles/why-im-excited-about-native-css-variables/。
如需转载,烦请注明出处:https://www.fedev.cn/css3/why-im-excited-about-native-css-variables.htmlAir Jordan VII 7.5 Ture Flight