浏览器原生支持的组件化方案?Web Components深度解毒指南

Web Component 深度解析:构建原生可复用组件

Web Components 是一套由浏览器原生支持的 Web API,它允许开发者创建可重用、封装良好的定制 HTML 元素,从而实现组件化的前端开发模式。本文将深入探讨 Web Components 的核心 API 及其使用方式,并通过丰富的代码示例展示如何构建强大的自定义组件。

什么是 Web Component?

Web Components 旨在解决代码复用和组件化管理的问题,它由三项主要技术组成:

  1. ​Custom Elements (自定义元素)​:允许开发者扩展 HTML 元素集合,通过定义新的标签来创建自定义组件
  2. ​Shadow DOM (影子 DOM)​:提供封装样式和结构的能力,使组件内部的 CSS 样式不会影响到外部环境,反之亦然
  3. ​HTML Templates (HTML 模板)​ :使用 <template><slot> 元素定义组件的内容和可替换区域

这些技术可以一起使用来创建封装功能的定制元素,可以在任何地方重用,不必担心代码冲突。

自定义元素 (Custom Elements)

基本概念

自定义元素分为两种类型:

  1. ​独立自定义元素 (Autonomous custom element)​ :继承自 HTML 元素基类 HTMLElement,必须从头开始实现它们的行为
  2. ​自定义内置元素 (Customized built-in element)​ :继承自标准的 HTML 元素,如 HTMLParagraphElementHTMLImageElement,扩展标准元素的行为

创建自定义元素

自定义元素作为一个类来实现,该类可以扩展 HTMLElement(在独立元素的情况下)或者你想要定制的接口(在自定义内置元素的情况下)。

scala 复制代码
// 独立自定义元素的最小实现
class PopUpInfo extends HTMLElement {
  constructor() {
    super();
    // 此处编写元素功能
  }
}

// 自定义内置元素的最小实现,该元素定制了<p>元素
class WordCount extends HTMLParagraphElement {
  constructor() {
    super();
    // 此处编写元素功能
  }
}

注册自定义元素

要使自定义元素在页面中可用,需要调用 CustomElementRegistry.define() 方法:

php 复制代码
// 注册独立自定义元素
customElements.define('popup-info', PopUpInfo);

// 注册自定义内置元素
customElements.define('word-count', WordCount, { extends: 'p' });

使用方式也有所不同:

xml 复制代码
<!-- 使用独立自定义元素 -->
<popup-info></popup-info>

<!-- 使用自定义内置元素 -->
<p is="word-count"></p>

生命周期回调

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

  • connectedCallback():每当元素添加到文档中时调用
  • disconnectedCallback():每当元素从文档中移除时调用
  • adoptedCallback():每当元素被移动到新文档时调用
  • attributeChangedCallback():在属性更改、添加、移除或替换时调用
javascript 复制代码
class MyCustomElement extends HTMLElement {
  static observedAttributes = ["color", "size"];

  constructor() {
    super();
  }

  connectedCallback() {
    console.log("自定义元素添加至页面。");
  }

  disconnectedCallback() {
    console.log("自定义元素从页面中移除。");
  }

  adoptedCallback() {
    console.log("自定义元素移动至新页面。");
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name} 已变更。`);
  }
}

响应属性变化

为了有效地使用属性,元素必须能够响应属性值的变化。为此,自定义元素需要:

  1. 一个名为 observedAttributes 的静态属性,包含需要监听的属性名称数组
  2. 实现 attributeChangedCallback() 生命周期回调
javascript 复制代码
class MyCustomElement extends HTMLElement {
  static observedAttributes = ["size"];

  constructor() {
    super();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`);
    // 根据属性变化更新组件
  }
}

customElements.define('my-custom-element', MyCustomElement);

使用示例:

ini 复制代码
<my-custom-element size="100"></my-custom-element>

Shadow DOM (影子 DOM)

基本概念

Shadow DOM 允许你将一个 DOM 树附加到一个元素上,并且使该树的内部对于在页面中运行的 JavaScript 和 CSS 是隐藏的。关键术语:

  • ​影子宿主 (Shadow host)​:影子 DOM 附加到的常规 DOM 节点
  • ​影子树 (Shadow tree)​:影子 DOM 内部的 DOM 树
  • ​影子边界 (Shadow boundary)​:影子 DOM 终止,常规 DOM 开始的地方
  • ​影子根 (Shadow root)​:影子树的根节点

创建 Shadow DOM

ini 复制代码
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);

attachShadow() 方法接受一个配置对象,其中 mode 属性可以是:

  • open:可以通过 host.shadowRoot 获取影子 DOM
  • closed:无法通过 host.shadowRoot 获取影子 DOM(返回 null)

Shadow DOM 的样式封装

Shadow DOM 的一个重要特性是样式封装,组件内部的样式不会影响外部,外部的样式也不会影响组件内部。

