本文也是在阅读红宝书中了解到了 web component,文中提及的 lit-html 再上篇文章中已经提及过

web components 提供了一个官方的、原生的解决方案。它不是一个库,而是一套浏览器内置的标准,让我们能够创建独立、可复用、且不受任何框架限制的 UI 组件


目标:构建具备以下功能的 my-counter

  • 可复用:使用自定义标签
  • 样式隔离:使用 Shadow DOM
  • 高性能:使用 lit-html 高效更新 UI
  • 可配置:通过属性自定义初始值、步长等

# Part 1:Web Component 的三大支柱

# Step 1:搭建骨架 --Custom Elements(自定义元素)

class MyCounter extends HTMLElement {
  constructor() {
    super(); // 必须调用
    this.count = 0; 
  }
  // 当元素被插入到 DOM 文档流时调用
  connectedCallback() {
    // 基础渲染:内容会和页面的全局样式混在一起
    this.innerHTML = '<p>我是一个计数器</p>'; 
  }
}
// 注册组件,标签名必须包含连字符
customElements.define('my-counter', MyCounter);
  1. 通过继承 HTMLElement 赋予组件生命力,使其能够被浏览器识别和管理
  2. 通过 connectedCallback 方法,在元素插入 DOM 文档流时进行初始化渲染
  3. 这里的 this 指向当前实例化的组件对象,即 my-counter 元素
  4. 通过 customElements.define 方法,将组件注册到浏览器中,第一个参数为自定义标签名,第二个参数为组件类名

# Step 2:隔离保护 --Shadow DOM(影子 DOM)

为了解决样式冲突,我们为组件创建一个独立的、封装的 DOM 树

class MyCounter extends HTMLElement {
  constructor() {
    super();
    // 1. 开启影子 DOM 模式
    this.attachShadow({ mode: 'open' });
    this.count = 0; 
  }
  connectedCallback() {
    // 2. 把内容和内部样式写进 shadowRoot
    this.shadowRoot.innerHTML = `
      <style> 
        p { 
          color: blue; /* 仅在组件内部生效 */
          font-weight: bold;
        } 
      </style> 
      <p>我是被保护的计数器</p>
    `;
  }
}
customElements.define('my-counter', MyCounter);
  1. 通过 attachShadow 方法开启影子 DOM 模式,第一个参数为配置对象, modeopen 表示允许外部访问, closed 表示不允许外部访问
  2. 通过 shadowRoot 属性访问影子 DOM 树,将内容和内部样式写进它

# Part 2:性能与响应式(lit-html)

# Step 3:引入神器 --lit-html

这个时候发现,每次创建标签时,都会调用 connectedCallback 方法,导致重复渲染
最开始的解决方法是,将模板写在 HTML 文件中,通过 document.getElementById 方法获取,然后克隆到 shadowRoot

<template id="my-counter-template">
  <!-- 基础模板 -->
</template>

但是这样又不满足高内聚低耦合的原则,因为模板和组件的代码都写在 HTML 文件中

因此我们就使用 lit-html 库,将模板写在 JavaScript 文件中,通过 html 函数渲染到 shadowRoot 中,既满足高内聚低耦合的原则,又能够实现高效更新 UI

import { html, render } from 'https://unpkg.com/lit-html@^2.0.0/lit-html.js';
class MyCounter extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.count = 0; 
  }
  // 1. 定义增加的方法 (使用箭头函数锁定 this),注意!!!!!!!
  _increment = () => {
    this.count++;
    this.render(); // 数据变了,请求更新界面
  }
  // 2. 定义模板 (View)
  get template() {
    return html`
      <style>
        button { cursor: pointer; padding: 5px 10px; }
      </style>
      <div>当前计数: ${this.count}</div>
      <button @click=${this._increment}>+1</button>
    `;
  }
  // 3. 核心渲染方法
  render() {
    render(this.template, this.shadowRoot);
  }
  connectedCallback() {
    this.render(); // 首次挂载时渲染一次
  }
}
customElements.define('my-counter', MyCounter);
  1. 通过 html 函数定义模板,使用 ${} 插值表达式插入动态数据
  2. 通过 render 函数将模板渲染到 shadowRoot 中,render 的第一个参数为模板,第二个参数为目标 DOM 节点
  3. 通过 connectedCallback 方法,在元素插入 DOM 文档流时进行初始化渲染

# Step 4:打通内外 -- 响应 HTML 属性变化

为了让组件可配置,我们需要监听外部的 HTML 属性( initial-count )的变化

  1. 通过 static get observedAttributes() { return [...] } 告知浏览器只监听特定的属性
  2. 通过 attributeChangedCallback(name, oldVal, newVal) 方法,在属性发生变化时更新组件状态

# 完整代码

# test.js

import { html, render } from 'https://unpkg.com/lit-html@^2.0.0/lit-html.js';
class MyCounter extends HTMLElement {
  static get observedAttributes() {
    // 监听 initial-count 属性变化
    return ['initial-count'];
  }
  constructor() {
    super();
    this.count = 0;
    // 创建 Shadow DOM
    this.attachShadow({ mode: 'open' });
  }
  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'initial-count' && oldVal !== newVal) {
      // 当 initial-count 属性变化时,更新组件状态
      this.count = parseInt(newVal) || 0;
      this.render();
    }
  }
  connectedCallback() {
    // 首次挂载时渲染一次
    this.render();
  }
  _increment = () => {
    // 点击 +1 按钮时,增加计数并更新 initial-count 属性
    this.count ++;
    // 同步更新 initial-count 属性
    this.setAttribute('initial-count', this.count);
    this.render();
  }
  get template() {
    // 定义模板 (View)
    return html`
          <style>
            /* :host 代表组件本身 */
            :host {
              display: block;
              font-family: sans-serif;
              margin-bottom: 20px;
            }
            .container {
              padding: 20px;
              background-color: #f0f0f0;
              border-radius: 8px;
              text-align: center;
            }
            button {
              padding: 8px 16px;
              font-size: 16px;
              cursor: pointer;
              background-color: #007bff;
              color: white;
              border: none;
              border-radius: 4px;
            }
            button:hover {
              background-color: #0056b3;
            }
          </style>
          <div class="container">
            <h2>Lit 计数器</h2>
            <p>Count: ${this.count}</p>
            <button @click=${this._increment}>+ Plus</button>
          </div>
        `;
  }
  render() {
    // 核心渲染方法
    render(this.template, this.shadowRoot);
  }
}
// 定义自定义元素 my-counter
customElements.define('my-counter', MyCounter);

# test.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./test.js" type="module"></script>
    <my-counter initial-count="1"></my-counter>
  </body>
</html>

# 总结

  1. Custom Elements:解决了 HTML 标签语义化和复用问题
  2. Shadow DOM:解决了 CSS 和 DOM 的封装和隔离问题
  3. lit-HTML:解决了原生 DOM 操作性能低下繁琐问题
  4. Attribute Observation:解决了组件与外部世界进行数据通信初始化的问题