Web Components食用指北


0x01 概览

这是一篇实践向的Web Components的入门教程,在本教程中,我们将从零开始,逐步使用Web Components技术构建并完善info-card组件。完成后的组件看起来像这样:

info-card是一个展示个人信息的卡片,接受name、avatar、blog-site、dark四个参数(properties和attributes均可),而标签内容会被展示在卡片最下方,右上角的Dark Mode按钮可以切换深色和浅色模式。组件封装在一个js文件内,用户仅需引入该脚本即可使用。

基础知识的方面,我们假设读者已对HTML、JavaScript、CSS有一定的了解。

0x0101 Web Components是什么

Web Components是一套由W3C标准提供,且在目前(2022年12月)已受所有主流浏览器支持的,用于构建可复用的组件的技术,是以下三种规范的统称:

  • Custom Elements / 自定义元素
  • Shadow DOM / 影子DOM
  • Templates and Slots / 模板与插槽

使用Web Components技术创建的组件(或者说自定义元素)与Vue、React等框架中的同名概念类似,可以直接以一对标签的形式在HTML中使用,并能通过事件、Attributes和Properties实现与外界的通信。它们解决的问题也是类似的,即如何将复杂的页面拆分为可复用的组件,以便于开发和维护。但相较于框架提供的组件系统,Web Components最重要的优势便是其“原生”性:它仅依靠浏览器提供的API运行。这为Web Components提供了广泛的应用空间,横跨多种JavaScript前端框架的微前端架构、框架无关的通用前端组件库,许多场景下都有Web Components的用武之地。

常见的JavaScript框架(如VueReact)都提供了对Web Components的良好兼容,WC组件在其中的使用与框架的“原生”组件相比普遍不会有太大差异,事实上,某些框架的组件系统的设计正是参考了Web Components,如果您有这类框架的使用经验,那您一定不会Web Components中的一些概念感到陌生。

0x0103 Web Components不是什么

  • Web Components不是库
    Web Components是一种“原生”的技术,它由浏览器直接提供,而不依赖于第三方库。作为一种前端工程模块化的解决方案,这正是Web Components最大的优势之一。
  • Web Components不是框架
    Web Components不是一种前端框架。如前文所述,Web Components的功能仅限于构建可复用的组件,它并没有提供某种将视图和数据关联起来的途径,这与通常意义上的前端框架的职能有明显的不同。
    如果你想使用Web Components搭建一个Web应用,并且还想使用数据绑定、路由、状态管理等现代JavaScript前端框架通常会提供的功能,那么像Lit这样的框架可能会是一个不错的选择。

0x0104 开发环境

您可以选择任意一种您喜欢的编辑器和浏览器开发Web Components。我们推荐的搭配是最新的VSCode和Chrome。
如果您使用VSCode作为编辑器,那么我们推荐安装es6-string-html插件,它能为模板字符串中的HTML和CSS提供语法高亮。要使用该功能,只需在模板字符串前加上/* html *//* css */注释即可,如下:

const template = /* html */ `
  <!-- SOME HTML CODE -->
`

const style = /* css */ `
  /* SOME CSS CODE */
`
此插件仅能提供语法高亮,自动补全、IntelliSense等功能仍将无法使用。

本文将采用这种在模板字符串中书写HTML和CSS代码的做法,语法高亮功能将有助于排查错误。

0x02 自定义元素

顾名思义,自定义元素是由开发者自定义的新HTML元素,它能为我们的组件提供最基本的封装。

每一种原生的HTML元素都有各自在JavaScript中对应的类(这些类都是HTMLElement的子类),例如div元素对应HTMLDivElement类,我们在JavaScript中获取到的元素对应的DOM节点都是这些类的实例。类似地,要使用自定义元素,我们需要创建一个与新元素对应的JavaScript类,然后将其与新元素的名称关联起来。

0x0201 两种自定义元素

自定义元素可分为两类:

  • 独立自定义元素 / Autonomous Custom Elements
    这种自定义元素的名称、样式、行为等完全由开发者自定义,可以视作一种完全独立的新HTML标签。
  • 自定义内置元素 / Customized Built-in Elements
    这一类自定义元素可视为对已有HTML元素的拓展。它们不具有独立的名称,并将继承已有元素的默认样式和行为,开发者可在此基础上进行自定义。

两种自定义元素在创建和使用上略有差异,在这里举出一些简化过的例子:

// Creation

// Autonomous Custom Elements
customElements.define('my-element', class extends HTMLElement {
  // some code
})

// Customized Built-in Elements
customElements.define('my-element', class extends HTMLDivElement {
  // some code
}, { extends: 'div' })
<!-- Usage -->

<!-- Autonomous Custom Elements, simply 2 tags -->
<my-element></my-element>

