前端开发者学堂 - fedev.cn

在CSS自定义属性中有效使用无效变量

发布于 大漠

CSS自定义属性已是CSS中非常成熟的特性了,也是近两年非常受欢迎的特性。它的到来让CSS像其他编程语言一样,可以在编写CSS的时候变量,而且还可以在CSS具备动态计算逻辑运算状态切换等特性。特别是CSS Houdini的CSS自定义属性更进一步的扩展了CSS的能力。

就我个人而言,在几年前CSS自定义属性就进入我的世界,而且相关的规范也阅读过好几次。但自从阅读了@Lea Verou的《The -​-var: ; hack to toggle multiple values with one custom property》一文之后,我才发现自己遗漏了CSS自定义属性中一个非常关键,而且非常强大的特性。那就是CSS自定义属性中的无效变量,即var()函数中使用了一个无效的变量。如果我们在使用CSS自定义属性的时候,要是有效的使用了无效变量,它可以帮助我们实现一些非常有意思的功能。比如说状态切换,主题切换等。在接下来,我们就一起来探讨这方面的话题。

前景

@Lea Verou在《The -​-var: ; hack to toggle multiple values with one custom property》一文中介绍了CSS自定义一种特性,即:使用一个单一的属性值来开启或关闭多个不同的属性,甚至是多个CSS规则。比如下面这个示例,当你将鼠标移动到按钮上,按钮从一个扁平的效果过渡到一个凸起(带有渐变,高亮,边框)的按钮:

代码很简单:

button {
    --is-raised: ; /* off by default */

    border: 1px solid var(--is-raised, rgb(0 0 0 / 0.1));
    background: var(
            --is-raised,
            linear-gradient(hsl(0 0% 100% / 0.3), transparent)
        )
        hsl(200 100% 50%);
    box-shadow: var(
        --is-raised,
        0 1px hsl(0 0% 100% / 0.8) inset,
        0 0.1em 0.1em -0.1em rgb(0 0 0 / 0.2)
    );
    text-shadow: var(--is-raised, 0 -1px 1px rgb(0 0 0 / 0.3));
}

button:hover {
    --is-raised: initial; /* turn on */
}

button:hover状态时,自定义属性--is-raised从一个空字符串(--is-raised: ;)变成了initial--is-raised: initial;),就轻易地实现了两种状态的UI切换。最初我一直没搞明白这其中的原理是什么?后来重新阅读CSS自定义属性规范时,才发现我自己遗漏了其中最为关键的一个特性,即 无效变量(Invalid Variables)

需要了解的几个关键信息

在详细介绍CSS自定义属性中“无效变量”之间,我们有几个关键信息需要先了解一下,因为掌握这些关键信息有利于我们更好的理解CSS自定义属性中的“无效变量”所起的作用。也就是,为什么可以使用CSS自定义属性就可以轻易实现“一个单一的属性值来开启或关闭多个不同的属性,甚至是多个CSS规则”。

回退值(Fallback Value)

接触过CSS自定义属性的同学应该都了解,在CSS中使用var()函数调用CSS自定义属性时,该自定义属性在var()函数中就变成了CSS的变量:

var()函数接受两个参数,其中第一个参数是要替换的自定义属性名;第二个参数,如果提供的话是一个回退值,即当被引用的自定义属性无效时,它被用作自定义属性的回退值。比如下面这个示例:

.element {
    color: var(--color, red)
}

就该示例而言,--color并没有被定义,那么这个时候,var()将会取其第二个参数red作为其回退值,并赋值给color属性。

另外一种情况是,在代码中显式声明了自定义属性,但该自定义属性运用于某些CSS属性上时,它是个无效值,比如:

.element {
    --color: 20;
    border: 3px double var(--color, red);
    color: var(--color, blue);
}

这个示例中我们显式声明了--color自定义属性,而且其值是20,对于--color自定义属性是个有效值,但var()引用--color时,--color是有效的,此时var()的回退值就不会起作用。此时相当于:

.element {
    border: 3px double 20;
    color: 20;
}

此对colorborder是个无效的值,会被忽略。但对于color属性来说,虽然是无效,但这个时候浏览器在计算时,会继承其父元素的color值,该示例对应的是浏览器默认文本颜色,即#000

乍一看似乎很混乱,但有充分的理由。第一个是技术原因:浏览器引擎在“解析时间”(先发生)处理无效或未知的语法,但变量要到“计算值时间”(后发生)才会被解析

  • 在解析时,无效语法的声明会被完全忽略(回退到之前的声明),而之前的声明会被丢弃
  • 在计算值时,变量被编译为无效,但为时已晚(之前的声明已经被丢弃了)

