w3ctech

[译]Why I'm Excited About Native CSS Variables

原文: Why I'm Excited About Native CSS Variables 原文作者:Philip Walton | Engineer at Google

几周前 CSS 变量,更准确的说是CSS自定义属性,是运行在 Chrome Canary 版的实验性网络平台的功能特性[1]

当 Chrome 工程师 Addy Osmani 第一次在 Twitter 上发表 release 版本,使 negativity, hostilityskepticism 非常惊讶。最近,也令我感到惊讶,让我对这个特性非常兴奋。

快速浏览这些回复之后,很明显99%的抱怨都针对这两点:

  • 语法“丑陋”、“冗长”。
  • Sass 已经支持变量, 那为什么我还需要关心它?

我承认不喜欢他的语法,但理解它不能随意的被选择是很重要的。很多 CSS 工作组成员详细地讨论了语法,为了兼容 CSS 语法并且避免与未来的语法冲突,他们不得不做出选择

关于 CSS 变量与 Sass 变量的比较,我认为最大的误解是:

原生的 CSS 变量不仅仅是尝试实现 CSS 预处理器的功能。事实上,如果你读了 初步设计讨论,你会发现原生 CSS 变量最大的动机,是让一些 CSS 预处理器不能做的事成为可能。

CSS 预处理器是梦幻般的工具,但是他们的变量是静态的,并且有词法作用域。另一方面讲,本地 CSS 变量是一种完全不同的变量:他们是动态的,他们作用于 DOM 上。事实上,我认为称他们为变量是令人困惑的。他们实际是 CSS 属性,这给了他们一套完全不同的能力以及允许他们解决一些完全不同的问题。

这篇文章我将讨论使用预处理器变量不能做的事而用 CSS 自定义属性可以做的一些事情。我将演示一些启动自定义属性的新的设计模式。最后,我将讨论为什么在未来,我们极有可能结合预处理器变量和自定义属性两者,充分利用好这两个东西。

注意:这篇文章不是对 CSS 自定义属性的介绍说明。如果你从没听说过他们,或对他们如何工作很陌生,我建议自己先熟悉下

预处理器变量的局限性

在继续之前,我想重点说下,我真的很喜欢CSS预处理器,我把他们用在我所有的项目上。预处理器能做一些十分棒的事,即使你知道他们最终只是吐出一些原始的 CSS,有时,他们还是会让你感到神奇。

那就是说,像其他工具,他们有自身的局限性。有时候动态力的表现能让这些局限性变得出人意料,尤其是对初学者。

预处理器变量是静态的

也许令初学者惊讶的最常见的预处理器局限性的例子是,在媒体查询里 Sass 不能定义变量或者使用 @extend。既然这篇文章是关于变量的,我将关注前者:

$gutter: 1em;
@media (min-width: 30em) {
  $gutter: 2em;
}
.Container {
  padding: $gutter;
}

如果你编译以上代码,你会得到这样的:

.Container {
  padding: 1em;
}

正如你所见,媒体查询块完全被抛弃并且变量赋值被忽略。

理论上讲 Sass 可能要做一些额外的变量声明工作,这样做将会很有挑战性,并且需要列举所有的排列,最后你的 CSS 大小会呈指数增长。

因为你不能改变一个基于匹配 @media 规则的变量,你唯一的操作是给每个媒体查询分配唯一的变量,用代码写出每个单独的变化。稍后更多介绍。

预处理器变量不能级联

当你使用变量时,作用域的问题会无可避免的发生。这个变量应该是全局的吗?应该作用在某个文件或模块吗?应该作用在块吗?

由于 CSS 最终将装饰 HTML,将会有另一种约束变量作用域的方式:DOM 元素。一旦处理器没有在浏览器运行或者从未找到标记上,他们不会这样做。

考虑一个网站尝试为 增加一个类 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)是无效的,但你应该能明白它想要完成的是什么。

最后一个声明是打算用 Sass 的 darken 函数使 <button> 元素的 background-color 属性可以继承自父级元素。如果类 info 或者 error 被添加到 alert(或者使用 JavaScript 或 一个用户样式表任意设置的背景颜色),button 元素也能生效。

现在,很明显在 Sass 里这不起作用,因为预处理器不知道 DOM 的结构,但希望它清楚,为什么这种做法可能是有用的。

讲一个很特别的使用场景:出于无障碍访问的原因,在可继承的 DOM 属性上运行颜色函数,这将是非常方便的。例如:确保文本总是可读的以及背景颜色充分对比,用自定义属性和新的CSS 颜色函数会更可行!

预处理器变量不可互操作

这是预处理器的一个相对明显的缺点,但是我提到它是因为我觉得很重要。如果你用 PostCSS 构建了一个网站,你想使用那些只是利用 Sass 来定义样式的第三方组件,那你就错了。

通过不同的工具集或者托管在 CDN 上的第三方样式表来共用预处理器变量是不行的(目前很难)。

