Web Components

Web Components 是一种 Web 技术,它允许你创建可复用的自定义元素(Custom Elements)和封装功能强大的组件。Web Components 由三个主要技术组成:

  • Custom Elements(自定义元素):一组 JavaScript API,允许你定义 custom elements 和它们的行为。
  • Shadow DOM(影子 DOM):一组 JavaScript API,用于将封装的 Shadow DOM 树附加到元素(与主文档 DOM 分开)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
  • HTML Templates(HTML 模板):通过 <template><slot> 元素,使你可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

实现 web component 的基本方法如下:

  1. 创建一个类或函数来指定 web 组件的功能。
  2. 使用 CustomElementRegistry.define() 方法注册你的新自定义元素,并向其传递要定义的元素名称、指定元素功能的类、以及可选的其所继承自的元素。
  3. 如果需要的话,使用 Element.attachShadow() 方法将一个 shadow DOM 附加到自定义元素上。使用通常的 DOM 方法向 shadow DOM 中添加子元素、事件监听器等等。
  4. 如果需要的话,使用 <template><slot> 定义一个 HTML 模板。再次使用常规 DOM 方法克隆模板并将其附加到你的 shadow DOM 中。
  5. 在页面任何你喜欢的位置使用自定义元素,就像使用常规 HTML 元素那样。

下面是一个简单的示例:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Component</title>
 
    <script>
      class MyComponent extends HTMLElement {
        constructor() {
          super();
          this.attachShadow({ mode: 'open' });
          this.shadowRoot.innerHTML = `
            <style>
              h1 {
                color: red;
              }
            </style>
            <h1>Hello World</h1>
          `;
        }
      }
 
      customElements.define('my-component', MyComponent);
    </script>
  </head>
  <body>
    <my-component></my-component>
  </body>
</html>

把这段代码保存成一个 html 文件,浏览器打开就能看到效果。代码看不懂没关系,接下来我们将详细介绍如何创建并使用 Web Components。

Custom Elements(自定义元素)

我们上面提到了 Web Components 的三个主要技术,第一个就是能够创建自定义元素,这些元素扩展了浏览器中可用的元素集。

自定义元素有两种类型:

  • Customized built-in elements:自定义内置元素,继承自内置 HTML 元素(比如 HTMLParagraphElement)的自定义元素。它们继承了内置元素的行为,但可以添加额外的功能。
  • Autonomous custom elements:独立自定义元素,继承自 HTMLElement。

创建自定义元素

对于这两种类型的自定义元素,创建方式是一样的,只是继承的类不同。

如果是自定义内置元素,需要继承自内置元素的类,比如继承自 HTMLParagraphElement 的自定义元素:

class MyParagraph extends HTMLParagraphElement {
  constructor() {
    super();
    // Your code here
  }
}

如果是独立自定义元素,需要继承自 HTMLElement

class MyElement extends HTMLElement {
  constructor() {
    super();
    // Your code here
  }
}

注册自定义元素

创建之后我们需要使用 CustomElementRegistry.define() 方法注册自定义元素,之后才能在页面中使用。

define 方法接受三个参数:

  • name:自定义元素的名称,必须包含一个连字符(-)。
  • constructor:自定义元素的构造函数。
  • options:仅对于自定义内置元素,这是一个包含单个属性 extends 的对象,该属性是一个字符串,命名了要扩展的内置元素。

下面分别是注册自定义内置元素和独立自定义元素的示例:

// 注册自定义内置元素
customElements.define('my-paragraph', MyParagraph, { extends: 'p' });
 
// 注册独立自定义元素
customElements.define('my-element', MyElement);

使用自定义元素

定义和注册自定义元素之后,我们就可以在页面中使用它了:

要使用自定义内置元素,只需在 HTML 中使用扩展的内置元素名称,将自定义名称作为 is 属性的值:

<p is="my-paragraph">Hello, world!</p>

要使用独立自定义元素,只需在 HTML 中使用自定义元素名称,就像使用常规 HTML 元素一样:

<my-element></my-element>

生命周期回调

一旦你的自定义元素被注册,当页面中的代码以特定方式与你的自定义元素交互时,浏览器将调用你的类的某些方法。通过提供这些方法的实现,规范称之为生命周期回调,你可以运行代码来响应这些事件。

自定义元素生命周期回调包括:

  • connectedCallback():每当元素添加到文档中时调用。规范建议开发人员尽可能在此回调中实现自定义元素的设定,而不是在构造函数中实现。
  • disconnectedCallback():每当元素从文档中移除时调用。
  • adoptedCallback():每当元素被移动到新文档中时调用。
  • attributeChangedCallback():在属性更改、添加、移除或替换时调用。