根据规范,无效的变量值和未设置的变量值会像unset一样解析。

这对于开发者而言是好事,因为它允许我们为提供更复杂的回退值。更妙的是,这允许我们使用nullundefined状态来设置所需参数。

另外规范中对“ 要在一个属性的值中替换一个var() ”做出了明确的指导:

  • 如果var()函数的第一个参数命名的自定义属性被动画污染(animation-tainted),并且var()函数被用于动画属性(animation)或它的一个简写属性,那么本算法的其余部分将自定义属性视为具有初始值(initial
  • 如果var()函数的第一个参数命名的自定义属性的值不是初始值(initial),则用相应的自定义属性的值替换var()函数;否则,则用var()函数的第二个参数值(当然,var()要显式设置了回退值)。如果var()的回退值中引用了任何var()函数,使用原则是相同的;如果没有var()没有回退值,那么var()函数的属性在计算值时是无效的

也就是说:

var()函数是在计算值时间(Computed-Value)被替换的。如果一个声明,一旦所有的var()函数都被替换进来,那么这个声明在计算值时间是无效的。

保证无效值(Guaranteed-Invalid Value)

如果自定义属性的值是initial,那它就是一个保证无效的值(Guaranteed-Invalid Value)。正如前面提到的,使用var()将一个自定义属性替换为这个值(initial),将会使引用它的属性在计算值时无效。

这个值序列化为空字符串,但实际上在自定义属性中写一个空值,比如--foo: ;是一个有效的(空)值,而不是保证无效的值。不管出于什么原因,想要手动将一个变量重置为保证无效的值,只需要使用关键词initial就可以做到。

说到自定义属性的空值就很有意思了,比如:

:root {
    --color: ;
    --borderColor:;
}

示例中--color--borderColor自定义属性都没有设置其他的值,唯一不同的是--color后面紧跟的冒号(:)和分号(;)之间有一个空格硬编码(记住,在编码的时候手动敲了一个空格),--borderColor后面紧跟的冒号和分号之间却没有这个空格。他们同时被var()函数引用的时候,却有天壤之别,--color是有一个有效的自定义属性,而--borderColor是一个无效的,如果var()函数提供回退值时,那么引用--borderColor变量的var()函数将会使用回退值替换--borderColor设置的值。

:root {
    --color: ;
    --borderColor:;
}

.element {
    border: 3px double var(--borderColor, red);
    color: var(--color, blue);
}

示例结果和我们前面描述是一致的。--borderColor:;是一个保证无效的值(等同于--borderColor: initial),因此会采用回退值red作为border-color的值,所以看到的边框颜色是red;而--color: ;是一个有效值,这个时候即使var()函数提供了回退值blue,也不会被使用。color取了一个空值,不过color会继承其父元素的color值(此例是#000),因此你看到的文本颜色是黑色。

虽然在自定义属性中使用--foo:;方式可以和使用--foo: initial;让该自定义属性是一个保证无效的值,但使用--foo:;在可读性上不怎么好,甚至对于不了解该特性的同学来说会以为是一个错语;而使用--foo: initial;方式对于不了解该技术的同学来说同样会让人感到奇怪。因此,为了提高代码可读性,最好是在后面添加相应的代码注释。

计算值(Computed Value)

了解CSS工作机制的同学都应该知道,CSS属性的最终值会经过四步计算:

  • 通过指定来确定值,常称之为 指定值(Specified Value)
  • 接着处理得到一个用于继承的值,常称之为 计算值(Computed Value)
  • 然后如果有必要的话转化为一个绝对值,常称之为 应用值(Used Value)
  • 最后根据本地环境限tmhj进行转换,常称之为 实际值(Actual Value)

其中,指定值的是 通过层叠被处理为计算值 ,例如,URI被转换成绝对的,emex单位被计算为像素或者绝对长度。计算一个值并不需要用户代理渲染文档。用户代理规则无法处理为绝对URI的话,该URI的计算值就是指定值。

一个属性的计算值由属性定义中Computed Value行决定。当指定值为inherit时,计算值的定义可以依据继承中介绍的规则来计算。即使属性不适用(于当前元素),其计算值也存在,定义在'Applies To'行。然而,有些属性可能根据属性是否适用于该元素来定义元素属性的计算值

理解起来似乎有点晕,这其实涉及到CSS的基础概念和浏览器(客户端)渲染机制相关的知识。抛开别的不说,浏览器的开发者调试器中有一个名叫“Computed”栏,选中元素时,可以查看到该元素最终被计算出来的CSS属性值:

在计算值时间无效

在介绍保证无效值(Guaranteed-Invalid Value)时,提到另一个术语,即 在计算值时无效

如果一个声明包含一个引用了具有保证无效值的自定义属性的var(),比如:

.element {
    --color: initial;
    color: var(--color, red);
}

或者它使用了一个有效的自定义属性,但在替换了它的var()函数之后,属性值是无效的,比如:

.element {
    --color: 20;
    color: var(--color)
}

那么这个声明在计算值时可能是无效的。当这种情况发生时,根据属性的类型,计算出的值是以下几种情形之一:

  • 该属性是非注册的自定义属性
  • 该属性是一个注册的自定义属性,且具有通用语法,计算的值是保证无效值
  • 否则,要么是属性的继承值,要么是它的初始值,分别取决于属性是否被继承,就像属性的值被指为unset关键词一样

比如:

:root {
    --not-a-color: 20px;
}

p {
    background-color: red;
}

p {
    background-color: var(--not-a-color);
}

p元素的background-color计算值将是transparent(因为background-color的初始值是transparent)而不是red

如果自定义属性本身没有设置,或者包含一个无效的var()函数,也会发生同样的情况。

注意,这和开发者直接在样式中写background-color:20px的情况不同,因为在样式这样书写会被视为CSS语法错误,会导致该规则background-color被忽略(丢弃),因此会使用background-color: red规则。

计算值时间无效的概念之所以存在,是因为变量不能像其他语法错误那样“ 提前失效 ”,所以当用户代理意识到一个属性值无效的时候,它已经把其他的级联值扔掉了(正如该示例中的p{background-color: red}就被扔掉了)。

CSS继承的机制

CSS继承的机制是 CSS层叠和继承中的概念。在CSS中提供了处理CSS继承的机制,简单地讲就是CSS提供了几个属性值,可以用来处理属性的继承。这几个属性值就是initialinheritunsetrevert。其实除了这四个属性值之外,还有一个all属性值。

另外,CSS的属性有“可继承”和“不可继承”之分,比如font-size就是可继承的,position就是不可继承,每个CSS属性在相应的规范文档中都可以查到:

同样的,属性有没有提供相应的初始值,规范中也有相应的描述:

revertunsetall相对来说要复杂一点。

  • revert表示没有使用任何属性值
  • unsetinitialinherit的结合
  • all是一个简写属性,其重设除了unicode-bididirection之外的所有属性至它们的初始值或继承值

这几个值的具体使用,可以参照下图:

上图来自于@Elad Shechter的《How Does CSS Work?》一文。

有关于CSS的继承机制更详细的介绍可以阅读:

无效变量

了解了上面这些基本概念之后,再来理解CSS自定义属性中的“无效变量”就好理解了。规范中这样描述”无效变量“

当一个自定义属性的值是initial时,var()函数不能使用它进行替换。除非指定了一个有效的回退值,否则会使声明在计算值时无效

也就是说,当一个自定义属性的值是一个保证无效的值时,var()函数不能使用它进行替换。即一个声明包含一个引用了具有保证无效值的自定义属性的var()函数,或者它使用了一个有效的自定义属性,但在替换了它的var()函数之后,属性值是无效的,那么这个声明在计算值时可能是无效的。当这种情况发生时,属性的计算值要么是属性的继承值,要么是它的初始值,分别取决于属性是否被继承,就像属性的值被指定为unset关键字一样。

其中原因是继承的标准属性将初始化处理为unset,除了行为是”从根开始未设置“。而且前面也说过:

级联值在计算值时间无效时就应该被仍掉

比如下面这个示例:

<!-- HTML -->
<div class="element">
    <i>Element</i>
</div>

/* CSS */
body {
    color: #fff;
}

.element {
    --color: red;
}

.element i {
    --foo: initial;
    --color: var(--foo);
    background-color: var(--color, orange);
}

如果<i>嵌套在div.element中,那么<i>元素的background-colorred,我们分别来将div.elementi中声明的自定义属性打印出来:

.element i 中声明的--color是一个保证无效的值,但这个时候background-color--color会引用其父元素声明的--color,所以看到的最终结果是red,并不是所期望的orange。如果<i>元素不是div.element的子元素时(或后代元素时),即:

<!-- HTML -->
<div class="element">
    <i>Element</i>
</div>

<i>Element</i>

/* CSS */
body {
    color: #fff;
}

.element {
    --color: red;
}

i {
    --foo: initial;
    --color: var(--foo);
    background-color: var(--color, orange);
}

因为第二个<i>并不是div.element的子元素,那它的--color这个时候和--foo一样,都是保证无效值,因此会采用var()函数的回退值,也就是orange

如果i中的样式调整一下:

i {
    --foo: initial;
    --color: var(--foo, #f36);
    background-color: var(--color, orange);
}

这个时候,--foo是个保证无效值,因此--color会取var()的回退值,这个时候,两个i元素的background-color都将是#f36

将对应的自定义属性的值打印出来:

但又不等同于:

.element {
    --color: red;
}

i {
    --foo: initial;
    background-color: var(--color, var(--foo, #f36));
}

我想你能猜到两个i元素的背景色是什么吧:

我想,通过这个示例,你对CSS自定义属性中的无效值有更深的了解了吧。如果没有理解的话,可以记住这两点:

  • 在同一作用域中,如果自定义属性的值是initial,表示该自定义属性是一个保证无效值,那么它将会采用var()回退值,如果var()未设置回退值,那么会根据属性的unset来设置值
  • 如果不在同一作用域中,当自定义属性值是保证无效值时,会类似JavaScript事件冒泡机制,向上寻找同名称的自定义属性,如果未找到,则会采用var()的回退值,要是未设置回退值将会根据属性的unset取值;如果向上找到同名称的自定义属性,将会采用父(祖先)同名的自定义属性的值

我们回过头来看@Lea Verou 提供的示例:

button {
    --is-raised: ; /* off by default */
    border: 1px solid var(--is-raised, rgb(0 0 0 / 0.1));
    background: var(
        --is-raised,
        linear-gradient(hsl(0 0% 100% / 0.3), transparent)
        )
        hsl(200 100% 50%);
    box-shadow: var(
        --is-raised,
        0 1px hsl(0 0% 100% / 0.8) inset,
        0 0.1em 0.1em -0.1em rgb(0 0 0 / 0.2)
    );
    text-shadow: var(--is-raised, 0 -1px 1px rgb(0 0 0 / 0.3));
}

button:hover {
    --is-raised: initial; /* turn on */
}

根据前面介绍的,当--is-raised的值是个空字符串( )时,--is-raised是个有效值,那么:

  • border的值是1px solid ;solid后面有一个空格符),border-color的值为currentColor
  • background的就是 hsl(200 100% 50%);hsl前面有一个空格符)
  • box-shadowtext-shadow的值是 ,最终的值将是它们的初始值none

button在悬浮状态(:hover)时--is-raised的值是initial,这个时候--is-raised是一个保证无效值,对应的:

  • border的值是1px solid rgb(0 0 0 / 0.1);,即--is-raised取了var()函数的回退值rgb(0 0 0 / 0.1)
  • background的值是linear-gradient(hsl(0 0% 100% / 0.3), transparent) hsl(200 100% 50%),即--is-raised取了var()函数的回退值linear-gradient(hsl(0 0% 100% / 0.3), transparent)
  • box-shadow的值是0 1px hsl(0 0% 100% / 0.8) inset, 0 0.1em 0.1em -0.1em rgb(0 0 0 / 0.2),即--is-raised取了var()函数的回退值0 1px hsl(0 0% 100% / 0.8) inset, 0 0.1em 0.1em -0.1em rgb(0 0 0 / 0.2)
  • text-shadow的值是0 -1px 1px rgb(0 0 0 / 0.3),即--is-raised取了var()函数的回退值0 -1px 1px rgb(0 0 0 / 0.3)

因此你所看到的效果如下:

实现了两种UI效果,在同一个属性上对两个值做了切换。虽然效果出来了,但--is-raised: ;--is-raised: initial;不易于阅读和理解。而且--is-raised的值是从 initial切换的状态(即开(ON)和关(OFF))切换,这样的话,可以将上面的Demo改成下面这样:

:root {
    --ON: initial;
    --OFF: ;
}

button {
    --is-raised: var(--OFF); 
    
    border: 1px solid var(--is-raised, rgb(0 0 0 / 0.1));
    background: var(
        --is-raised,
        linear-gradient(hsl(0 0% 100% / 0.3), transparent)
        )
        hsl(200 100% 50%);
    box-shadow: var(
        --is-raised,
        0 1px hsl(0 0% 100% / 0.8) inset,
        0 0.1em 0.1em -0.1em rgb(0 0 0 / 0.2)
    );
    text-shadow: var(--is-raised, 0 -1px 1px rgb(0 0 0 / 0.3));
}

button:hover {
    --is-raised: var(--ON);
}

button:active {
    box-shadow: var(--is-raised, 0 1px 0.2em black inset);
}

注意:在CSS自定义属性中,注册自定义属性名称时,它是有大小写之分的,因此--on--ON或者--On代表的都是不同的自定义属性

自定义属性中有效使用无效变量案例

上面我们看到的示例是非常简单地。在我们实际开发中,除了UI效果的切换之外,还会有一些状态的切换,比如说iOS的暗黑模式(暗色和亮色的切换),Switch的切换,或者说不同屏幕之下组件字号、间距切换等。接下来,我们就来看看如何使用前面介绍的原理实现这些效果。

先来看下面这个示例:

上图来自于@Rob Stinson在Codepen提供的示例

看到这个效果,你可能会想到换肤或者暗黑模式等

如果忽略第三部分仅看前面两种状态,那和暗黑模式真没啥区别。实现这样的效果,或许你会先想到通过CSS的媒体查询来切换两种状态

:root { 
    /* Light theme */ 
    --c-text: #333; 
    --c-background: #fff; 
} 

.theme-container { 
    color: var(--c-text); 
    background-color: var(--c-background); 
} 

@media (prefers-color-scheme: dark) { 
    :root { 
        /* Dark theme */ 
        --c-text: #fff; 
        --c-background: #333; 
    } 
}

不过我们要和大家一起来看,如何使用前面介绍的技术,不依赖媒体查询实现这个效果。为了简化示例的复杂度,先实现下图中黑色和白色(对应的12)的效果:

以往像下在这样就可以实现两种皮肤色的UI效果:

.nav {
    /* Dark */
    --dark-color: rgba(156, 163, 175, 1);
    --dark-bgcolor: rgba(17, 24, 39, 1);
    --dark-active-bgcolor: rgba(55, 65, 81, 1);
    
    /* Light */
    --light-color: rgba(55, 65, 81, 1);
    --light-bgcolor: rgba(243, 244, 246, 1);
    --light-active-bgcolor: rgba(209, 213, 219, 1);

    color: var(--dark-color);
    background-color: var(--dark-bgcolor);
}

a.active,
a:hover {
    background-color: var(--dark-active-bgcolor);
}

.nav.light {
    color: var(--light-color);
    background-color: var(--light-bgcolor);
}

.nav.light a.active,
.nav.light a:hover {
    background-color: var(--light-active-bgcolor);
}

如果我们换成今天所介绍的技术来完成的话,我们可以像下面这样改造:

/* 设置切换开关 */
:root {
    --ON: initial;
    --OFF: ; 
}

/* 默认为Dark */
.nav,
.dark {
    --light: var(--OFF);
    --dark: var(--ON);
}

/* 默认为Light */
.light {
    --light: var(--ON);
    --dark: var(--OFF);
}

再回过头来,看我们的示例,颜色有变化的主要是:

主题 nav背景色 nav文本色 a当前状态和悬浮状态背景色
暗色(Dark) --dark-bgcolor --dark-color --dark-active-bgcolor
亮色(Light) --light-bgcolr --light-color --light-active-bgcolor

将这些自定义属性和前面定义的开关结合起来运用到对应的CSS属性中:

.nav {
    color: var(--light, var(--light-color)) var(--dark, var(--dark-color));
    background-color: var(--light, var(--light-bgcolor)) var(--dark, var(--dark-bgcolor));
}

同样的方式对a链接悬浮(:hover)状态和当前状态(.active)调整样式:

a.active,
a:hover {
    background-color: var(--light, var(--light-active-bgcolor)) var(--dark, var(--dark-active-bgcolor));
}

整个的代码如下:

.nav {
    --dark-color: rgba(156, 163, 175, 1);
    --dark-bgcolor: rgba(17, 24, 39, 1);
    --dark-active-bgcolor: rgba(55, 65, 81, 1);

    --light-color: rgba(55, 65, 81, 1);
    --light-bgcolor: rgba(243, 244, 246, 1);
    --light-active-bgcolor: rgba(209, 213, 219, 1);

    
    color: var(--light, var(--light-color)) var(--dark, var(--dark-color));
    background-color: var(--light, var(--light-bgcolor))
        var(--dark, var(--dark-bgcolor));
}

a.active,
a:hover {
    background-color: var(--light, var(--light-active-bgcolor))
    var(--dark, var(--dark-active-bgcolor));
}

/* 设置切换开关 */
:root {
    --ON: initial;
    --OFF: ;
}

/* 默认为Dark */
.nav,
.dark {
    --light: var(--OFF);
    --dark: var(--ON);
}

/* 默认为Light */
.light {
    --light: var(--ON);
    --dark: var(--OFF);
}

相比之下,代码更简洁了(不懂这技术的话,理解起来会更痛苦一点)。如果需要根据设备来自动调整的话,我们只在媒体查询中改变开关值:

.nav {
    --light: var(--ON);
    --dark: var(--OFF);
}

@media (prefers-color-scheme: dark) {
    .nav {
        --light: var(--OFF);
        --dark: var(--ON);
    }
}

因为我的系统默认是暗色系,所以看到的效果如下:

调整系统设置可以看到暗色和亮色的切换:

如果你仅是为了验证效果的话,可以直接在Chrome浏览器中调整主题色的值:

如果为了用户有更好的体验,可以给Demo添加一个切换开关:

简单回顾一下,就拿color为例吧:

.nav {
    color: var(--light, var(--light-color)) var(--dark, var(--dark-color));
}

--light取值为var(--ON)--dark取值为var(--OFF)时:

  • --light是一个保证无效值,因此会取var()的回退值var(--light-color),对应的就是rgba(55, 65, 81, 1)
  • --dark是一个有效值,因此--dark会取一个空值

此时,color的值就是 color: rgba(55, 65, 81, 1) ;)右括号后面有一个空格符)。

--light取值为var(--OFF)--dark取值为var(--ON)时:

  • --light是一个有效值,此时--light会取一个空值
  • --dark是一个保证无效值,因此会取var()的回退值var(--dark-color),对应的就是rgba(156, 163, 175, 1)

此时,color的值就是 color: rgba(156, 163, 175, 1);r字母前面有一个空格)。

