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

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

David Darnes
David Darnes

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

我们如何构建 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 组件的标准。


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

Shadow DOM 样式封装

原生 HTML 元素具有 Shadow DOM 的方式非常相似,网络组件也是如此。Shadow 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;
  /* ... */
}
将某些组件样板代码应用于影子根或主机选择器。

但如果某些使用您的 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>
同样是两个分隔线,但这一次,可以将分隔线的上下文自定义属性添加到版块选择器中,从而为分隔线重新着色。分隔线会继承该元素,从而生成更简洁、更灵活的代码段。

虽然可能有人认为这种方法并非真正的“私密”方法,但我们仍然认为,对于我们曾担心的问题而言,这是一个相当不错的解决方案。如果有机会,我们将在组件中解决这一问题,这样我们的开发团队就可以更好地控制组件的使用,同时仍然从现有的安全措施中受益。

希望您深入了解我们如何将网络组件与 CSS 自定义属性结合使用。请将您的想法告诉我们。如果您决定在自己的工作中使用这些方法,可以在 Twitter (@DavidDarnes) 上与我联系。您也可以在 Twitter 上找到 Nordhealth @NordhealthHQ 以及我团队的其他成员,他们致力于将该设计系统整合在一起并执行本文中提及的功能:@Viljamis@WickyNilliams@eric_habich

主打图片:Dan Cristian Pădure