下面是一个简单的示例:

// 为这个元素创建类
class MyCustomElement extends HTMLElement {
  static observedAttributes = ['color', 'size'];
 
  constructor() {
    // 必须首先调用 super 方法
    super();
  }
 
  connectedCallback() {
    console.log('自定义元素添加至页面。');
  }
 
  disconnectedCallback() {
    console.log('自定义元素从页面中移除。');
  }
 
  adoptedCallback() {
    console.log('自定义元素移动至新页面。');
  }
 
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name} 已变更。`);
  }
}
 
customElements.define('my-custom-element', MyCustomElement);

响应属性变化

与内置元素一样,自定义元素可以使用 HTML 属性来配置元素的行为。为了有效地使用属性,元素必须能够响应属性值的变化。为此,自定义元素需要将以下成员添加到实现自定义元素的类中:

  • observedAttributes:一个静态属性,它是一个字符串数组,包含你想要监听的属性名称。

  • attributeChangedCallback():当元素的一个被监视的属性发生变化时,浏览器将调用此方法。

    attributeChangedCallback() 回调在列在元素的 observedAttributes 属性中的属性被添加、修改、移除或替换时调用。它接收三个参数:

    • name:属性的名称。
    • oldValue:属性的旧值。
    • newValue:属性的新值。

下面是一个简单的示例:

class MyCustomElement extends HTMLElement {
  static observedAttributes = ['size'];
 
  constructor() {
    super();
  }
 
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`);
  }
}
 
customElements.define('my-custom-element', MyCustomElement);

完整示例

所以一个完整的自定义元素示例可能是这样的:

独立自定义元素

class MyCustomElement extends HTMLElement {
  static observedAttributes = ['size'];
 
  constructor() {
    super();
    // 创建 Shadow DOM
    this.attachShadow({ mode: 'open' });
  }
 
  connectedCallback() {
    console.log('connectedCallback: MyCustomElement added to the page.');
    this.render();
  }
 
  disconnectedCallback() {
    console.log('disconnectedCallback: MyCustomElement removed from the page.');
  }
 
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`);
    this.render();
  }
 
  render() {
    const size = this.getAttribute('size') || 'medium';
    this.shadowRoot.innerHTML = `
      <style>
        p {
          font-size: ${this.getFontSize(size)};
          color: blue;
        }
      </style>
      <p>Hello, I am a custom element with size ${size}!</p>
    `;
  }
 
  getFontSize(size) {
    switch (size) {
      case 'small':
        return '12px';
      case 'large':
        return '24px';
      default:
        return '16px';
    }
  }
}
 
customElements.define('my-custom-element', MyCustomElement);

在 HTML 中使用这个自定义元素:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Custom Element Example</title>
  </head>
  <body>
    <my-custom-element size="small"></my-custom-element>
 
    <script>
      // 修改属性以触发 attributeChangedCallback
      const customElement = document.querySelector('my-custom-element');
      setTimeout(() => {
        customElement.setAttribute('size', 'large');
      }, 3000);
 
      // 移除元素以触发 disconnectedCallback
      setTimeout(() => {
        customElement.remove();
      }, 6000);
    </script>
  </body>
</html>

自定义内置元素

class MyCustomParagraph extends HTMLParagraphElement {
  static observedAttributes = ['size'];
 
  constructor() {
    super();
    // 创建 Shadow DOM
    this.attachShadow({ mode: 'open' });
  }
 
  connectedCallback() {
    console.log('connectedCallback: MyCustomParagraph added to the page.');
    this.render();
  }
 
  disconnectedCallback() {
    console.log(
      'disconnectedCallback: MyCustomParagraph removed from the page.'
    );
  }
 
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`);
    this.render();
  }
 
  render() {
    const size = this.getAttribute('size') || 'medium';
    this.shadowRoot.innerHTML = `
      <style>
        p {
          font-size: ${this.getFontSize(size)};
          color: blue;
        }
      </style>
      <p>Hello, I am a custom paragraph with size ${size}!</p>
    `;
  }
 
  getFontSize(size) {
    switch (size) {
      case 'small':
        return '12px';
      case 'large':
        return '24px';
      default:
        return '16px';
    }
  }
}
 
customElements.define('my-custom-paragraph', MyCustomParagraph, {
  extends: 'p',
});