接着我们使用同样的方式来实现三种值的切换。

.nav {
    /* Dark */
    --dark-color: rgba(156, 163, 175, 1);
    --dark-bgcolor: rgba(17, 24, 39, 1);
    --dark-active-bgcolor: rgba(55, 65, 81, 1);

    /* Light */
    --light-color: rgba(55, 65, 81, 1);
    --light-bgcolor: rgba(243, 244, 246, 1);
    --light-active-bgcolor: rgba(209, 213, 219, 1);

    /* Blue */
    --blue-color: rgba(165, 180, 252, 1);
    --blue-bgcolor: rgba(49, 46, 129, 1);
    --blue-active-bgcolor: rgba(67, 56, 202, 1);

    color: var(--light, var(--light-color)) var(--dark, var(--dark-color))
        var(--blue, var(--blue-color));
    background-color: var(--light, var(--light-bgcolor))
        var(--dark, var(--dark-bgcolor)) var(--blue, var(--blue-bgcolor));
}

a.active,
a:hover {
    background-color: var(--light, var(--light-active-bgcolor))
        var(--dark, var(--dark-active-bgcolor))
        var(--blue, var(--blug-active-bgcolor));
}

/* 设置切换开关 */
:root {
    --ON: initial;
    --OFF: ;
}

/* 默认为Dark */
.dark {
    --light: var(--OFF);
    --dark: var(--ON);
    --blue: var(--OFF);
}