css 复制代码
class PopUpInfo extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    
    // 创建一些 CSS 应用于影子 DOM
    const style = document.createElement('style');
    style.textContent = `
      .wrapper {
        position: relative;
      }
      .info {
        font-size: 0.8rem;
        width: 200px;
        display: inline-block;
        border: 1px solid black;
        padding: 10px;
        background: white;
        border-radius: 10px;
        opacity: 0;
        transition: 0.6s all;
        position: absolute;
        bottom: 20px;
        left: 10px;
        z-index: 3;
      }
      img {
        width: 1.2rem;
      }
      .icon:hover + .info, .icon:focus + .info {
        opacity: 1;
      }
    `;
    
    shadow.appendChild(style);
    // 添加其他元素...
  }
}

HTML 模板 (HTML Templates)

<template> 元素

<template> 元素使你可以编写不在呈现页面中显示的标记模板,然后它们可以作为自定义元素结构的基础被多次重用。

xml 复制代码
<template id="my-template">
  <style>
    /* 组件样式 */
  </style>
  <div class="container">
    <slot></slot> <!-- 这里可以插入其他元素 -->
  </div>
</template>

<slot> 元素

<slot> 元素作为插槽,允许你在使用自定义元素时插入自定义内容。

xml 复制代码
const template = document.createElement('template');
template.innerHTML = `
  <style>
    label { display: block; }
    .description { color: #a9a9a9; font-size: .8em; }
  </style>
  <label>
    <input type="checkbox" />
    <slot></slot>
    <span class="description"><slot name="description"></slot></span>
  </label>
`;

使用示例:

xml 复制代码
<todo-item>
  todo1
  <span slot="description">其他描述</span>
</todo-item>

完整示例:实现一个下拉选择组件

让我们实现一个包含 selectoption 的基础下拉选择组件。

Select 组件

ini 复制代码
class Select extends HTMLElement {
  constructor() {
    super();
    
    const template = document.createElement("template");
    template.innerHTML = `
      <style>
        :host {
          position: relative;
          display: inline-block;
        }
        .select-inner {
          height: 34px;
          border: 1px solid #cdcdcd;
          box-sizing: border-box;
          font-size: 13px;
          outline: none;
          padding: 0 10px;
          border-radius: 4px;
        }
        .drop {
          position: absolute;
          top: 36px;
          left: 0;
          width: 100%;
          padding: 4px 0;
          border-radius: 2px;
          overflow: auto;
          max-height: 256px;
          box-shadow: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04);
          display: none;
        }
      </style>
      <input class="select-inner" readonly>
      <div class="drop">
        <slot></slot>
      </div>
    `;
    
    const shadowEle = this.attachShadow({ mode: "open" });
    const content = template.content.cloneNode(true);
    shadowEle.appendChild(content);
    
    this.input = shadowEle.querySelector(".select-inner");
    this.dropEle = shadowEle.querySelector(".drop");
    this.value = null;
    
    this.input.addEventListener("click", () => {
      this.dropEle.style.display = "block";
    });
    
    this.BodyClick = (ev) => {
      if (ev.target !== this.input) {
        this.dropEle.style.display = "none";
      }
    };
    
    this.dropEle.addEventListener("click", (ev) => {
      const target = ev.target;
      const nodeName = target.nodeName.toLowerCase();
      if (nodeName === "ivy-option") {
        this.value = target.getAttribute("value");
        this.input.setAttribute("value", target.innerHTML);
        this.dispatchEvent(new CustomEvent("change", {
          detail: { value: this.value }
        }));
        this.dropEle.style.display = "none";
      }
    });
  }
  
  connectedCallback() {
    document.addEventListener("click", this.BodyClick, true);
  }
  
  disconnectedCallback() {
    document.removeEventListener("click", this.BodyClick);
  }
}

Option 组件

ini 复制代码
class Option extends HTMLElement {
  constructor() {
    super();
    
    const template = document.createElement("template");
    template.innerHTML = `
      <style>
        :host {
          position: relative;
        }
        .option {
          height: 32px;
          line-height: 32px;
          box-sizing: border-box;
          font-size: 13px;
          color: #333333;
          padding: 0 10px;
          overflow: hidden;
          text-overflow: ellipsis;
          white-space: nowrap;
        }
        .option:hover {
          background-color: #f4f4f4;
        }
      </style>
      <div class="option">
        <slot></slot>
      </div>
    `;
    
    const shadowELe = this.attachShadow({ mode: "open" });
    const content = template.content.cloneNode(true);
    shadowELe.appendChild(content);
  }
  
  static get observedAttributes() {
    return ["value"];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "value" && oldValue !== newValue) {
      // 处理value属性变化
    }
  }
}

注册和使用

makefile 复制代码
customElements.define("ivy-select", Select);
customElements.define("ivy-option", Option);
vbnet 复制代码
<ivy-select>
  <ivy-option value="1">Apple</ivy-option>
  <ivy-option value="2">Banana</ivy-option>
  <ivy-option value="3">Orange</ivy-option>
</ivy-select>

扩展内置元素

Web Components 还允许你扩展内置 HTML 元素的功能。