在 HTML 中使用这个自定义元素:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Custom Element Example</title>
  </head>
  <body>
    <p is="my-custom-paragraph" size="small"></p>
 
    <script>
      // 修改属性以触发 attributeChangedCallback
      const customParagraph = document.querySelector(
        'p[is="my-custom-paragraph"]'
      );
      setTimeout(() => {
        customParagraph.setAttribute('size', 'large');
      }, 3000);
 
      // 移除元素以触发 disconnectedCallback
      setTimeout(() => {
        customParagraph.remove();
      }, 6000);
    </script>
  </body>
</html>

Shadow DOM

Shadow DOM 是 Web Components 的一个重要特性,它允许你将封装的 Shadow DOM 树附加到元素(与主文档 DOM 分开)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。

上面示例的构造函数中已经展示了如何创建 Shadow DOM:

this.attachShadow({ mode: 'open' });

attachShadow() 方法接受一个配置对象,其中 mode 属性可以是 openclosedopen 意味着外部代码可以访问 Shadow DOM,而 closed 意味着外部代码无法访问 Shadow DOM。

Shadow DOM 并不是 Web Components 的专属功能,下面是一个简单的示例:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Shadow DOM Example</title>
  </head>
  <body>
    <div id="container"></div>
 
    <script>
      const container = document.getElementById('container');
      const shadowRoot = container.attachShadow({ mode: 'open' });
      shadowRoot.innerHTML = `
        <style>
          p {
            color: red;
          }
        </style>
        <p>Hello, I am a paragraph in Shadow DOM!</p>
      `;
    </script>
  </body>
</html>

使用 JavaScript 操作 Shadow DOM

默认情况下,Shadow DOM 是封闭的,这意味着它是私有的,外部代码无法访问。比如我们有如下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Shadow DOM Example</title>
  </head>
  <body>
    <div id="host"></div>
    <span>I'm not in the shadow DOM</span>
    <br />
 
    <button id="upper" type="button">将 span 元素转换为大写</button>
 
    <script>
      const host = document.querySelector('#host');
      const shadow = host.attachShadow({ mode: 'open' });
      const span = document.createElement('span');
      span.textContent = "I'm in the shadow DOM";
      shadow.appendChild(span);
 
      const upper = document.querySelector('button#upper');
      upper.addEventListener('click', () => {
        const spans = Array.from(document.querySelectorAll('span'));
        for (const span of spans) {
          span.textContent = span.textContent.toUpperCase();
        }
      });
    </script>
  </body>
</html>

点击按钮后,只有页面中的 span 元素被转换为大写,而 Shadow DOM 中的 span 元素没有被转换。

要访问 Shadow DOM 中的元素,首先把 attachShadow() 方法的 mode 参数设置成 open, 接下来可以使用 shadowRoot 属性访问 Shadow DOM:

const spans = Array.from(host.shadowRoot.querySelectorAll('span'));

这样就可以把 Shadow DOM 中的 span 元素转换为大写了。

CSS 封装

页面的 CSS 不会影响 Shadow DOM 内的节点:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Shadow DOM Example</title>
    <style>
      span {
        color: blue;
        border: 1px solid black;
      }
    </style>
  </head>
  <body>
    <div id="host"></div>
    <span>I'm not in the shadow DOM</span>
 
    <script>
      const host = document.querySelector('#host');
      const shadow = host.attachShadow({ mode: 'open' });
      const span = document.createElement('span');
      span.textContent = "I'm in the shadow DOM";
      shadow.appendChild(span);
    </script>
  </body>
</html>

在这种情况下,页面中的 span 元素是蓝色的,有黑色边框,而 Shadow DOM 中的 span 元素是默认样式。

要在 Shadow DOM 中使用 CSS,有两种方法:

  1. 编程式:通过构建一个 CSSStyleSheet 对象并将其附加到影子根。
  2. 声明式:通过在一个 <template> 元素的声明中添加一个 <style> 元素。

在这两种情况下,Shadow DOM 树中定义的样式局限在该树内,所以就像页面样式不会影响 Shadow DOM 中的元素一样,Shadow DOM 样式也不会影响页面中其它元素的样式。

要通过构建 CSSStyleSheet 来为 Shadow DOM 添加样式,我们可以:

  1. 创建一个 CSSStyleSheet 对象。
  2. 使用 CSSStyleSheet.replace 或者 CSSStyleSheet.replaceSync 方法添加样式规则。
  3. 使用 shadowRoot.adoptedStyleSheets 属性将样式表附加到 Shadow DOM。

