Web Component

Web Component

arduino 复制代码
在了解 Shadow DOM , Custom element, HTML template 这些内容之前,先回想一下vue 和 react 中组件编写方式, template, jsx, 插槽,属性绑定, 父子组件通讯, 监听属性改变, 实时更新等...

一开始以为vue和react框架作者牛逼到可以凭空创建编程新范式,在了解了web component后才发现, 他们也是站在别人肩膀上进行框架设计的。

前提: 如何创建真正独立、可复用、且不与外部环境冲突的 UI 组件 现代响应式框架早就实现了这个需求, 如果是用原生三件套呢? 其实也是可以的, 基于这个特点,甚至可以封装跨框架,跨端的组件库,对个人项目有很大帮助。

Shadow DOM , Custom element, HTML template

这三个概念是 Web Components 的基石, 组合使用可以解决上面的问题

HTML Template

一个可以被复用且惰性的html片段(模版) 页面加载时不会渲染, 只能手动克隆模版添加到指定dom 这使得声明式的、结构化的 HTML 片段可以被重复使用,而无需在 JavaScript 中用字符串拼接的方式来创建 DOM,代码更清晰、更易于维护。

html 复制代码
    <!-- 这个模板在页面上是不可见的 -->
    <template id="my-template">
      <style>
        p { color: blue; }
      </style>
      <p>你好,我是一个来自模板的段落!</p>
      <button>点击我</button>
    </template>
    <div id="container"></div>
    <script>
      // 1. 获取模板
      const template = document.getElementById('my-template');
      
      // 2. 克隆模板内容 (true 表示深度克隆,包括所有子节点)
      const clone = template.content.cloneNode(true);
      
      // 3. 将克隆的内容添加到页面中
      document.getElementById('container').appendChild(clone);
    </script>
    

Custom Elements

自定义元素, web components 的核心 定义一个类,继承自 HTMLElement,然后通过 customElements.define() 方法将它注册为一个新的 HTML 标签。 特点: 语义化更强,代码更具可读性

html 复制代码
 <script>
      // 1. 创建一个类,定义组件的逻辑
      class MyGreeting extends HTMLElement {
        // 当元素被插入到 DOM 时,这个方法会被调用
        connectedCallback() {
          // 获取属性 'name'
          const name = this.getAttribute('name') || '朋友';
          // 渲染元素内容
          this.innerHTML = `<p>你好, ${name}!</p>`;
        }
        
        // 可以定义属性变化时的响应
        static get observedAttributes() { return ['name']; }
        attributeChangedCallback(name, oldValue, newValue) {
          if (name === 'name') {
            this.innerHTML = `<p>你好, ${newValue}!</p>`;
          }
        }
      }

      // 2. 注册自定义元素,告诉浏览器 <my-greeting> 标签对应 MyGreeting 类
      customElements.define('my-greeting', MyGreeting);
    </script>

Shadow DOM

为组件提供一个"隔离的 DOM 空间", 相当于沙箱。 是实现组件"封装性"和"样式隔离"的关键。 解决了什么问题? 样式污染, DOM 冲突, 结构脆弱 element.attachShadow({ mode: 'open' }) 可以创建一个 Shadow DOM 实例

html 复制代码
    <style>
      /* 外部样式,试图影响所有 p 标签 */
      p { color: red; font-size: 20px; }
    </style>

    <p>我是一个普通的段落,我会被外部样式影响,变成红色。</p>

    <div id="host"></div>

    <script>
      class ShadowComponent extends HTMLElement {
        constructor() {
          super();
          // 1. 附加一个 Shadow DOM
          const shadow = this.attachShadow({ mode: 'open' });
          
          // 2. 在 Shadow DOM 中创建内容和样式
          shadow.innerHTML = `
            <style>
              /* 这个样式只作用于 Shadow DOM 内部的 p 标签 */
              p { color: green; font-style: italic; }
            </style>
            <p>我在 Shadow DOM 内部,外部样式影响不了我,我是绿色的!</p>
          `;
        }
      }
      customElements.define('shadow-component', ShadowComponent);
      
      // 将组件添加到页面
      document.getElementById('host').appendChild(document.createElement('shadow-component'));
    </script>
    