kotlin 复制代码
class ExpandableList extends HTMLUListElement {
  constructor() {
    super();
    this.style.position = 'relative';
    
    // 创建切换按钮
    this.toggleBtn = document.createElement('button');
    this.toggleBtn.style.position = 'absolute';
    this.toggleBtn.style.border = 'none';
    this.toggleBtn.style.background = 'none';
    this.toggleBtn.style.padding = '0';
    this.toggleBtn.style.top = '0';
    this.toggleBtn.style.left = '5px';
    this.toggleBtn.style.cursor = 'pointer';
    this.toggleBtn.innerText = '>';
    this.appendChild(this.toggleBtn);
    
    // 定义点击事件
    this.toggleBtn.addEventListener('click', () => {
      this.dataset.expanded = !this.isExpanded;
    });
  }
  
  get isExpanded() {
    return this.dataset.expanded !== 'false' && this.dataset.expanded !== null;
  }
  
  static get observedAttributes() {
    return ['data-expanded'];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    this.updateStyles();
  }
  
  updateStyles() {
    const transform = this.isExpanded ? 'rotate(90deg)' : '';
    this.toggleBtn.style.transform = transform;
    
    [...this.children].forEach((child) => {
      if (child !== this.toggleBtn) {
        child.style.display = this.isExpanded ? '' : 'none';
      }
    });
  }
  
  connectedCallback() {
    this.updateStyles();
  }
}

customElements.define('expandable-list', ExpandableList, { extends: 'ul' });

使用示例:

xml 复制代码
<ul is="expandable-list" data-expanded name="myul">
  <li>apple</li>
  <li>banana</li>
</ul>

预告:Polymer 和 Lit 库

虽然 Web Components 提供了强大的原生能力,但在实际开发中,我们可能会使用一些库来简化开发流程。下面简单介绍两个流行的 Web Components 库:

Polymer

Polymer 是一个开源的 JavaScript 库,由 Google 开发,旨在简化 Web 组件的开发过程。它提供了一系列语法糖和工具,使得创建和使用 Web Components 更加便捷。

Polymer 的核心特性包括:

  • 声明式数据绑定(单向绑定使用 [[ ]],双向绑定使用 {{ }}
  • 便捷的属性系统
  • 简化的事件处理
javascript 复制代码
<dom-module id="hello-world">
  <template>
    <style>
      :host { display: block; padding: 10px; }
    </style>
    <h1>Hello, [[name]]!</h1>
  </template>
  <script>
    Polymer({
      is: 'hello-world',
      properties: {
        name: { type: String, value: 'World' }
      }
    });
  </script>
</dom-module>

Lit

Lit 是一个轻量级的库,基于 Polymer 项目发展而来,旨在简化 Web 组件的开发。它提供了更简洁的 API 和更好的性能,是当前 Web Components 生态中的重要组成部分。

Lit 的核心特点:

  • 简单的组件定义方式
  • 高效的渲染
  • 模板字面量支持
javascript 复制代码
import { LitElement, html } from 'lit';

class MyElement extends LitElement {
  static properties = {
    name: { type: String }
  };

  constructor() {
    super();
    this.name = 'World';
  }

  render() {
    return html`<h1>Hello, ${this.name}!</h1>`;
  }
}

customElements.define('my-element', MyElement);

在下一篇文章中,我们将深入探讨 Polymer 和 Lit 这两个库的使用方法和最佳实践,帮助你更高效地开发 Web Components。

成熟组件库:Quarck Desgin

结语

Web Components 为 Web 开发带来了一种强大的组件化方式,让开发者能够更好地组织代码,提升代码复用性和维护性。通过深入学习和实践,你会发现 Web Components 在现代前端项目中的巨大价值。

虽然 Web Components 已经得到了所有现代浏览器的支持,但在实际项目中,你可能还需要考虑一些额外的因素,如浏览器兼容性、性能优化和与现有框架的集成等。Polymer 和 Lit 这样的库可以帮助你解决这些问题,让你更专注于业务逻辑的实现。

希望本文能够帮助你理解 Web Components 的核心概念和 API,并激发你尝试在自己的项目中使用这项强大的技术。

相关推荐
德莱厄斯1 天前
没开玩笑,全框架支持的 dialog 组件,支持响应式
前端·javascript·github
非凡ghost1 天前
Affinity Photo(图像编辑软件) 多语便携版
前端·javascript·后端
非凡ghost1 天前
VideoProc Converter AI(视频转换软件) 多语便携版
前端·javascript·后端
endlesskiller1 天前
3年前我不会实现的,现在靠ai辅助实现了
前端·javascript
用户904706683571 天前
commonjs的本质
前端
Sailing1 天前
5分钟搞定 DeepSeek API 配置:从配置到调用一步到位
前端·openai·ai编程
Pa2sw0rd丶1 天前
如何在 React 中实现键盘快捷键管理器以提升用户体验
前端·react.js
非凡ghost1 天前
ToDoList(开源待办事项列表) 中文绿色版
前端·javascript·后端
j七七1 天前
5分钟搭微信自动回复机器人5分钟搭微信自动回复机器人
运维·服务器·开发语言·前端·python·微信
快起来别睡了1 天前
TypeScript装饰器详解:像搭积木一样给代码加功能
前端·typescript