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

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

David Darnes
David Darnes

我是 Nordhealth 的高级前端开发者 Dave。我负责设计和开发设计系统 Nord,包括为我们的组件库构建 Web 组件。我想分享一下我们如何使用 CSS 自定义属性解决了与 Web 组件样式相关的问题,以及在设计系统和组件库中使用自定义属性的一些其他优势。

为了构建我们的 Web 组件,我们使用了 Lit,这是一个提供大量样板代码(例如状态、作用域样式、模板等)的库。Lit 不仅轻量,而且还基于原生 JavaScript API 构建,这意味着我们可以提供一组精简的代码 bundle,充分利用浏览器已有的功能。


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 元素具有 Shadow DOM 一样,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 组件中。

这种样式的封装是组件库的一个优势。这样一来,我们就能更放心地保证,无论应用于父页面的样式如何,当用户使用我们的某个组件时,该组件的外观都会符合我们的预期。为进一步确保安全,我们会将 all: unset; 添加到所有 Web 组件的根(或“主机”)中。


: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="Title">
  <!-- ... -->
</nord-tab-group>

<style>
  nord-tab-group {
    --n-tab-group-padding: var(--n-space-xl);
  }
</style>
在页面上使用标签页组组件,并将内边距自定义属性更新为更大的尺寸。

上例展示了我们的一个 Web 组件,其中的上下文自定义属性通过选择器进行了更改。采用这种方法的结果是,组件可为用户提供足够的样式灵活性,同时仍能控制大多数实际样式。此外,作为组件开发者,我们还可以拦截用户应用的这些样式。如果我们想调整或扩展其中某个属性,无需用户更改任何代码,我们就可以做到。

我们发现,这种方法非常强大,不仅对我们这些设计系统组件的创建者而言如此,对在产品中使用这些组件的开发团队而言也是如此。

进一步使用自定义属性

在撰写本文时,我们实际上并未在文档中披露这些情境自定义属性;不过,我们计划这样做,以便更广泛的开发团队能够了解和利用这些属性。我们的组件在 npm 上打包时会使用清单文件,其中包含有关这些组件的所有信息。然后,在部署文档网站时,我们会将该清单文件作为数据使用,这项操作是使用 Eleventy 及其全局数据功能完成的。我们计划在此清单数据文件中添加这些情境自定义属性。

我们希望改进的另一个方面是这些情境自定义属性如何继承值。例如,目前,如果您想调整两个分隔线组件的颜色,则需要使用选择器专门定位这两个组件,或者使用样式属性直接在元素上应用自定义属性。这似乎没什么问题,但如果开发者可以在容器元素或甚至根级别定义这些样式,会更有帮助。


<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 组件与 CSS 自定义属性搭配使用中获得有用的信息。欢迎与我们分享您的想法。如果您决定在自己的工作中使用上述任一方法,可以在 Twitter 上与我联系:@DavidDarnes。您还可以在 Twitter 上找到 Nordhealth 的 @NordhealthHQ,以及为整合此设计系统并实现本文中提及的功能而付出辛苦努力的团队其他成员:@Viljamis@WickyNilliams@eric_habich

主打图片:Dan Cristian Pădureț