在设计体系和组件库中使用自定义属性的好处。
我是 Dave,Nordhealth 的高级前端开发者。我负责设计体系 Nord 的设计和开发,包括为我们的组件库构建 Web 组件。我想分享一下我们如何通过使用 CSS 自定义属性来解决Web Components样式设置方面的问题,以及在设计体系和组件库中使用自定义属性的其他一些好处。
我们如何构建 Web 组件
为了构建我们的 Web 组件,我们使用了 Lit,这是一个提供大量样板代码(例如状态、作用域样式、模板等)的库。Lit 不仅轻量,而且基于原生 JavaScript API 构建,这意味着我们可以提供精简的代码包,充分利用浏览器已有的功能。
import {html, css, LitElement} from 'lit';
export class SimpleGreeting extends LitElement {
static styles = css`:host { color: blue; font-family: sans-serif; }`;
static properties = {
name: {type: String},
};
constructor() {
super();
this.name = 'there';
}
render() {
return html`Hey
${this.name}, welcome to Web Components!`;
}
}
customElements.define('simple-greeting', SimpleGreeting);
不过,Web 组件最吸引人的地方在于,它们几乎可以与任何现有 JavaScript 框架搭配使用,甚至可以不使用任何框架。在网页中引用主 JavaScript 软件包后,使用 Web 组件的方式与使用原生 HTML 元素非常相似。唯一能表明它不是原生 HTML 元素的迹象是标记中一致的连字符,这是向浏览器表明这是一个 Web 组件的标准。
Shadow DOM 样式封装
与原生 HTML 元素具有 Shadow DOM 的方式大致相同,Web 组件也具有 Shadow DOM。Shadow DOM 是元素内隐藏的节点树。要直观地了解这一点,最好的方法是打开 Web 检查器,然后开启“Show Shadow DOM tree”(显示 Shadow DOM 树)选项。完成此操作后,尝试在检查器中查看原生输入元素,现在您可以选择打开该输入元素并查看其中的所有元素。您甚至可以使用我们的一个 Web 组件来尝试此操作,尝试检查我们的自定义输入组件,以查看其 Shadow DOM。
Shadow DOM 的一个优点(或缺点,取决于您的看法)是样式封装。如果您在 Web 组件中编写 CSS,这些样式不会泄露并影响主页面或其他元素;它们完全包含在组件内。此外,为主页面或父级 Web 组件编写的 CSS 无法泄露到您的 Web 组件中。
这种样式封装是组件库的一大优势。这让我们更有把握,当有人使用我们的某个组件时,无论父页面应用了什么样式,该组件都会按照我们的预期显示。为了进一步确保这一点,我们向所有 Web 组件的根(即“宿主”)添加了 all: unset;。
:host {
all: unset;
display: block;
box-sizing: border-box;
text-align: start;
/* ... */
}
不过,如果使用您的 Web 组件的用户有正当理由更改某些样式,该怎么办?也许某行文本因其上下文而需要更高的对比度,或者边框需要更粗?如果没有任何样式可以进入组件,您该如何解锁这些样式选项?
这时,CSS 自定义属性就派上用场了。
CSS 自定义属性
自定义属性的命名非常贴切,它们是 CSS 属性,您可以完全自行命名并应用所需的任何值。唯一的要求是,您必须在这些变量前面加上两个连字符。声明自定义属性后,您可以使用 var() 函数在 CSS 中使用该值。
:root {
--n-color-accent: rgb(53, 89, 199);
/* ... */
}
.n-color-accent-text {
color: var(--n-color-accent);
}
在继承方面,所有自定义属性都会被继承,这遵循常规 CSS 属性和值的典型行为。应用于父元素或元素本身的任何自定义属性都可以用作其他属性的值。我们通过 CSS 框架将自定义属性应用于根元素,从而大量使用自定义属性来表示设计令牌,这意味着页面上的所有元素都可以使用这些令牌值,无论是 Web 组件、CSS 辅助类,还是想要从我们的令牌列表中提取值的开发者。
这种通过使用 var() 函数继承自定义属性的能力,使我们能够穿透 Web 组件的 Shadow DOM,让开发者在设置组件样式时拥有更精细的控制。
Nord Web 组件中的自定义属性
每当我们为设计体系开发组件时,都会深思熟虑地处理其 CSS,力求编写精简且易于维护的代码。我们拥有的设计令牌在根元素上的主 CSS 框架中定义为自定义属性。
:root {
--n-space-m: 16px;
--n-space-l: 24px;
/* ... */
--n-color-background: rgb(255, 255, 255);
--n-color-border: rgb(216, 222, 228);
/* ... */
}
然后,我们的组件会引用这些令牌值。在某些情况下,我们会直接在 CSS 属性上应用该值,但在其他情况下,我们会实际定义一个新的上下文自定义属性,并将该值应用于该属性。
:host {
--n-tab-group-padding: 0;
--n-tab-list-background: var(--n-color-background);
--n-tab-list-border: inset 0 -1px 0 0 var(--n-color-border);
/* ... */
}
.n-tab-group-list {
box-shadow: var(--n-tab-list-border);
background-color: var(--n-tab-list-background);
gap: var(--n-space-s);
/* ... */
}
我们还会抽象化一些特定于组件但不在令牌中的值,并将其转换为上下文自定义属性。与组件相关的自定义属性为我们带来了两大主要优势。首先,这意味着我们可以更“干练”地编写 CSS,因为该值可以应用于组件内的多个属性。
.n-tab-group-list::before {
/* ... */
padding-inline-start: var(--n-tab-group-padding);
}
.n-tab-group-list::after {
/* ... */
padding-inline-end: var(--n-tab-group-padding);
}
其次,它使组件状态和变体更改变得非常简洁。例如,在设置悬停或活动状态(在本例中为变体)的样式时,只需更改自定义属性即可更新所有这些属性。
:host([padding="l"]) {
--n-tab-group-padding: var(--n-space-l);
}
但最强大的好处是,当我们在组件上定义这些上下文自定义属性时,我们为每个组件创建了一种自定义 CSS API,该组件的用户可以利用该 API。
<nord-tab-group label="Title">
<!-- ... -->
</nord-tab-group>
<style>
nord-tab-group {
--n-tab-group-padding: var(--n-space-xl);
}
</style>
上例展示了我们的一个 Web 组件,其中使用选择器更改了上下文自定义属性。这种方法的最终结果是,组件为用户提供了足够的样式灵活性,同时仍能控制大多数实际样式。此外,作为组件开发者,我们还可以拦截用户应用的这些样式。如果我们要调整或扩展其中一个属性,我们可以在用户无需更改任何代码的情况下进行。
我们发现这种方法非常强大,不仅对我们这些设计体系组件的创建者来说如此,对在产品中使用这些组件的开发团队来说也是如此。
进一步了解自定义属性
在撰写本文时,我们实际上并未在文档中透露这些上下文自定义属性;不过,我们计划这样做,以便我们的更广泛开发团队能够理解并利用这些属性。我们的组件在 npm 上与清单文件打包在一起,其中包含有关这些组件的所有信息。然后,在部署文档网站时,我们会将清单文件用作数据,这是通过 Eleventy 及其全局数据功能完成的。我们计划将这些上下文自定义属性纳入此清单数据文件中。
我们希望改进的另一个方面是这些上下文自定义属性如何继承值。现在,举例来说,如果您想调整两个分隔线组件的颜色,则需要使用选择器专门定位这两个组件,或者使用 style 属性直接在元素上应用自定义属性。这看起来可能没问题,但如果开发者可以在包含元素上甚至在根级别定义这些样式,会更有帮助。
<nord-divider></nord-divider>
<section>
<nord-divider></nord-divider>
<!-- ... -->
</section>
<style>
nord-divider {
--n-divider-color: var(--n-color-status-danger);
}
section {
padding: var(--n-space-s);
background: var(--n-color-surface-raised);
}
section nord-divider {
--n-divider-color: var(--n-color-status-success);
}
</style>
之所以必须直接在组件上设置自定义属性值,是因为我们使用组件宿主选择器在同一元素上定义了这些属性。我们在组件中直接使用的全局设计令牌会直接传递,不受此问题的影响,甚至可以在父元素上被拦截。如何才能兼顾这两方面?
私有和公开自定义属性
私有自定义属性是由 Lea Verou 提出的概念,它是组件本身上的上下文“私有”自定义属性,但设置为具有后备值的“公共”自定义属性。
:host {
--_n-divider-color: var(--n-divider-color, var(--n-color-border));
--_n-divider-size: var(--n-divider-size, 1px);
}
.n-divider {
border-block-start: solid var(--_n-divider-size) var(--_n-divider-color);
/* ... */
}
以这种方式定义上下文自定义属性意味着,我们仍然可以执行之前所做的所有操作,例如继承全局令牌值并在整个组件代码中重复使用值;但组件也会在自身或任何父元素上优雅地继承该属性的新定义。
<nord-divider></nord-divider>
<section>
<nord-divider></nord-divider>
<!-- ... -->
</section>
<style>
nord-divider {
--n-divider-color: var(--n-color-status-danger);
}
section {
padding: var(--n-space-s);
background: var(--n-color-surface-raised);
--n-divider-color: var(--n-color-status-success);
}
</style>
虽然有人可能会说这种方法并非真正“私密”,但我们仍然认为,这是一种相当巧妙的解决方案,可以解决我们担心的问题。有机会时,我们会在组件中解决这个问题,以便我们的开发团队能够更好地控制组件的使用,同时仍能受益于我们现有的防护措施。
希望您觉得这篇关于如何将 Web 组件与 CSS 自定义属性结合使用的文章对您有所帮助。请告诉我们您的想法,如果您决定在自己的工作中使用这些方法,可以在 Twitter 上找到我,我的账号是 @DavidDarnes。您还可以在 Twitter 上找到 Nordhealth (@NordhealthHQ),以及我团队的其他成员,他们为整合此设计体系和实现本文档中提到的功能付出了不懈努力:@Viljamis、@WickyNilliams 和 @eric_habich。