这三者彼此协作,构建出完整的web component shadow dom 负责隔离 html template 定义结构 custom element 定义组件核心, 生命周期, 状态 , api

示例: Button组件

js 复制代码
const template = document.createElement('template');
template.innerHTML = `
  <style>
    /* 使用 :host 选择器来为组件本身(即 <my-button>)设置样式 */
    :host {
      display: inline-block;
    }

    .button {
      padding: var(--button-padding, 8px 16px);
      border: none;
      border-radius: var(--button-border-radius, 4px);
      font-size: var(--button-font-size, 14px);
      cursor: pointer;
      transition: background-color 0.2s;
      /* 通过属性来应用不同的样式类 */
    }

    .button.primary {
      background-color: var(--button-primary-bg, #007bff);
      color: var(--button-primary-color, white);
    }

    .button.primary:hover {
      background-color: var(--button-primary-hover-bg, #0056b3);
    }

    .button:disabled {
      opacity: 0.6;
      cursor: not-allowed;
    }

    /* 使用 slot 来允许用户自定义按钮内容 */
    ::slotted(*) {
      pointer-events: none; /* 防止插槽内容干扰按钮点击 */
    }
  </style>
  <button class="button">
    <!-- slot 是一个占位符,外部传入的内容会显示在这里 -->
    <slot></slot>
  </button>
`;

class MyButton extends HTMLElement {
  constructor() {
    super();
    // 1. 创建 Shadow DOM
    this._shadowRoot = this.attachShadow({ mode: 'open' });
    // 2. 克隆并附加模板内容
    this._shadowRoot.appendChild(template.content.cloneNode(true));
    // 3. 获取内部 button 元素的引用
    this.$button = this._shadowRoot.querySelector('button');
  }

  // 当元素被插入到 DOM 时调用
  connectedCallback() {
    this._updateType();
    this._updateDisabled();
    // 监听内部 button 的点击事件,并对外派发一个新事件
    this.$button.addEventListener('click', this._handleClick.bind(this));
  }

  // 当元素从 DOM 中移除时调用,做好清理工作
  disconnectedCallback() {
    this.$button.removeEventListener('click', this._handleClick);
  }

  // 声明需要监听的属性
  static get observedAttributes() {
    return ['type', 'disabled'];
  }

  // 当属性发生变化时调用
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;
    
    switch(name) {
      case 'type':
        this._updateType();
        break;
      case 'disabled':
        this._updateDisabled();
        break;
    }
  }

  // --- 私有方法 ---
  _updateType() {
    const type = this.getAttribute('type') || 'primary';
    this.$button.className = `button ${type}`;
  }

  _updateDisabled() {
    const isDisabled = this.hasAttribute('disabled');
    this.$button.disabled = isDisabled;
  }

  _handleClick(e) {
    // 派发一个自定义事件,让外部可以监听
    this.dispatchEvent(new CustomEvent('onClick', { detail: { value: 'clicked' } }));
  }
}

// 注册组件
customElements.define('my-button', MyButton);
  1. Web Component 可以在 React、Vue、Angular 等任何框架中使用
  2. 对于一些简单的项目或微前端架构,可以显著减少对庞大框架的依赖
  3. Shadow DOM 提供了框架难以比拟的样式和 DOM 隔离
  4. 无需任何框架或库,浏览器原生支持,长期稳定

示例: UserCard

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>响应式自定义元素</title>
  <style>
    body { font-family: sans-serif; }
    user-card {
      display: inline-block;
      border: 1px solid #ccc;
      border-radius: 8px;
      padding: 16px;
      margin: 10px;
      width: 200px;
      text-align: center;
      box-shadow: 2px 2px 8px rgba(0,0,0,0.1);
    }
  </style>