/* 默认为Light */
.light {
    --light: var(--ON);
    --dark: var(--OFF);
    --blue: var(--OFF);
}

/* 默认为Blue */
.blue {
    --light: var(--OFF);
    --dark: var(--OFF);
    --blue: var(--ON);
}

你将看到效果如下:

在上面的示例上,稍微调整一下,就可以让用户选择自己需要的颜色,然后让导航根据用户的选择切换颜色:

label.dark {
    background-color: var(--dark-bgcolor);
}

label.light {
    background-color: var(--light-bgcolor);
}

label.blue {
    background-color: var(--blue-bgcolor);
}

#dark:checked ~ div .dark ,
#light:checked ~ div .light ,
#blue:checked ~ div .blue {
    box-shadow: 0 0 0 3px #2196f3;
}

.nav {
    color: var(--light, var(--light-color)) var(--dark, var(--dark-color))
        var(--blue, var(--blue-color));
    background-color: var(--light, var(--light-bgcolor))
        var(--dark, var(--dark-bgcolor)) var(--blue, var(--blue-bgcolor));
}

a.active,
a:hover {
    background-color: var(--light, var(--light-active-bgcolor))
        var(--dark, var(--dark-active-bgcolor))
        var(--blue, var(--blue-active-bgcolor));
}