下面是一个示例:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Shadow DOM Example</title>
  </head>
  <body>
    <div id="host"></div>
    <span>I'm not in the shadow DOM</span>
 
    <script>
      const sheet = new CSSStyleSheet();
      sheet.replaceSync('span { color: red; border: 2px dotted black;}');
 
      const host = document.querySelector('#host');
 
      const shadow = host.attachShadow({ mode: 'open' });
      shadow.adoptedStyleSheets = [sheet];
 
      const span = document.createElement('span');
      span.textContent = "I'm in the shadow DOM";
      shadow.appendChild(span);
    </script>
  </body>
</html>

构建 CSSStyleSheet 对象的一个替代方法是将一个 <style> 元素包含在用于定义 web 组件的 <template> 元素中。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Shadow DOM Example</title>
  </head>
  <body>
    <!-- 定义模板 -->
    <template id="my-element">
      <style>
        span {
          color: red;
          border: 2px dotted black;
        }
      </style>
      <span>I'm in the shadow DOM</span>
    </template>
 
    <!-- 宿主元素 -->
    <div id="host"></div>
    <span>I'm not in the shadow DOM</span>
 
    <script>
      // 获取宿主元素
      const host = document.querySelector('#host');
 
      // 创建 Shadow DOM
      const shadow = host.attachShadow({ mode: 'open' });
 
      // 获取模板内容并克隆
      const template = document.getElementById('my-element');
      const content = template.content.cloneNode(true);
 
      // 将模板内容附加到 Shadow DOM
      shadow.appendChild(content);
    </script>
  </body>
</html>

使用哪种方式取决于你的应用程序和个人喜好。

创建一个 CSSStyleSheet 并通过 adoptedStyleSheets 将其赋给影子根允许你创建单一样式表并将其与多个 DOM 树共享。例如,一个组件库可以创建单个样式表,然后将其与该库的所有自定义元素共享。浏览器将仅解析该样式表。此外,你可以对样式表进行动态更改,并将更改传播到使用表的所有组件。

而当你希望是声明式的、需要较少的样式并且不需要在不同组件之间共享样式的时候,附加 <style> 元素的方法则非常适合。

模板和插槽

接下来我们介绍 HTML templateslot,它们是 Web Components 的另一个重要特性。

当需要在网页中多次重用相同的 HTML 结构时,可以使用 template。模板是一个 HTML 元素,它包含了不会被呈现的 HTML 内容。模板通常用于存储可重复使用的 HTML 片段,这些片段可以通过 JavaScript 复制和插入到文档中。

让我们看一个简单的示例:

<template id="my-paragraph">
  <p>我的段落</p>
</template>

要将它显示在页面中,需要使用 JavaScript 获取对它的引用,然后将其内容插入到 DOM 中:

let template = document.getElementById('my-paragraph');
let templateContent = template.content;
document.body.appendChild(templateContent);

模板本身就很有用,但与 web component 一起使用效果更好。

customElements.define(
  'my-paragraph',
  class extends HTMLElement {
    constructor() {
      super();
      let template = document.getElementById('my-paragraph');
      let templateContent = template.content;
 
      const shadowRoot = this.attachShadow({ mode: 'open' });
      shadowRoot.appendChild(templateContent.cloneNode(true));
    }
  }
);

这里要注意的关键点是,我们将模版内容的克隆添加到通过 Node.cloneNode() 方法创建的影子根上。

由于我们添加了模板的内容到影子 DOM,我们可以在模板的 <style> 元素中包含一些样式信息,然后将其封装在自定义元素中。

比如:

<template id="my-paragraph">
  <style>
    p {
      color: white;
      background-color: #666;
      padding: 5px;
    }
  </style>
  <p>我的段落</p>
</template>

除了模板,我们还可以使用插槽(slot)来插入内容,让你的组件更灵活。插槽是一种占位符,允许你将内容插入到自定义元素中。

如果想添加一个插槽,可以在模板中添加一个 <slot> 元素:

<p><slot name="my-text">我的默认文本</slot></p>

如果没有插入内容,那么默认文本将被显示。如果要插入内容,在使用自定义元素时,在 <slot> 元素中添加内容:

<my-paragraph>
  <span slot="my-text">这是插槽中的文本</span>
</my-paragraph>

总结

在 JavaScript 框架的地位和能力不断提高的时代,Web Components 一直在努力获得认可和采用。现在大部分前端开发者都使用 React、Vue 或 Angular,Web Components 看起来可能很复杂且笨重,尤其是缺少数据绑定和状态管理等功能。

但 Web Components 作为一种标准化的 Web 技术,近年来也得到了显著的发展和广泛的支持。许多前端框架和库已经开始支持或集成 Web Components,使得它们更容易使用。

虽然有一些问题需要解决,但 Web Components 的未来仍然是值得期待的。