Nordhealth 如何在 Web 组件中使用自定义属性

在设计系统和组件库中使用自定义属性的优势。

David Darnes
David Darnes

我叫 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);
使用 Lit 编写的 Web 组件。

不过,Web 组件最吸引人的地方在于,它们几乎可以与任何现有的 JavaScript 框架搭配使用,甚至可以完全不使用框架。在网页中引用主 JavaScript 软件包后,使用 Web 组件就非常像使用原生 HTML 元素。唯一能真正表明它不是原生 HTML 元素的迹象是标记内的一致连字符,这是向浏览器表明这是一个 Web 组件的标准。

Shadow DOM 样式封装

与原生 HTML 元素一样,Web Components 也具有 Shadow DOM。shadow DOM 是元素内隐藏的节点树。直观了解这一点的最佳方式是打开 Web 检查器,然后开启“显示 Shadow DOM 树”选项。完成此操作后,尝试在检查器中查看原生输入元素,您现在可以选择打开该输入元素并查看其中的所有元素。您甚至可以尝试使用我们的某个 Web 组件来执行此操作,例如检查我们的自定义输入组件以查看其 Shadow DOM。

在开发者工具中检查的 shadow DOM。
常规文本输入元素和 Nord 输入 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 属性和值的典型行为一致。应用于父元素或元素本身的任何自定义属性都可以用作其他属性的值。我们通过 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 自定义属性。

然后,在我们的组件中引用这些令牌值。在某些情况下,我们会直接将该值应用于 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="T>itl<e"
  >!<-- ... --
/nord>-t<ab-gr>oup

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);
  /* ... */
}
调整了上下文自定义属性的分隔线 Web 组件 CSS,使内部 CSS 依赖于已设置为具有回退的公共自定义属性的私有自定义属性。

以这种方式定义上下文相关的自定义属性意味着,我们仍然可以执行之前的所有操作,例如继承全局令牌值并在整个组件代码中重复使用值;但组件也会顺利继承自身或任何父元素上该属性的新定义。


<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 Components 与 CSS 自定义属性搭配使用的文章很有见地。欢迎与我们分享您的想法。如果您决定在自己的工作中采用上述任何方法,可以在 Twitter 上通过 @DavidDarnes 找到我。您还可以在 Twitter 上关注 Nordhealth @NordhealthHQ,以及我的团队的其他成员,他们一直努力将此设计系统整合在一起,并执行本文中提到的功能:@Viljamis@WickyNilliams@eric_habich

主打图片,作者:Dan Cristian Pădureț