/* 设置切换开关 */
:root {
    --ON: initial;
    --OFF: ;

    /* Dark */
    --dark-color: rgba(156, 163, 175, 1);
    --dark-bgcolor: rgba(17, 24, 39, 1);
    --dark-active-bgcolor: rgba(55, 65, 81, 1);

    /* Light */
    --light-color: rgba(55, 65, 81, 1);
    --light-bgcolor: rgba(243, 244, 246, 1);
    --light-active-bgcolor: rgba(209, 213, 219, 1);

    /* Blue */
    --blue-color: rgba(165, 180, 252, 1);
    --blue-bgcolor: rgba(49, 46, 129, 1);
    --blue-active-bgcolor: rgba(67, 56, 202, 1);
}

#dark:checked ~ .nav {
    --light: var(--OFF);
    --dark: var(--ON);
    --blue: var(--OFF);
}

#light:checked ~ .nav {
    --light: var(--ON);
    --dark: var(--OFF);
    --blue: var(--OFF);
}

#blue:checked ~ .nav {
    --light: var(--OFF);
    --dark: var(--OFF);
    --blue: var(--ON);
}

尝试着选择Demo中的颜色,对应的导航UI就会换肤:

另外,示例中的单选按钮在选中和未选中状态边框上的效果也会有所差异:

可以使用同样的方式来做切换:

label {
    --box-shadow: var(--ON);
    --box-shadow-active: var(--OFF);
    box-shadow: 0 0 0 3px var(--box-shadow, rgba(0, 0, 0, 0.05))
        var(--box-shadow-active, #2196f3);
}

#dark:checked ~ div .dark,
#light:checked ~ div .light,
#blue:checked ~ div .blue {
    --box-shadow: var(--OFF);
    --box-shadow-active: var(--ON);
}

再来看一个示例,使用该方法来实现Switch按钮效果:

:root {
    --ON: initial;
    --OFF: ;
}

.switch {
    --checked-bg-color: #4cd964;
    --checked-color: #fff;
    --unchecked-color: rgba(0, 0, 0, 0.2);
    --unchecked-bg-color: #ff3b30;

    background: var(--checked, var(--checked-bg-color))
        var(--unchecked, var(--unchecked-bg-color));
    color: var(--unchecked-color);
}

#no:checked ~ .switch {
    --checked: var(--OFF);
    --unchecked: var(--ON);
}
#yes:checked ~ .switch {
    --checked: var(--ON);
    --unchecked: var(--OFF);
}

#yes:checked ~ .switch label[for="yes"],
#no:checked ~ .switch label[for="no"] {
    color: var(--checked-color);
}