</head>
<body>

  <h2>用户列表</h2>
  <user-card name="张三" avatar="https://i.pravatar.cc/150?img=11"></user-card>
  <user-card name="李四" avatar="https://i.pravatar.cc/150?img=32"></user-card>
  
  <button id="change-name-btn">更改张三的名字</button>

  <script>
    class UserCard extends HTMLElement {
      // 1. 声明要监听的属性
      static get observedAttributes() {
        return ['name', 'avatar'];
      }

      constructor() {
        super(); // 必须首先调用 super()
        console.log('构造函数:元素被创建了');
      }

      // 2. 元素被插入 DOM 时,进行初次渲染
      connectedCallback() {
        console.log('connectedCallback: 元素已连接到 DOM');
        this._render();
      }

      // 3. 属性变化时,重新渲染
      attributeChangedCallback(name, oldValue, newValue) {
        console.log(`attributeChangedCallback: 属性 ${name} 从 ${oldValue} 变为 ${newValue}`);
        // 只有当元素已经连接到 DOM 时才进行渲染
        if (this.isConnected) {
          this._render();
        }
      }

      // 4. 元素从 DOM 移除时,进行清理
      disconnectedCallback() {
        console.log('disconnectedCallback: 元素已从 DOM 移除');
        // 在这里可以移除事件监听器、取消定时器等
      }

      // 私有方法:负责渲染元素内容
      _render() {
        const name = this.getAttribute('name') || '匿名用户';
        const avatar = this.getAttribute('avatar') || 'https://i.pravatar.cc/150';

        this.innerHTML = `
          <img src="${avatar}" alt="用户头像" style="width: 80px; height: 80px; border-radius: 50%;" />
          <h3>${name}</h3>
          <p>这是一个用户卡片组件。</p>
        `;
      }
    }

    // 注册组件
    customElements.define('user-card', UserCard);

    // 演示属性变化
    document.getElementById('change-name-btn').addEventListener('click', () => {
      const zhangsanCard = document.querySelector('user-card');
      // 通过 setAttribute 修改属性,会触发 attributeChangedCallback
      zhangsanCard.setAttribute('name', '张三丰');
    });
  </script>

</body>
</html>

构造函数 (constructor)必须首先调用 super(),适合进行一些与 DOM 无关的初始化工作(如创建私有变量)。 不要在构造函数中设置属性或操作 this 的子元素,因为此时元素可能还未完全连接到 DOM。 connectedCallback:是执行初始渲染、添加事件监听器、发起网络请求等"一次性"设置工作的最佳位置。 disconnectedCallback:是进行清理工作的关键,用于移除事件监听器、清除定时器等,以防止内存泄漏。 封装:虽然这个例子直接操作了 this.innerHTML,但在实际项目中,强烈建议将自定义元素与 Shadow DOM 结合使用。

示例: Shadow Dom

js 复制代码
class MyComponent extends HTMLElement {
  constructor() {
    super();
    // 1. 创建一个 'open' 模式的 Shadow DOM
    const shadowRoot = this.attachShadow({ mode: 'open' });
    
    // 2. 向 Shadow DOM 中添加内容和样式
    shadowRoot.innerHTML = `
      <style>
        /* 这里的样式只作用于 Shadow DOM 内部 */
        p { color: blue; }
      </style>
      <p>我在 Shadow DOM 内部,我的颜色是蓝色。</p>
    `;
  }
}
customElements.define('my-component', MyComponent);

mode: 'open':最常用的模式。它允许你通过 JavaScript 从外部访问组件内部的 Shadow DOM(使用 element.shadowRoot) mode: 'closed':它会阻止外部访问 element.shadowRoot(其值为 null)。更强的封装性,降低了灵活性,较少使用。

示例: shadow dom 版的userCard

html 复制代码
  <user-card name="张三" avatar="https://i.pravatar.cc/150?img=11">
    <h3 slot="username">我是从外部插入的标题</h3>
    <span>这是额外的信息</span>
  </user-card>

  <user-card name="李四" avatar="https://i.pravatar.cc/150?img=32"></user-card>
  
