浏览器原生支持的组件化方案?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,并激发你尝试在自己的项目中使用这项强大的技术。

相关推荐
pe7er9 分钟前
HTTPS:本地开发绕不开的设置指南
前端
晨枫阳16 分钟前
前端VUE项目-day1
前端·javascript·vue.js
江山如画,佳人北望23 分钟前
SLAM 前端
前端
患得患失94928 分钟前
【前端】【Iconify图标库】【vben3】createIconifyIcon 实现图标组件的自动封装
前端
颜酱30 分钟前
抽离ant-design后台的公共查询设置
前端·javascript·ant design
用户95251151401551 小时前
js最简单的解密分析
前端
FogLetter1 小时前
深入浅出React-Router-Dom:从前端路由到SPA架构的华丽转身
前端·react.js
绅士玖1 小时前
JavaScript 设计模式之单例模式🚀
前端·javascript·设计模式
Dream耀1 小时前
useReducer:React界的"灭霸手套",一个dispatch搞定所有状态乱局
前端·javascript·react.js
余大侠在劈柴1 小时前
pdf.js 开发指南:在 Web 项目中集成 PDF 预览功能
前端·javascript·学习·pdf