在介绍CSS自定义属性的时候,多次提到,使用CSS自定义属性可以实现if ... else的功能

事实上,我们使用CSS自定义属性的无效值也可以做if ... else的判断。不同的是,定义--i01之间切换,需要借助CSS的calc()做动态计算,但使用自定义属性值是不需要做动态计算。接下来,我们使用CSS自定义属性的无效值来实现上图这个简单的效果:

:root {
    --ON: initial;
    --OFF: ;
}

.element {
    --rotate-switch: var(--OFF);
    --rotate-deg: 45deg;

    transform: rotate(var(--rotate-switch, var(--rotate-deg)));
}

.element span {
    transform: rotate(var(--rotate-switch, -45deg));
}

.element:hover {
    --rotate-switch: var(--ON);
}

效果如下:

我们可以使用类似的方式实现其他多个值切换,或者在交互行为带来的状态变化等效果。

为什么要使用CSS自定义属性来做切换

众所周知,对于UI的多态切换,我们常用的方式主要有:

  • 借助CSS的伪类选择器,比如:hover:focus等改变相应CSS属性的值
  • 借助CSS的媒体查询,在预期的断点下重新设置CSS样式
  • 借助JavaScript的能力,在不同状态下动态更新CSS或者增删class

就拿文章最开始的示例为例吧,如果按照以前的编码习惯,我们可能会像下面这样编码:

button {
    padding: .6em 1em;
    border: 1px solid;
    border-radius: .2em;
    background: hsl(200 100% 50%);
    color: white;
    font: 600 100%/1 sans-serif;
    cursor: pointer;
}

button:hover {
    border: 1px solid rgb(0 0 0 / .1);
    background: linear-gradient(hsl(0 0% 100% / .3), transparent) hsl(200, 100%, 50%);
    box-shadow: 0 1px hsl(0 0% 100% / .8) inset, 0 .1em .1em -.1em rgb(0 0 0 / .2);
    text-shadow: 0 -1px 1px rgb(0 0 0 / .3)
}

button:active {
    box-shadow: 0 1px .2em black inset;
}

或者像下面这个多个皮肤效果的UI:

会给不同的UI设置不同的类名:

<!-- HTML -->
<nav class="dark">
    <!-- 列表项 -->
</nav>

<nav class="light">
    <!-- 列表项 -->