javascript 复制代码
    class UserCard extends HTMLElement {
      constructor() {
        super();
        // 1. 创建一个 open 模式的 Shadow DOM
        this._shadowRoot = this.attachShadow({ mode: 'open' });
      }

      connectedCallback() {
        // 2. 在 Shadow DOM 中渲染内容
        this._render();
      }

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


      attributeChangedCallback(name, oldValue, newValue) {
        if (this.isConnected) {
          this._render();
        }
      }

      _render() {
        const name = this.getAttribute('name') || '匿名用户';
        const avatar = this.getAttribute('avatar') || 'https://i.pravatar.cc/150';

        // 3. 将结构和样式全部写入 Shadow DOM
        const template = `
          &lt;img class="avatar" src="${avatar}" alt="用户头像" /&gt;
          &lt;div class="name"&gt;
            &lt;slot name="username"&gt;${name}&lt;/slot&gt;
          &lt;/div&gt;
          &lt;div class="slot-content"&gt;
            &lt;slot&gt;&lt;/slot&gt;
          &lt;/div&gt;
        `;
        
        this._shadowRoot.innerHTML = template;
      }
    }
    customElements.define('user-card', UserCard);

总结

  1. 在元素的构造函数中调用 this.attachShadow({ mode: 'open' }),确保 Shadow DOM 在元素被使用前就已准备好
  2. 通过this.shadowRoot 来访问和操作内部的 DOM,而不是直接操作 this
  3. 使用 slot 实现灵活性
  4. 将所有组件相关的 CSS 都写在 Shadow DOM 的 style 标签内

注意: 关于 自定义组件 以及 所有DOM元素都会有的一些属性

生命周期:

  1. constructor: 元素被创建时调用,用于初始化状态和属性 用途: 初始化组件的状态,设置初始 DOM 结构,创建shadow dom 注意: 不能设置属性 和 操作 DOM 元素 理解为vue中的create
  2. connectedCallback() 当元素被插入到 DOM 中时调用。这可能发生多次 用途: 执行初始化渲染:这是渲染元素内容的最佳时机。 添加事件监听器:监听 click、mouseover 等事件。 发起网络请求:获取组件所需的数据。 启动定时器。 简单除暴理解为 框架中的 mounted
  3. disconnectedCallback() 当元素从 DOM 中移除时调用。这也可能发生多次 用途: 移除事件监听器:确保不会内存泄漏。 清除定时器:防止内存泄漏。 理解为 框架中的 unmounted
  4. attributeChangedCallback(name, oldValue, newValue) 当元素的属性被添加、删除或更改时调用。 用途: 响应属性变化:当属性值改变时,重新渲染组件。 触发自定义事件:通知外部环境属性已改变。 理解为vue中的watch, computed1
  5. adoptedCallback(oldDocument, newDocument) 当元素被移动到新文档时调用。这可能发生在文档 fragment 中。 用途: 更新组件的状态:确保组件在新文档中正确运行。 重新初始化事件监听器:确保事件不会丢失。 很少用到

属性

生命周期与属性总结表

回调/属性 类型 何时调用/用途
constructor() 生命周期 元素实例化时。用于基本设置,必须调用 super()。
connectedCallback() 生命周期 元素插入 DOM 时。用于渲染、添加事件监听、启动任务。
disconnectedCallback() 生命周期 元素从 DOM 移除时。用于清理工作,防止内存泄漏。
attributeChangedCallback() 生命周期 observedAttributes 中的属性变化时。用于响应外部数据更新。
adoptedCallback() 生命周期 元素被移动到新 document 时。不常用。
observedAttributes 静态属性 static get observedAttributes() { return [...] }。声明要监听的属性列表。
isConnected 只读属性 true/false。检查元素是否在 DOM 中。
shadowRoot 只读属性 对元素 Shadow DOM 的引用(如果 mode 为 'open')。
setAttribute() 方法 设置 HTML 属性,会触发 attributeChangedCallback。
dispatchEvent() 方法 派发自定义事件,用于组件向外通信。

实战用例

跑马灯组件
html 复制代码
    <my-marquee
      :event-list="JSON.stringify(events)"
      @event-click="handleEventClick">
    </my-marquee>
    