<!-- Customized Built-in Elements, 2 tags with an "is" attribute -->
<div is="my-element"></div>

显然,独立自定义元素具有更好的语义性,在形态上也更接近于原生HTML元素。在本文中,我们将主要讨论这种自定义元素。

0x0202 创建一个自定义元素

现在,我们将开始构建我们的info-card组件。
首先,我们需要创建一个继承自HTMLElement的类,然后使用customElements.define将该类与info-card元素关联起来。:

class InfoCard extends HTMLElement {
  constructor() {
    super()
  }
}
customElements是CustomElementRegistry的一个实例,它提供了定义、查询、更新自定义元素的功能。下面的代码用JSDoc的形式给出了用于定义自定义元素的customElements.define接受的参数及其类型。
/**
 * @param {string} name - Name of the custom element.
 * @param {class} constructor - The class of the custom element.
 * @param {object} [options] - Optional, an object containing an extends property, which indicates the name of the built-in element to extend.
 */
customElements.define('info-card', InfoCard)

如此我们便定义了一种全新的HTML元素info-card,它在JavaScript中对应的类为InfoCard

由于InfoCard类不会被再次使用,我们可以直接用匿名类替代之,将以上代码简化为:

customElements.define('info-card', class extends HTMLElement {
  constructor() {
    super()
  }
})

现在,我们就可以在HTML中使用info-card元素了。

<info-card></info-card>

但渲染结果将是一片空白,因为我们还没有为info-card元素添加任何内容。

0x0203 为info-card添加内部结构

InfoCard类中,this指向的是该类的实例,也就是一个info-card元素,我们可以通过this像操作原生HTML元素一样操作info-card元素。在这里,我们在构造函数中修改info-card元素的innerHTML属性,使其在被创建时就拥有一些内部结构。

在这个示例中,我们在元素外部为这些内容添加了一些样式以作美化。当然,你也可以选择在元素里单开一个style块,将样式写在元素内以提高内聚性。

info-card元素加载完毕后,我们便得到这样一个DOM树结构:

...
└── info-card
    └── div.wrapper
        ├── img.avatar
        ├── div.name
        ├── a
        └── span

但您很快会发现上面这种结构和下面的并没有太大不同:

...
└── div
    └── div.wrapper
        ├── img.avatar
        ├── div.name
        ├── a
        └── span

我们写在外部的CSS样式就很好地说明了问题所在:到现在为止,我们的info-card似乎和普通的<div><span>等原生元素还没有太大差别,其内部结构暴露在外,CSS选择器可以直接穿透它选中其内部的元素,这显然不能满足我们对一个可能会在各种场合下被使用的组件的鲁棒性的追求。我们需要隔离组件内外的样式,使得组件本身的样式不会影响到外部元素,反之亦然。

0x03 影子DOM

影子DOM(Shadow DOM)便是我们进一步封装组件所需要的技术。影子DOM是DocumentFragment的一种应用,后者类似Document的“精简版”,可以记录一颗DOM子树(或者说一段独立的HTML结构)。影子DOM内部可以使用<style>元素编写样式,在此编写的样式无法作用于影子DOM外部的元素,id等属性不会与外部冲突,反之亦然;在创建影子DOM时也可以选择使影子DOM的根节点对外部不可见,这样便可实现将组件的内部结构对外部DOM树隐藏。
影子DOM的根节点称为影子根(Shadow Root),是ShadowRoot类的实例,而该类正是DocumentFragment的子类。它可以被挂载在一个元素上,并接管其内容的渲染。影子DOM内的元素在渲染时的行为类似宿主元素的后代元素。
我们可以使用attachShadow方法在HTML元素上挂载一个空的影子DOM:

someElement.attachShadow({ mode: 'open' })

attachShadow方法接受一个配置对象作为参数,并返回被挂载的ShadowRoot实例。我们在这里使用到的mode属性用于控制影子根对影子DOM外部的可见性,其值可以为'open''closed'。它实际上影响的是宿主元素的只读属性(property)shadowRoot,当modeopen时,shadowRoot属性将被设为被挂载的影子根,而当modeclosed时,shadowRoot属性会被设为null
需要注意的是,如果将mode设为closed,则即使在自定义元素类内也无法通过this.shadowRoot访问到影子根,如果您想完全对外隐藏影子根(实际上这样做的意义不大),并且还需要在自定义元素类内部操作它,那么可以考虑在类内使用一个私有属性存储对影子根的引用:

class MyElement extends HTMLElement {
  #shadowRoot // private properties must be declared right in the class body
  constructor() {
    super()
    this.#shadowRoot = this.attachShadow({ mode: 'closed' })
  }
}

mode外,配置对象还可包含delegatesFocusslotAssignment两个属性,分别用于控制焦点代理和插槽分配,具体内容请参考MDN