</nav>

<nav class="blue">
    <!-- 列表项 -->
</nav>

/* CSS */

:root {
    /* Dark */
    --dark-color: rgba(156, 163, 175, 1);
    --dark-bgcolor: rgba(17, 24, 39, 1);
    --dark-active-bgcolor: rgba(55, 65, 81, 1);

    /* Light */
    --light-color: rgba(55, 65, 81, 1);
    --light-bgcolor: rgba(243, 244, 246, 1);
    --light-active-bgcolor: rgba(209, 213, 219, 1);

    /* Blue */
    --blue-color: rgba(165, 180, 252, 1);
    --blue-bgcolor: rgba(49, 46, 129, 1);
    --blue-active-bgcolor: rgba(67, 56, 202, 1);
}

.dark {
    background-color: var(--dark-bgcolor);
    color: var(--dark-color);
}

.dark a:hover,
.dark a.active {
    background-color: var(--dark-active-bgcolor);
}

.light {
    background-color: var(--light-bgcolor);
    color: var(--light-color);
}

.light a:hover,
.light a.active {
    background-color: var(--light-active-bgcolor);
}

.blue {
    background-color: var(--blue-bgcolor);
    color: var(--blue-color);
}

.blue a:hover,
.blue a.active {
    background-color: var(--blue-active-bgcolor);
}

当然也可以通过JavaScript的style.setProperty动态改变所需要的CSS自定义属性的值:

document.querySelector('.nav').style.setProperty('--color', 'rgba(156, 163, 175, 1)')
document.querySelector('.nav').style.setProperty('--bg-color', 'rgba(17, 24, 39, 1)')
document.querySelector('.nav').style.setProperty('--bg-active-color', 'rgba(55, 65, 81, 1)')

但它们都没有的CSS自定义属性中有效地使用无效变量那么方便。还是拿最开始的按钮为例吧:

从上图中代码的对比来看,虽然最终的效果是一致的,使用CSS自定义属性无效变量实现不同状态的UI效果,所要关注的是,在不同状态下自定义属性,比如示例中--is-raised值的切换。对于不同状态的CSS属性值都在同一个地方做了描述:

button {
    border: 1px solid var(--is-raised, rgb(0 0 0 / 0.1));
    background: var(
        --is-raised,
        linear-gradient(hsl(0 0% 100% / 0.3), transparent)
        )
        hsl(200 100% 50%);
    box-shadow: var(
        --is-raised,
        0 1px hsl(0 0% 100% / 0.8) inset,
        0 0.1em 0.1em -0.1em rgb(0 0 0 / 0.2)
    );
    text-shadow: var(--is-raised, 0 -1px 1px rgb(0 0 0 / 0.3));
}

在以前的编码方式中,不管是CSS还是JavaScript中,都无法在同一个CSS属性中使用不同状态下的属性值。

另外,使用CSS自定义属性的无效值时,可以让我们在很多场景之下不依赖任何的JavaScript的代码,就可以轻易的实现多态的UI效果。即使是需要借助JavaScript来处理CSS自定义属性的值,也会变得更为容易,因为他只需要关注CSS自定义属性的值是--ON还是--OFF,因为其他的变更都在CSS中处理了。

还有就是,我们把不同状态下的CSS都放在一起了,代码的管理和维护都会更集中,即使要修改起来,也更方便。再者,就从实现UI的角度来说,如果不需要依赖任何脚本语言就可以完成状态的切换,那么我更倾向于使用纯CSS来完成。

当然,他也有不足之处,那就是在某种程度来说,开发者理解起来会有一度的难度,特别是对于不了解该技术的开发而言。

小结

前面在不同的文章中分别向大家介绍了CSS自定义属性的基础概念使用场景,也和大家一起探讨了如何使用CSS自定义属性和其他的函数做一些动态计算模拟if ... else的条件判断(类似Switch的状态切换),除此之外,@Ana Tudor 给大家介绍了CSS自定义属性如何做逻辑运算。今天这篇文章中@Lea Verou的引导之下,让我重新获取了CSS自定义属性的另一特性,那就是文章中和大家一起探讨的内容。

在深挖CSS自定义如何有效使用无效变量的过程中,发现自己在阅读规范的时候,还是不够仔细,遗漏了一些有意思的东东,比如文章中介绍的,使用无效变量可以在同一个属性中运用多个值,并且可以完全的进行切换。听起来是不是很神奇,我看到这个的时候,我也觉得很神,甚至一脸蒙逼。如果你刚看到这个也有这个感想的话,那你阅读这篇文章是绝对有收获的。