在vue组件中使用该跑马灯组件,使用了动态绑定和事件监听 Vue会:

  • 监听 events 变量的变化
  • 当 events 变化时,自动调用 element.setAttribute('event-list', JSON.stringify(newValue))
  • 监听Web Component派发的 event-click 自定义事件

Web Component 通过

js 复制代码
 static get observedAttributes() {
        return ['event-list', 'type'];
    }

attributeChangedCallback(name, _, newValue) {
        if (name === 'event-list') {
            this.events = JSON.parse(newValue);
            this.duplicatedEvents = [...this.events, ...this.events, ...this.events];
            this._render();
        }
    }

来实现属性监听和回调处理

javascript 复制代码
class EventMarquee extends HTMLElement {
    constructor() {
        super();
        this.events = [];
        this.duplicatedEvents = [];
        this.animationId = null;
        this.offsetX = 0; // 水平偏移量
        this.itemWidth = 500; // 每个项目的宽度
        this.speed = 30; // 滚动速度(像素/秒)
        this.isPaused = false; // 是否暂停
        this.lastTimestamp = 0; // 上一帧时间戳
        this._shadowRoot = this.attachShadow({ mode: 'open' });
    }
    formatTime(time) {
        const date = new Date(time);
        const now = new Date();
        const diff = now - date;

        // 小于1小时
        if (diff < 60 * 60 * 1000) {
            const minutes = Math.floor(diff / (60 * 1000));
            return `${minutes}分钟前`;
        }

        // 小于24小时
        if (diff < 24 * 60 * 60 * 1000) {
            const hours = Math.floor(diff / (60 * 60 * 1000));
            return `${hours}小时前`;
        }

        // 超过24小时
        const days = Math.floor(diff / (24 * 60 * 60 * 1000));
        return `${days}天前`;
    }

    static get observedAttributes() {
        return ['event-list', 'type'];
    }

    attributeChangedCallback(name, _, newValue) {
        if (name === 'event-list') {
            this.events = JSON.parse(newValue);
            this.duplicatedEvents = [...this.events, ...this.events, ...this.events];
            this._render();
        }
    }

    connectedCallback() {
        // 添加鼠标悬停事件监听
        this.addEventListener('mouseenter', () => this.pauseScroll());
        this.addEventListener('mouseleave', () => this.resumeScroll());
    }

    disconnectedCallback() {
        // 清理动画
        if (this.animationId) {
            cancelAnimationFrame(this.animationId);
        }

        // 移除事件监听
        this.removeEventListener('mouseenter', () => this.pauseScroll());
        this.removeEventListener('mouseleave', () => this.resumeScroll());
    }
    updateOffsets() {
        this._render();
    }
    handleEventClick(eventId) {
        location.href = `/#/home/leakageIncidentDetail?id=${eventId}`;
        //    const customEvent = new CustomEvent('event-click', {
        //        detail: { eventId },
        //        bubbles: true,
        //        composed: true
        //    });
        //    this.dispatchEvent(customEvent);
    }

    _addEventListeners() {
        const eventItems = this._shadowRoot.querySelectorAll('.event-item');
        eventItems.forEach(item => {
            item.addEventListener('click', (e) => {
                e.preventDefault();
                const eventId = item.dataset.eventId;
                this.handleEventClick(eventId);
            });
        });
    }
    // 动画函数
    animate(timestamp) {
        if (!this?.lastTimestamp) {
            this.lastTimestamp = timestamp;
        }

        const elapsed = timestamp - this.lastTimestamp;
        this.lastTimestamp = timestamp;

        if (!this.isPaused) {
            // 计算新的偏移量
            this.offsetX -= (this.speed * elapsed) / 1000;

            // 计算内容总宽度
            const originalWidth = this.itemWidth * this.events.length;

            // 如果滚动超过原始内容宽度,重置位置以实现无缝循环
            if (Math.abs(this.offsetX) >= originalWidth) {
                this.offsetX = this.offsetX % originalWidth;
            }

            // 应用transform样式到内容元素
            const marqueeContent = this._shadowRoot.querySelector('.marquee-content');
            if (marqueeContent) {
                marqueeContent.style.transform = `translateX(${this.offsetX}px)`;
            }
        }

        this.animationId = requestAnimationFrame((time) => this.animate(time));
    };
    startAnimation() {
        if (this.animationId) {
            cancelAnimationFrame(this.animationId);
        }
        this.lastTimestamp = 0;
        this.animationId = requestAnimationFrame((time) => this.animate(time));
    }

