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

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

David Darnes
David Darnes

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

如需构建我们的 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 软件包后,使用网络组件与使用原生 HTML 元素非常相似。唯一能表明它不是原生 HTML 元素的确切迹象是标记中一致的连字符,这是向浏览器表明它是 Web 组件的标准。


// TODO: DevSite - Code sample removed as it used inline event handlers
使用上面在网页上创建的 Web 组件。

Shadow DOM 样式封装

与原生 HTML 元素具有 Shadow DOM 的方式大致相同,网络组件也是如此。阴影 DOM 是元素内的一个隐藏节点树。要直观地了解这一点,最好的方法是打开网络检查器,然后启用“Show Shadow DOM tree”选项。完成后,请尝试在检查器中查看原生输入元素 - 您现在可以选择打开该输入元素并查看其中的所有元素。您甚至可以使用我们的某个 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;
  /* ... */
}
某些组件样板代码应用于影子根或主机选择器。

但是,如果使用您的网络组件的用户有正当理由更改某些样式,该怎么办?或许有一行文本因其上下文而需要更高的对比度,或者需要加粗边框?如果所有样式都无法添加到组件中,您该如何解锁这些样式选项呢?

这正是 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ț