ShadowRoot类对DocumentFragment做了一些拓展,使得我们可以使用类似Element类同名属性的innerHTML来直接操作其内容,下面,我们将把info-card的内容和样式放入影子DOM中:

此处的样式中使用了:host伪类,它用于选择影子DOM的宿主元素,即info-card本身,这是在影子DOM内使用CSS选择器选择宿主元素的唯一方法。后面的章节中我们会详细介绍影子DOM内部的样式。

此时从外部仅能看到一个没有任何子元素的info-card元素:

...
└── info-card

您可以打开开发者工具查看上方示例中的结构,可以看到info-card的内部结构已经被放入了影子DOM(#shadow-root(open))下,而宿主元素info-card的内容则为空。

您可能会看到一些文章中通过利用template元素构造DocumentFragment,然后再将其内容复制到影子根内的方式为影子DOM填充内容,但两种方法原理基本一致,我们认为仅在JavaScript文件中构建组件时没有如此将HTML内容辗转腾挪的必要。

0x04 通信

目前为止,我们的组件只具有有硬编码在组件内的数据,我们需要一种方式来让组件接收从外部传入的信息。
先让我们回顾一下,我们是如何与原生HTML元素进行通信的:

上面的示例展示了一个点击后将自身禁用的按钮。我们在HTML标签内指定了type属性(Attribute)为button以消除其默认行为,并为按钮添加了一些将会显示在其内部的文本,然后在JavaScript中监听了这个按钮上的click事件,当事件触发(也即按钮被点击)时,我们将按钮的disabled属性(Property)设置为true,从而将其禁用。

示例中,我们用到了以下方法进行通信:

  1. 由外向内的通信:
  • 在HTML中设置元素的Attribute
  • 在HTML中设置元素的内容
  • 在JavaScript中设置元素的Property
  1. 由内向外的通信:
  • 在元素上派发事件,在外部使用JavaScript监听

在本章中,我们将详细讲解如何将这些方法运用到我们的组件中。

0x0401 Attributes与Properties

HTML元素拥有两种“属性”:AttributeProperty。我们可以通过它们来从外部向我们的组件传递数据。

Attributes写在HTML开始标签里,形式为用=连接的键值对,用于描述元素的元数据或改变元素行为:

<markup attribute="value"></markup>

Attributes只能传递字符串,数据类型比较简单时可以优先选用Attributes向元素数据。我们可以在JavaScript中通过getAttributesetAttribute等方法操作Attributes。

HTML经解析后便可通过DOM操作,其中的元素将成为DOM中的节点,而作为JavaScript对象,DOM节点自然会拥有其属性(Properties):

const el = document.querySelector('markup')
el.property = { foo: 'bar' }

DOM节点的Properties与其它JavaScript对象的Properties无异,可以通过.[]等方式访问,其值可以为任何JavaScript类型,当数据类型较为复杂时应当使用Properties向元素传递数据。

监听Attributes和Properties的变化

  1. Attributes

自定义组件提供了监听Attributes变化的机制,这将使用到attributeChangedCallback生命周期函数和observedAttributes静态Getter。

我们将在之后的章节中详细讲解生命周期函数。

observedAttributes返回一个包含要监听的Attributes名称的数组:

static get observedAttributes() {
  return ['name', 'age']
}

attributeChangedCallback会在被监听的Attributes发生变化时被调用,它接收三个参数:Attribute名称,旧值和新值,我们可以使用switch/case处理不同的Attributes变化:

attributeChangedCallback(name, oldValue, newValue) {
  switch (name) {
    case 'name':
      // some code
      break
    case 'age':
      // some code
      break
  }
}

若自定义元素在被加载时已经拥有被监听的Attributes,则此时attributeChangedCallback也会被调用,而未被监听的Attributes此时也可以在构造函数中访问。多数场景下无需对被监听的Attributes进行初始化处理。

  1. Properties

我们可以使用Setter来监听Properties的变化:

set name(value) {
  // some code
}

name被设置时,name的Setter会被调用,我们可以在其中处理数据的变化。

  1. Attributes与Properties的同步

原生HTML元素中,有很多同名Attributes与Properties的值是同步的,例如style,但我们自定义的Attributes和Properties则不会具有这种特性,当数据较为简单时,我们应当同步同名Attributes与Properties的值,以提供与原生元素统一的使用体验。

我们可以将数据存放在Attributes中,然后通过getter和setter来提供通过Properties访问数据的途径:

attributeChangedCallback(name, oldValue, newValue) {
  switch (name) {
    case 'name':
      this.name = newValue
      break
  }
}

get name() {
  return this.getAttribute('name')
}

set name(value) {
  this.setAttribute('name', value)
}

0x0402 插槽

我们可以在影子DOM中使用<slot>元素。自定义组件的子节点(元素和文本)将被分配(assign)至<slot>元素下,以一种类似<slot>元素子节点的形式进行渲染,这提供了一种便捷地在组件中插入自定义HTML内容的方式。

你可以修改上方示例中slot-demo的内容,观察渲染结果的变化。

我们可以使用<slot>元素的name属性(Attribute)来指定插槽的名称,拥有name<slot>元素称为具名插槽(Named Slot),而不具有name<slot>元素称为默认插槽(Default Slot)。自定义元素的子元素中拥有slot属性(Attribute)的将被分配至与具有与其值相同名称的的具名插槽内,我们可以利用这种特性在组件内不同地点插入HTML内容;而文本和不具有slot属性的元素将被分配至默认插槽内。默认插槽只能有一个,而不重名的具名插槽可以有多个,当出现多个默认插槽或具名插槽重名的情况时(实践中应当避免这种情况的发生),匹配的子节点将被分配至首先被渲染的插槽内进行渲染。

<slot>元素也可以拥有自己的内容,这些内容将在插槽未被分配任何子节点时被渲染。

需要注意的是,被分配至插槽内的节点并没有被从外部DOM树中移除,也不会成为插槽元素真正的子元素,而仅仅是换了个地方渲染。

现在,让我们尝试将头像URL、昵称、个人简介信息通过Attributes和插槽传入info-card

示例中,我们将一些Attributes反射为Properties,并在描述组件内部结构的模板字符串中通过${}使用了这些Properties,请注意,${}内的表达式只会在模板字符串被解析时求值一次,其中引用的数据发生变更时组件的渲染结果并不会随之更新,故这种做法仅适用于不会被修改的属性。

0x0403 事件

现在,我们要为info-card添加切换深色/浅色模式的功能,我们希望在切换模式时能让外部得知这一变化,以实现对整个网页的主题切换。

我们可以通过JavaScript的事件系统实现数据由组件内部至外部的传递。影子DOM内的事件冒泡会在影子根处停止,因此如果想让外部监听到组件内状态的变化,我们需要在状态变化发生时自行在组件实例上发布事件。

我们将为info-card添加一个用于切换模式的按钮,点击按钮时将会为wrapper添加wrapper__dark类以切换主题,同时在组件实例上发布dark-mode-change事件,事件detail中的dark属性表示当前是否为深色模式。

关于如何获取影子DOM中的元素:和document类似,我们可以直接在影子根上使用querySelectorgetElementById等方法。

我们在组件外部监听了dark-mode-change事件,当事件被触发时,我们将切换body的背景色。

至此,我们已经完成了一个完整的Web Components组件。

0x05 没有提到但还是很重要的东西

0x0501 影子DOM内的样式

影子DOM提供了一些额外的样式特性,例如:

  • :host伪类,用于匹配组件自身
  • part Attribute和::part()伪元素,用于穿透影子DOM,从外部匹配其中某部分,例如这段外部的CSS:

    custom-element::part(foo) {
    background-color: red;
    }

    可选中影子DOM内的如下元素:

    ...
    <div part="foo"></div>
    ...
  • ::slotted()伪元素,用于匹配分配至插槽内的元素,该选择器前可指定一个插槽,括号内是用于选择插槽内元素的选择器。例如:
slot[name="bar"]::slotted(.foo) {
  background-color: red;
}

可以选中如下元素:

...
<custom-element>
  <div class="foo" slot="bar"></div>
</custom-element>
...

需要注意的是,插槽内的元素可以同时受影子DOM外部和内部的样式影响;被分配至插槽内的文本和插槽内的默认内容则仅能被作用于插槽元素本身的样式影响。

  • :defined伪类,用于匹配已定义的元素,可以搭配:not()在JS未完成加载时将自定义元素隐藏起来:

    :not(:defined) {
    display: none;
    }

0x0502 自定义元素的生命周期

相比于主流框架,自定义元素的生命周期更加简单,只有以下五个方法:

  • constructor():也即构造函数,在组建被创建时被调用,可以在其中进行一些初始化操作。
  • connectedCallback():在组件被插入DOM时被调用。
  • disconnectedCallback():在组件从DOM中移除时被调用。
  • attributeChangedCallback():当被监视的属性被修改时被调用。
  • adoptedCallback():在组件被移动到新的文档时被调用。

0x06 参考

Shadow DOM v1 - Self-Contained Web Components - web.dev
Custom Elements v1 - Reusable Web Components - web.dev
Custom Element Best Practices - web.dev
Web Components - MDN
Web Components 入门实例教程 - 阮一峰的网络日志

本站所有文章均采用CC BY-NC-SA 3.0协议进行授权


『嘆く祈りこそ人の業』