    pauseScroll() {
        this.isPaused = true;
    }

    resumeScroll() {
        this.isPaused = false;
        this.lastTimestamp = 0;
    }

    _render() {
        const template = `
        <style>
.event-marquee {
  width: 100%;
  overflow: hidden;
  position: relative;
}
.event-title:hover{
    text-decoration: underline;
}

.marquee-wrapper {
  position: relative;
  width: 100%;
  overflow: hidden;
  border-radius: 4px;
  background: #ffffff;
  padding-left: 16px;
  padding-right: 16px;
  padding-top: 10px;
}

.marquee-container {
  display: flex;
  will-change: transform;
  overflow: hidden;
}

.marquee-content {
  display: flex;
  width: fit-content;
}

.event-item {
  min-width: 500px;
  border-radius: 4px;
  background: white;
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  backface-visibility: hidden;
  transform: translateZ(0);
  margin-right: 20px;
}

.event-item:hover {
  transform: translateY(-3px) scale(1.02);
}

.event-header {
  display: flex;
  align-items: center;
  margin-bottom: 5px;
}

.event-type {
  padding: 2px 6px;
  border-radius: 3px;
  font-size: 12px;
  font-weight: 500;
  margin-right: 8px;
}

.event-type.urgent {
  background: #fef2f2;
  color: #dc2626;
}

.event-title {
  font-size: 14px;
  font-weight: 500;
  color: #374151;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex: 1;
}

.event-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 12px;
  color: #9ca3af;
}

.event-time {
  font-size: 12px;
  color: #9ca3af;
}

@media (max-width: 768px) {
  .event-item {
    min-width: 300px;
  }
}

@media (max-width: 480px) {
  .event-item {
    min-width: 250px;
  }
}
        </style>
       <div class="event-marquee">
     <div class="marquee-wrapper">
      <div class="marquee-container" ref="marqueeContainer">
        <div
          class="marquee-content"
          ref="marqueeContent"
        >
        ${this.duplicatedEvents.map(event => `
                  <div class="event-item" data-event-id="${event.eventId}">
                    <div class="event-header">
                      <div class="event-title">
                        <span class="event-type urgent">消息</span>
                        <span class="event-title">${event.title}</span> - 
                        <span class="event-time">${this.formatTime(event.time)}</span>
                      </div>
                    </div>
                  </div>
                `).join('')}
        </div>
      </div>
    </div>
        </div>
        `;
        this._shadowRoot.innerHTML = template;
        this._addEventListeners();
        // 只有在有数据时才开始动画
        if (this.duplicatedEvents.length > 0) {
            this.startAnimation();
        }
    }
}

export default EventMarquee;
相关推荐
San302 小时前
深入理解 JavaScript 事件机制:从事件流到事件委托
前端·javascript·ecmascript 6
行走在顶尖2 小时前
基础随记
前端
Sakura_洁2 小时前
解决 el-table 在 fixed 状态下获取 dom 不准确的问题
前端
best6662 小时前
Vue3什么时候不会触发onMounted生命周期钩子?
前端·vue.js
best6662 小时前
Javascript有哪些遍历数组的方法?哪些不支持中断?那些不支持异步遍历?
前端·javascript·面试
特级业务专家2 小时前
Chrome DevTools 高级调试技巧:从入门到真香
前端·javascript·浏览器
爱学习的程序媛2 小时前
【Web前端】Angular核心知识点梳理
前端·javascript·typescript·angular.js
小时前端2 小时前
前端架构师视角:如何设计一个“站稳多端”的跨端体系?
前端·javascript·面试
Hilaku2 小时前
这 5 个冷门的 HTML 标签,能让你少写 100 行 JS
前端·javascript·html