本地 CSS 自定义属性可以和任何 CSS 预处理器或者纯 CSS 文件一起很好的工作。反之,则不一定正确。

自定义属性有怎样的不同

你或许会猜测,我上面例举的局限性都不适用于 CSS 自定义属性。但或许比他们不适用更重要的是为什么他们不适用。

CSS 自定义属性就像常规的 CSS 属性,可以用相同的方式操作他们(一个明显的例外是他们不风格化任何东西)。

像常规的 CSS 属性一样, 自定义属性是动态的。在运行时可以修改他们,可以在媒体查询里更新他们或者给 DOM 增加一个新的类。他们可以被赋值到内嵌(一个元素)或者一个正常的 CSS 声明的选择器里。使用所有标准的级联规则或者 JavaScript 可以更新或重写他们。可能最重要的是,他们可继承,那么当他们应用到一个 DOM 元素上,他们会传递给元素的后代们。

为了使他们更简洁,预处理器变量是词法作用域的,编译之后是静态的。自定义属性作用于 DOM,他们是生动的,他们是动态的。

现实生活中的例子

如果你还是不确定什么是自定义属性能做的而预处理器变量不能做的,我来给你举一些例子。

对于他的价值,有很多非常不错的例子我可以展示,为了不让这篇文章过长,我举两个例子。

我挑选这些例子因为他们不是纸上谈兵,他们是我在过去所面对的真实挑战。我能清楚的记得尝试用预处理器让他们工作,但就是不行,现在用自定义属性就行了。

响应式属性与媒体查询

很多网站使用“gap” 或 “gutter” 变量来定义页面中不同部分布局项的默认留白以及默认填充。大多数时候,你希望这个 gutter 的值依据浏览器窗口大小而不同。在大屏幕上你希望项目间间距大些,但是小屏幕上你不能提供太多的间距,所以 gutter 需要小点。

正如我上面提到的, Sass 变量不能在媒体查询里工作,所以你必须为不同情况写代码。

下面的例子定义了变量 $gutterSm、$gutterMd 和 $gutterLg,然后为每种情况声明了独立的规则:

/* 声明3个 gutter 值,一个用于每个断点 */
$gutterSm: 1em;
$gutterMd: 2em;
$gutterLg: 3em;
/* 用 $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;
}
/* 用 $gutterMd 重写中屏样式 */
@media (min-width: 30em) {
  .Container {
    padding: $gutterMd;
  }
  .Grid {
    margin: -$gutterMd 0 0 -$gutterMd;
  }
  .Grid-cell {
    padding: $gutterMd 0 0 $gutterMd;
  }
}
/* 用 $gutterLg 重写大屏样式 */
@media (min-width: 48em) {
  .Container {
    padding: $gutterLg;
  }
  .Grid {
    margin: -$gutterLg 0 0 -$gutterLg;
  }
  .Grid-cell {
    padding: $gutterLg 0 0 $gutterLg;
  }
}

为了使用自定义属性来完成相同的事,你只需要定义样式一次。你可以用一个 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);
}

即使自定义属性语法过于累赘,完成相同的事情需要的代码量实际上是减少了的。并且这只考虑到了3种变化。变化越多,就越节省代码。

下面的演示使用了上面的代码来构建一个基本的网站布局,当视口变化会自动重定义 gutter 的值。在支持自定义属性的浏览器中看看他的实际效果。

在 CodePen 中查看演示: 编辑器查看 / 全屏

上下文样式

上下文样式(DOM 元素根据它出现在某个 DOM 上来定义他的样式)在 CSS 中是一个有争议的主题。一方面是,那些备受推崇的 CSS 开发者对其很警惕。但另一方面,很多人每天都在使用它。

Harry Roberts 最近写了一篇文章,是关于他对这个问题的看法。

If you need to change the cosmetics of a UI component based on where it is placed, your design system is failing…Things should be designed to be ignorant; things should be designed so that we always just have “this component” and not “this component when inside…”

如果你需要根据一个 UI 组件放置的地方来修改他,那你的设计系统是失败的……组件应该设计成无感知的;组件应该设计成,我们总是只有“这个组件”,而不是“这个组件在……内部”

我赞成 Harry 的观点,我认为这些场景中很多人走捷径的现象可能会反映出很大的问题:css 的表现力受限,并且很多人不满足于当前的“最佳实践”。

下面的例子展示了 CSS 中大多数人如何使用上下文样式的,用后代选择器:

/* Regular button styles. */
.Button { }
/* Button styles that are different when inside the header. */
.Header .Button { }

这种方法有很多问题(我已经在我的文章 CSS Architecture 中解释了)。一种方式可以将这种模式识别为 code smell,他违反了软件开发的 open/closed 原则;它修改了一个封闭组件的实现细节。

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

软件实体应该开放扩展,不允许修改。

自定义属性以一种有趣的方式修改了定义一个组件的规范。使用自定义属性,首先,我们可以写真正对扩展开放的组件。这有个例子:

.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,那种方式不同于按钮组件定义它自己。这种声明方式是独断的(借用 Harry 的话),万一有一个例外,例如 header 里的 button 不需要这样展现,就很难撤销。

另一方面讲,使用自定义属性,按钮组件总是对他的上下文无知,并且完全与 header 组件解耦。他的声明只是说: 我要用这些自定义属性装饰我自己,无论我目前的状况如何。 header 组件简单地说:我正打算设置这些属性值;取决于我的后代决定是否以及如何使用他们

主要的不同在于扩展是被 button 组件选入的,在特定的情况下也很容易撤销。

下面的演示说明了上下文样式同时作用在网页 header 以及内容区域的链接和按钮上。

在 CodePen 上查看演示: editor view / full page

制定特定样式

为了进一步说明使用这种模式更容易制定特定样式,试想如果一个 .Promo 组件被添加到了 header,并且在 .Promo 组件里面的按钮需要看起来像正常的按钮而不是 header 按钮。

如果你是用后代选择器,你必须要为 header 按钮写一堆样式,然后为 promo 按钮撤销这些样式;这很麻烦并且容易出错,当组合数增加,很容易失控。

/* 常规的按钮样式 */
.Button { }
/* 按钮在 header 里面时样式不同 */
.Header .Button { }
/* 撤销 header 里和 promo 重复的按钮样式 */
.Header .Promo .Button { }

使用自定义属性,你可以简单的更新按钮的属性,重置他们来返回一个默认的样式。并且不用管一大堆的特定样式,修改样式也是同样的方式。

.Promo {
  --Button-backgroundColor: initial;
  --Button-borderColor: initial;
  --Button-color: initial;
}

向 React 学习

当我第一次通过自定义属性来探索上下文样式的想法时,我的内心是怀疑的。正如我所说,我的观点更倾向于上下文无关的组件,定义自己的变量而不是接受从父级继承来的任意数据。

但比较 CSS 的自定义属性和 React 的 props时,动摇了我的观点。

React props 也是动态的,DOM 作用域变量,可继承的,它允许组件上下文相关。在 React 里,父级组件传递数据给子组件,然后子组件决定props接收的数据以及如何使用数据。这个架构模型俗称单向数据流。

即使自定义属性是一个新的、未验证的领域,我认为 React 模式的成功给了我信心,我相信一个复杂的系统可以构建在属性可继承之上,此外,DOM 作用域变量是一个很有用的设计模式。

最大限度减少副作用

所有CSS 自定义属性都默认继承。有些情况,这将导致给组件的样式不是他们预期的。

正如我前面部分展示的,你可以通过重置个别的属性来阻止继承,阻止未知值被应用到子元素上。

.MyComponent {
  --propertyName: initial;
}

虽然还不属于规范,-- 属性已经开始研究了,[2]可以用来重置所有的自定义属性。如果你想将一些属性添加到白名单,你可以将它们逐一设置为 inherit,这可以让他们继续正常的工作:

.MyComponent {
  /* Resets all custom properties. */
  --: initial;
  /* Whitelists these individual custom properties */
  --someProperty: inherit;
  --someOtherProperty: inherit;
}

管理全局名称

如果你已经注意到了我是如何对自定义属性命名的,你可能观察到我 在组件特定的属性前用组件自己的类名作为前缀,例如: --Button-backgroundColor。

像大多数 CSS 的名称,自定义属性是全局的,所以总是有可能和你的团队的其他开发者的命名发生冲突。

一个简单的方法来避免这个问题就是坚持命名公约,像我这里做的。

对于更多复杂的项目,你可能要多些考虑,比如有全局名称的CSS 模块和对日渐受欢迎的自定义属性的支持度

总结

在读此文章之前如果你还不熟悉 CSS 自定义属性,我希望我说服了你给他们一个机会。如果你是一个怀疑他们必要性的人,我希望我能够改变你的想法。

自定义属性给 CSS 赋予了一套动态的、强大的能力,我确信很多他们的强大优势还有待发掘。

自定义属性填充了一个预处理器变量根本无法填充的空白。尽管如此,预处理器变量依然容易使用,在很多情形下是更优雅的选择。因为这个,我牢牢的相信,未来很多网站会将两者结合起来使用。自定义属性用于动态主题,预处理器变量用于静态模板。

我不认为这是一个非此即彼的情况。对每个人来说,让他们作为对手相互对立起来是帮倒忙。

尤其感谢 Addy OsmaniMatt Gaunt 校验这篇文字,以及 Shane Stephens 优先修复了一个 Chrome bug 使演示可以工作。

脚注:

  1. 你可以在 Chrome 里浏览地址 about:flags 来启动开发中的实验性网络平台功能,查找 "Experimental Web Platform Features", 然后点击 "enable" 按钮。

  2. 使用 -- 属性是 Tab Atkins 在 Githib 评论里提到的。此外,在一个帖子的在WWW式邮件列表里,Tab 建议增加 --,这个规格应该很快会产生。

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复