微前端之 Web Components 完全指南

Web Components 由三项主要技术构成,三者可以组合使用,也可以独立使用。

自定义元素(Custom Elements)

自定义元素是一组 JavaScript API,允许开发者定义自己的 HTML 元素及其行为。简单来说,就是可以创建一个像 <my-element> 这样的标签,并赋予它完整的生命周期和交互逻辑。

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

  • 自主自定义元素(Autonomous Custom Elements) :继承自 HTMLElement 基类,需要从头实现所有行为。这是最常用的类型。
  • 定制内置元素(Customized Built-in Elements) :继承自标准 HTML 元素(如 HTMLParagraphElementHTMLImageElement),扩展现有元素的功能。需要注意:Safari 不打算支持定制内置元素,因此在实际项目中应谨慎使用

示例 简单使用

html 复制代码
  <body>
    <my-button id="btn">默认按钮</my-button>
    <hr />
  </body>
  <script>
    const btn = document.getElementById("btn");

    btn.addEventListener("click", (event) => {
      // PointerEvent 指针事件 => MouseEvent => UIEvent => Event
      console.log("点击了按钮", event);
    });

    btn.onclick = (event) => {
      console.log("点击了按钮-onclick", event);
    };
    class MyButton extends HTMLElement {
      constructor() {
        super();
      }
    }
    customElements.define("my-button", MyButton);

    // 创建自定义按钮元素
    const btnDom = document.createElement("my-button");
    btnDom.textContent = "自定义按钮";
    document.body.appendChild(btnDom); // 添加到 DOM 中

    btnDom.addEventListener("click", (event) => {
      console.log("点击了按钮-dom", event);
    });
  </script>

生命周期

  1. constructor
    • 触发时机:元素实例被创建 时,例如使用 document.createElement() 或在HTML中解析到标签时
    • 初始化内部状态 :设置初始值、添加默认事件监听器等。注意 :此时元素还未被添加到DOM,不应在此检查其属性或子元素
  2. connectedCallback
    • 触发时机:元素被插入到文档DOM中时。每次移动都可能触发
    • 进行主要渲染工作,如访问DOM属性、添加子元素、获取数据等
  3. disconnectedCallback
    • 触发时机:元素从文档DOM中被移除
    • 执行清理工作:用于移除在此元素上添加的全局事件监听器、取消定时器等,以防止内存泄漏
  4. attributeChangedCallback
    • 触发时机:当自定义元素的属性值发生变化时调用
    • 只有被列在 observedAttributes 中的属性变化才会触发 attributeChangedCallback
  5. adoptedCallback
    • 触发时机:元素被移动到一个新的文档 (如通过 document.adoptNode())时。
    • 适配新环境 :用于处理元素在不同文档(如 <iframe>)间移动时,需要重新初始化或适配的逻辑。
    • 普通的 DOM 移动(如 appendChild 到同文档的其他位置)不会触发 adoptedCallback ,只有跨文档移动才会触发。

示例 生命周期的使用

js 复制代码
  <body>
    <my-button id="btn" type="primary">默认按钮</my-button>
    <button id="addBtn">添加添加一个按钮</button>
    <button id="removeBtn">移除一个按钮</button>
    <button id="changeBtn">改变按钮属性</button>
    <button id="moveBtn">移动到iframe</button>
    <br />
    <iframe src="about:blank"></iframe>
  </body>
  <script>
    class MyButton extends HTMLElement {
      // 触发时机:元素实例被创建时,例如使用 document.createElement() 或在HTML中解析到标签时
      constructor() {
        super();
      }

      // 当自定义元素被添加到 DOM 中时调用
      connectedCallback() {
        console.log("connectedCallback");
      }

      // 当自定义元素从 DOM 中移除时调用
      disconnectedCallback() {
        console.log("disconnectedCallback");
      }

      // 定义需要监听的属性
      static get observedAttributes() {
        return ["type", "size", "disabled"];
      }

      // 当自定义元素的属性值发生变化时调用
      attributeChangedCallback(name, oldValue, newValue) {
        console.log("attributeChangedCallback", name, oldValue, newValue);
      }

      // 当自定义元素被移动到其他文档时时调用
      adoptedCallback() {
        console.log("adoptedCallback");
      }
    }
    customElements.define("my-button", MyButton);

    // 添加按钮
    const addBtn = document.getElementById("addBtn");
    addBtn.addEventListener("click", () => {
      const dyBtn = document.createElement("my-button");
      dyBtn.id = "dyBtn";
      dyBtn.textContent = "动态按钮";
      document.body.appendChild(dyBtn);
    });

    // 移除按钮
    const removeBtn = document.getElementById("removeBtn");
    removeBtn.addEventListener("click", () => {
      const btn = document.getElementById("dyBtn");
      btn.remove();
    });

    // 改变按钮属性
    const changeBtn = document.getElementById("changeBtn");
    changeBtn.addEventListener("click", () => {
      const btn = document.getElementById("dyBtn");
      btn.setAttribute("type", "danger");
    });

    // 移动按钮到iframe
    const moveBtn = document.getElementById("moveBtn");
    moveBtn.addEventListener("click", () => {
      const btn = document.getElementById("dyBtn");
      const iframe = document.querySelector("iframe");
      iframe.contentDocument.body.appendChild(btn);
    });
  </script>

新添加 自定义按钮 显示在页面上,触发 connectedCallback 回调;从页面中移除,也会触发 connectedCallback 回调。

改变按钮属性,触发 attributeChangedCallback 回调。需要同时设置 static get observedAttributes,因为只有被列在 observedAttributes 中的属性变化才会触发 attributeChangedCallback

将自定义按钮移动到 iframe ,触发 adoptedCallback 回调

示例 转移 DOM

使用 appendChild() 将一个节点插入到 不同的文档 时,浏览器会 隐式执行 adopt 操作

js 复制代码
// 移动按钮到 iframe
const moveBtn = document.getElementById("moveBtn");
moveBtn.addEventListener("click", () => {
  const btn = document.getElementById("dyBtn");
  const iframe = document.querySelector("iframe");
  
  if (!iframe.contentDocument) {
    return;
  }
  // 如果 iframe 没有加载完成,会报错
  iframe.contentDocument.body.appendChild(btn);
});

克隆一个副本,不会触发 adoptedCallback 回调

js 复制代码
// 复制按钮到 iframe
const copyBtnDom = document.getElementById("copyBtn");
copyBtnDom.addEventListener("click", () => {
  const btn = document.getElementById("dyBtn");
  const cloneBtn = btn.cloneNode(true);
  const iframe = document.querySelector("iframe");
  // 如果 iframe 没有加载完成,会报错
  if (!iframe.contentDocument) {
    return;
  }
  iframe.contentDocument.body.appendChild(cloneBtn);
});

显示声明跨文档转移

执行 document.adoptNode(externalNode) 时,浏览器会做两件事:

  1. 修改归属 :将 externalNode.ownerDocument 指向当前的 document
  2. 切断原籍 :如果该节点有父节点,会自动从其原始父节点中移除(相当于剪切)。
js 复制代码
// 移动按钮到iframe
const moveBtn = document.getElementById("moveBtn");
moveBtn.addEventListener("click", () => {
  const btn = document.getElementById("dyBtn");
  const iframe = document.querySelector("iframe");
  // 如果 iframe 没有加载完成,会报错
  if (!iframe.contentDocument) {
    return;
  }
  // 显示声明跨文档转移,不是必须的
  iframe.contentDocument.adoptNode(btn);
  // 使用 appendChild() 将一个节点插入到 不同的文档 时,浏览器会 隐式执行 adopt 操作
  iframe.contentDocument.body.appendChild(btn);
    });

当执行 document.importNode(externalNode, true) 时,浏览器底层做了两件事:

  1. 深拷贝 :完全复制该节点及其所有子节点(取决于 deep 参数)。
  2. 修改归属 :将这份副本ownerDocument 指向当前文档。
js 复制代码
moveBtn.addEventListener("click", () => {
  const btn = document.getElementById("dyBtn");
  const iframe = document.querySelector("iframe");
  // 如果 iframe 没有加载完成,会报错
  if (!iframe.contentDocument) {
    return;
  }
  // importNode() 方法复制节点
  // 新节点,不会触发 adoptedCallback() 回调方法
  const imported = iframe.contentDocument.importNode(btn, true);
  iframe.contentDocument.body.appendChild(imported);
});

示例 创建自定义元素

js 复制代码
customElements.define("my-button", MyButton);

customElements.define('my-element', MyElementClass, { 
    extends: 'div'  // 继承 div 元素 
});
  • 标签名必须带 -
  • 同一个标签名只能注册一次,否则会抛出 NotSupportedError
  • 类必须继承 HTMLElement或者其子类
  • 推荐使用 whenDefined().then() 来等待元素定义,而不是用 setInterval 检查 get()
  • define 可以在 DOM 解析前或后调用,但如果在元素已存在于 DOM 后定义,浏览器会自动升级它们
js 复制代码
interface CustomElementRegistry {
    /**
     * The **`define()`** method of the CustomElementRegistry interface adds a definition for a custom element to the custom element registry, mapping its name to the constructor which will be used to create it.
     *
     * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomElementRegistry/define)
     */
    define(name: string, constructor: CustomElementConstructor, options?: ElementDefinitionOptions): void;
    /**
     * The **`get()`** method of the previously-defined custom element.
     *
     * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomElementRegistry/get)
     */
    get(name: string): CustomElementConstructor | undefined;
    /**
     * The **`getName()`** method of the previously-defined custom element.
     *
     * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomElementRegistry/getName)
     */
    getName(constructor: CustomElementConstructor): string | null;
    /**
     * The **`upgrade()`** method of the elements in a Node subtree, even before they are connected to the main document.
     * 强制升级未定义的元素
     * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomElementRegistry/upgrade)
     */
    upgrade(root: Node): void;
    /**
     * The **`whenDefined()`** method of the resolves when the named element is defined. 异步等待元素定义
     *
     * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomElementRegistry/whenDefined)
     */
    whenDefined(name: string): Promise<CustomElementConstructor>;
}

MyButton 实例,有一个属性 customElementRegistry

Shadow DOM(影子 DOM)

Shadow DOM 是一组 JavaScript API,用于将一个封装的"影子"DOM 树附加到元素上。这个影子 DOM 与主文档的 DOM 分开渲染,实现了:

  • 样式隔离:组件内部的样式不会泄漏到外部,外部样式也不会影响组件内部。
  • 结构封装:组件的内部结构对外部是不可见的,外部无法直接访问或操作。

通过 Shadow DOM,可以保持元素特性的私有性,使其可以被脚本化和样式化,而不用担心与文档其他部分发生冲突。

js 复制代码
interface ShadowRootInit {
    clonable?: boolean;
    // 可以传入一个独立的 CustomElementRegistry 实例,
    // 使得在该 Shadow Root 内部定义的或使用的自定义元素(通过 `<x-foo>` 标签)只从这个注册表中查找,而不影响全局注册表
    customElementRegistry?: CustomElementRegistry;
    delegatesFocus?: boolean;
    mode: ShadowRootMode;
    serializable?: boolean;
    // 控制 Shadow DOM 中 插槽(Slot)  的分配机制
    slotAssignment?: SlotAssignmentMode;
}

示例 基本使用

在HTML中解析到 my-button 标签时,触发 MyButton 构造函数

js 复制代码
  <body>
    <my-button id="btn" type="primary">默认按钮</my-button>
  </body>
  <script>
    class MyButton extends HTMLElement {
      constructor() {
        super();
        // 原型链:HTMLElement => Element(attachShadow) => Node => EventTarget => Object => null
        const shadowRoot = this.attachShadow({
          // 外部 JavaScript 可以通过 element.shadowRoot 属性访问到该 Shadow Root。
          mode: "open",
        });
       
        this.shadowRoot.innerHTML = `
        <style>
          button {
            padding: 8px 8px;
            border: none;
            &:hover {
              cursor: pointer;
            }
          }
        </style>
          <button>
            ${this.textContent}
          </button>
        `;
      }
      connectedCallback() {
        console.log("connectedCallback");
      }
      disconnectedCallback() {
        console.log("disconnectedCallback");
      }
    }
    customElements.define("my-button", MyButton);
  </script>

HTML 模板(HTML Templates)

<template><slot> 元素,可以编写在渲染页面中不显示的标记模板。这些模板随后可以多次用作自定义元素结构的基础进行重用。

  • <template> :定义可复用的 HTML 结构,其内容在页面加载时不会被渲染。
  • <slot> :提供内容分发机制,允许用户在使用自定义元素时插入自定义内容。<slot> 只能在 Shadow DOM 中工作,在 Light DOM(innerHTML)中无效。

示例 slot 的使用

js 复制代码
  <body>
    <my-button id="btn" type="primary">默认按钮</my-button>
    <my-button id="btn1" type="primary">按钮</my-button>
  </body>
  <script>
    class MyButton extends HTMLElement {
      constructor() {
        super();
        // 原型链:HTMLElement => Element(attachShadow) => Node => EventTarget => Object => null
        const shadowRoot = this.attachShadow({
          // 外部 JavaScript 可以通过 element.shadowRoot 属性访问到该 Shadow Root。
          mode: "open",
        });
      }
      connectedCallback() {
        this.render();
        console.log("connectedCallback");
      }
      disconnectedCallback() {
        console.log("disconnectedCallback");
      }

      // 自定义渲染
      render() {
        this.shadowRoot.innerHTML = `
        <style>
          button {
            padding: 8px 8px;
            border: none;
            &:hover {
              cursor: pointer;
            }
          }
        </style>
          <button>
            <slot></slot>
          </button>
        `;
      }
    }
    customElements.define("my-button", MyButton);
  </script>

示例 closed 模式

自定义元素的插槽内容属于 Light DOM,不属于 Shadow DOM。

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>
    <style>
      /* 这里的样式优先级比插槽内部定义的样式高,所以这里定义的样式会生效,插槽内部定义的样式会被覆盖 */
      .title {
        font-size: 24px;
      }
    </style>
  </head>
  <body>
    <my-card>
      <!-- 插槽内容属于 Light DOM,不属于 Shadow DOM -->
      <div class="title">卡片标题</div>
      <div class="content">卡片内容</div>
    </my-card>
  </body>
  <script>
    class MyCard extends HTMLElement {
      constructor() {
        super();

        // 设置 closed 模式,无法从外部访问 shadowRoot
        const shadowRoot = this.attachShadow({
          mode: "closed",
        });

        console.log("shadowRoot", shadowRoot);

        shadowRoot.innerHTML = `
          <style>
           .card {
            padding: 12px;
            border: 1px solid #ccc;
            border-radius: 4px;
            background-color: #F4C0EB;
            padding:12px 14px;
           }
          ::slotted(.title) {
            font-size: 16px;
            font-weight: bold;
          }
          // 匹配插槽中的元素,并应用样式
          ::slotted(.content) {
            font-size: 14px;
          }
          </style>

          <div class="card">
            <slot></slot>
          </div>
          `;

        // 无法从外部访问 shadowRoot
        // console.log("this.shadowRoot", this.shadowRoot); // undefined
      }

      connectedCallback() {
        console.log("connectedCallback");
      }
      disconnectedCallback() {
        console.log("disconnectedCallback");
      }
    }

    customElements.define("my-card", MyCard);
  </script>
</html>

示例 利用宿主元素属性/方法通信

js 复制代码
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .title {
        font-size: 24px;
      }
    </style>
  </head>
  <body>
    <my-card id="my-card">
      <!-- 插槽内容属于 Light DOM,不属于 Shadow DOM -->
      <div class="title">卡片标题</div>
      <div class="content">卡片内容</div>
    </my-card>

    <button id="btn">增加计数</button>
  </body>
  <script>
    class MyCard extends HTMLElement {
      count = 0;
      constructor() {
        super();

        // 设置 closed 模式,无法从外部访问 shadowRoot
        this.root = this.attachShadow({
          mode: "closed",
        });

        this.render();

        console.log("root", this.root);

        // 无法从外部访问 shadowRoot
        console.log("this.shadowRoot", this.shadowRoot); // undefined
      }

      render() {
        this.root.innerHTML = `
          <style>
           .card {
            padding: 12px;
            border: 1px solid #ccc;
            border-radius: 4px;
            background-color: #F4C0EB;
            padding:12px 14px;
           }
          ::slotted(.title) {
            font-size: 16px;
            font-weight: bold;
          }
          // 匹配插槽中的元素,并应用样式
          ::slotted(.content) {
            font-size: 14px;
          }
          </style>

          <div class="card">
            <slot></slot>
            <p id="count">当前计数: ${this.count || 0}</p>
          </div>
          `;
      }

      connectedCallback() {
        console.log("connectedCallback");
      }
      disconnectedCallback() {
        console.log("disconnectedCallback");
      }

      // 自定义方法,用于交互外部事件
      setCount(count) {
        console.log("setCount", count);
        this.count = count;
        this.render();
      }

      getCount() {
        return this.count;
      }
    }

    customElements.define("my-card", MyCard);

    // 调用自定义方法
    const myCard = document.getElementById("btn");
    const btn = document.getElementById("btn");
    btn.addEventListener("click", () => {
      const cardDom = document.getElementById("my-card");
      cardDom.setCount(cardDom.getCount() + 1);
      console.log(cardDom.getCount());
    });
  </script>
</html>

示例 利用自定义事件(需借助宿主方法)

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>
    <style>
      .title {
        font-size: 24px;
      }
    </style>
  </head>
  <body>
    <my-card id="my-card">
      <!-- 插槽内容属于 Light DOM,不属于 Shadow DOM -->
      <div class="title">卡片标题</div>
      <div class="content">卡片内容</div>
    </my-card>

    <button id="btn">增加计数</button>

    <button id="close-btn">关闭卡片</button>
  </body>
  <script>
    class MyCard extends HTMLElement {
      count = 0;
      constructor() {
        super();

        // 设置 closed 模式,无法从外部访问 shadowRoot
        this.root = this.attachShadow({
          mode: "closed",
        });

        this.render();
        this.addEventListener("close", this.closeHandler);
      }
      closeHandler(e) {
        console.log("close", e);
      }

      render() {
        this.root.innerHTML = `
          <style>
           .card {
            padding: 12px;
            border: 1px solid #ccc;
            border-radius: 4px;
            background-color: #F4C0EB;
            padding:12px 14px;
           }
          ::slotted(.title) {
            font-size: 16px;
            font-weight: bold;
          }
          // 匹配插槽中的元素,并应用样式
          ::slotted(.content) {
            font-size: 14px;
          }
          </style>

          <div class="card">
            <slot></slot>
            <p id="count">当前计数: ${this.count || 0}</p>
          </div>
          `;
      }

      connectedCallback() {
        console.log("connectedCallback");
      }
      disconnectedCallback() {
        console.log("disconnectedCallback");
        this.removeEventListener("close", this.closeHandler);
      }

      notify() {
        this.dispatchEvent(
          new CustomEvent("countChange", {
            detail: {
              count: this.count, 
            },
            // bubbles: true,
            // composed: true, // 关键:允许穿透 shadow boundary
          }),
        );
      }

      getCount() {
        return this.count;
      }
      setCount(count) {
        this.count = count || 0;
        this.render();
        this.notify();
      }
    }

    customElements.define("my-card", MyCard);

    // 调用自定义方法
    const btn = document.getElementById("btn");
    const cardDom = document.getElementById("my-card");

    btn.addEventListener("click", () => {
      const cardDom = document.getElementById("my-card");
      cardDom.setCount(cardDom.getCount() + 1);
      console.log(cardDom.getCount());
    });

    cardDom.addEventListener("countChange", (e) => {
      console.log("e", e);
    });

    const closeBtn = document.getElementById("close-btn");
    closeBtn.addEventListener("click", () => {
      cardDom.dispatchEvent(
        new CustomEvent("close", {
          detail: {
            message: "关闭卡片",
          },
        }),
      );
    });
  </script>
</html>

外部 ----> 内部 shadow dom

外部不能直接触发内部的事件监听器(因为事件目标在内部)。通常做法是调用宿主元素的方法 或向宿主元素派发事件,内部通过 this.addEventListener 监听宿主元素自身的事件。

内部 shadow dom ----> 外部

ts 复制代码
declare var CustomEvent: {
    prototype: CustomEvent;
    new<T>(type: string, eventInitDict?: CustomEventInit<T>): CustomEvent<T>;
};
ts 复制代码
interface EventInit {
    bubbles?: boolean; // 事件是否冒泡到父元素
    cancelable?: boolean;
    composed?: boolean; // 事件是否能穿透 Shadow DOM 边界
}

直接在目标元素上监听,不需要设置这两个选项。 当外部从父元素/祖先元素监听,需要设置 bubblestrue; 当从内部shadow 中触发事件,需要 设置 composedtrue

js 复制代码
this.dispatchEvent(
  new CustomEvent("countChange", {
    detail: {
      count: this.count,
    },
    // bubbles: true,
    // composed: true, // 关键:允许穿透 shadow boundary
  }),
);

示例 宿主父节点/祖先节点监听 shodow dom 事件

结合上述代码修改

js 复制代码
customElements.define("my-card", MyCard);

// 调用自定义方法
const btn = document.getElementById("btn");
const cardDom = document.getElementById("my-card");

//
btn.addEventListener("click", () => {
  const cardDom = document.getElementById("my-card");
  cardDom.setCount(cardDom.getCount() + 1);
  console.log(cardDom.getCount());
});

// body 触发监听 countChange 
document.body.addEventListener("countChange", (e) => {
  console.log("e", e);
});
js 复制代码
  notify() {
    this.dispatchEvent(
      new CustomEvent("countChange", {
        detail: {
          count: this.count,
        },
        bubbles: true,
        // composed: true, // 关键:允许穿透 shadow boundary
      }),
    );
  }

示例 template 的使用

在 Custom Element(自定义元素)中使用 <template>,是 Web Components 开发中最标准的实践 。它解决了传统 JS 拼接字符串导致 XSS 风险和维护困难的问题,同时提供了极高的渲染性能(模板只解析一次,克隆多次)。

<template> 最大的价值在于惰性:它内部的 DOM 在激活前不会被渲染,也不会请求资源(如图片)。在 Custom Element 中,我们通常这样做:

  1. 定义模板 :在 HTML 或 JS 中定义 <template>
  2. 获取片段 :在组件实例化时,获取 template.content(这是一个 DocumentFragment)。
  3. 深克隆 :使用 cloneNode(true) 克隆这个片段(因为片段是引用类型,直接使用会移动节点,导致只能使用一次)。

模板最好定义在 HTML 的 <body> 中,或者通过 JS 动态创建。如果定义在 <head> 中,部分浏览器可能无法正常获取内容。

<template> 内部 的子元素不推荐使用 id,模板内部的内容会被克隆多次 ,如果内部写了 id,克隆后会有多个同名 id 存在于同一个 Shadow Root 或 Document 中。

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>
    <div>我是一个div</div>
    <template id="card-template">
      <style>
        .card {
          background-color: aliceblue;
          padding: 12px 16px;
        }
        p {
          color: blue;
        }
      </style>
      <div class="card">
        <p class="title">我是一个p标题</p>
        <div class="content">我是一个template</div>
      </div>
    </template>
    <my-component title="标题1" content="自定义内容1"></my-component>
    <br />
    <my-component title="标题2" content="自定义内容2"></my-component>
  </body>

  <script>
    class MyComponent extends HTMLElement {
      title = "默认标题";
      content = "默认内容";

      constructor() {
        super();
        this.root = this.attachShadow({ mode: "closed" });

        // 获取模板内容(DocumentFragment)
        // HTMLTemplateElement.content 是一个 DocumentFragment 对象
        const template = document.getElementById("card-template");
        console.log("template", template);

        // 深克隆节点(关键!不能直接用 template.content)
        // DocumentFragment => Node(cloneNode) => EventTarget => Object => null
        const clone = template.content.cloneNode(true);

        // 存储相关dom引用
        this.titleElement = clone.querySelector(".title");
        this.contentElement = clone.querySelector(".content");

        // 挂载到 Shadow Root
        this.root.appendChild(clone);
      }

      static get observedAttributes() {
        return ["title", "content"];
      }

      attributeChangedCallback(name, oldValue, newValue) {
        console.log("attributeChangedCallback", name, oldValue, newValue);

        this._updateContent({
          name,
          newValue,
          oldValue,
        });
      }

      _updateContent({ name, newValue, oldValue }) {
        if (name === "title") {
          this.titleElement.textContent = newValue;
        } else if (name === "content") {
          this.contentElement.textContent = newValue;
        }
      }
    }
    customElements.define("my-component", MyComponent);
  </script>
</html>

DocummentFragment.content

js 复制代码
const clone = template.content // 直接使用

// 挂载到 Shadow Root
this.root.appendChild(clone);

必须深克隆 (cloneNode(true)) :如果不克隆,template.content 会被移动,导致第二个组件实例挂载时,模板内容是空的。

示例 通信

使用 shadowRoot 的事件委托 来处理内部事件,而不是在模板里写 onclick 属性(不符合内容安全策略 CSP)

html 复制代码
<template id="card-template">
  <style>
    .card {
      background-color: aliceblue;
      padding: 12px 16px;
    }
    p {
      color: blue;
    }
  </style>
  <div class="card">
    <p class="title">我是一个p标题</p>
    <div class="content">我是一个template</div>
  </div>
</template>
js 复制代码
class MyComponent extends HTMLElement {
  title = "默认标题";
  content = "默认内容";

  constructor() {
    super();
    this.root = this.attachShadow({ mode: "closed" });

    // 获取模板内容(DocumentFragment)
    // HTMLTemplateElement.content 是一个 DocumentFragment 对象
    const template = document.getElementById("card-template");

    // 深克隆节点(关键!不能直接用 template.content)
    // DocumentFragment => Node(cloneNode) => EventTarget => Object => null
    const clone = template.content.cloneNode(true);


    // 存储相关dom引用
    this.titleElement = clone.querySelector(".title");
    this.contentElement = clone.querySelector(".content");

    // 挂载到 Shadow Root
    this.root.appendChild(clone);
    this.root.addEventListener("click", this.handleClick.bind(this));
  }

  connectedCallback() {
    console.log("connectedCallback");
  }

  disconnectedCallback() {
    // 组件从文档中移除时,移除事件监听
    this.root.removeEventListener("click", this.handleClick.bind(this));
    console.log("disconnectedCallback");
  }

  handleClick(e) {
    console.log("handleClick", e);
    this.dispatchEvent(
      new CustomEvent("message", {
        detail: {
          title: this.title,
          content: this.content,
        },
        composed: true,
      }),
    );
  };

  static get observedAttributes() {
    return ["title", "content"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log("attributeChangedCallback", name, oldValue, newValue);

    this._updateContent({
      name,
      newValue,
      oldValue,
    });
  }

  _updateContent({ name, newValue, oldValue }) {
    if (name === "title") {
      this.title = newValue;
      this.titleElement.textContent = newValue;
    } else if (name === "content") {
      this.content = newValue;
      this.contentElement.textContent = newValue;
    }
  }
}
customElements.define("my-component", MyComponent);
html 复制代码
<my-component
  id="component1"
  title="标题1"
  content="自定义内容1"
></my-component>
<br />
<my-component
  id="component2"
  title="标题2"
  content="自定义内容2"
></my-component>

<script>
    const myComponent = document.querySelector("#component1");
    myComponent.addEventListener("message", (e) => {
      console.log("message", e.detail);
    });
</script>
js 复制代码
class MyComponent extends HTMLElement {
  title = "默认标题";
  content = "默认内容";

  constructor() {
    super();
    this.root = this.attachShadow({ mode: "closed" });

    // 获取模板内容(DocumentFragment)
    // HTMLTemplateElement.content 是一个 DocumentFragment 对象
    const template = document.getElementById("card-template");

    // 深克隆节点(关键!不能直接用 template.content)
    // DocumentFragment => Node(cloneNode) => EventTarget => Object => null
    const clone = template.content.cloneNode(true);

    // console.log("clone---", clone);
    // 存储相关dom引用
    this.titleElement = clone.querySelector(".title");
    this.contentElement = clone.querySelector(".content");

    // 挂载到 Shadow Root
    this.root.appendChild(clone);
    this.root.addEventListener("click", this.handleClick);
  }

  connectedCallback() {
    console.log("connectedCallback");
  }

  disconnectedCallback() {
    // 组件从文档中移除时,移除事件监听
    this.root.removeEventListener("click", this.handleClick);
    console.log("disconnectedCallback");
  }

  // 箭头函数,解决 this 指向问题
  handleClick = (e) => {
    console.log("handleClick", e);
    this.dispatchEvent(
      new CustomEvent("message", {
        detail: {
          title: this.title,
          content: this.content,
        },
        composed: true,
      }),
    );
  };

  static get observedAttributes() {
    return ["title", "content"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log("attributeChangedCallback", name, oldValue, newValue);

    this._updateContent({
      name,
      newValue,
      oldValue,
    });
  }

  _updateContent({ name, newValue, oldValue }) {
    if (name === "title") {
      this.title = newValue;
      this.titleElement.textContent = newValue;
    } else if (name === "content") {
      this.content = newValue;
      this.contentElement.textContent = newValue;
    }
  }
}  

示例 template 和 slot

js 复制代码
<template id="card-template">
  <style>
    .card {
      background-color: aliceblue;
      padding: 12px 16px;
    }
    p {
      color: blue;
    }
  </style>
  <div class="card">
    <p class="title">我是一个p标题</p>
    <div class="content">我是一个template</div>
    <slot name="other"></slot>
  </div>
</template>
js 复制代码
<my-component id="component1" title="标题1" content="自定义内容1">
  <div slot="other">我是一个other</div>
</my-component>
<br />
<my-component
  id="component2"
  title="标题2"
  content="自定义内容2"
></my-component>

应用场景

跨框架组件库

Web Components 最大的价值在于框架无关性。无论项目使用 React、Vue、Angular 还是原生 JavaScript,Web Components 都能像原生 HTML 标签一样工作

微前端架构

在微前端场景中,不同子应用可能使用不同的前端框架。Web Components 可以作为"胶水层",让各个子应用共享通用组件,而无需担心框架冲突

最后

  1. customElements
相关推荐
霸道流氓气质3 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
Metaphor6923 天前
使用 Python 将 PDF 转换为 HTML
python·pdf·html
a1117763 天前
“黑夜流星“个人引导页 网页html
java·前端·html
JieE2123 天前
手把手带你用纯 CSS 实现一个 3D 旋转魔方,这些前端基础你能打几分?
前端·css·html
YHL3 天前
🧊 CSS 3D 硬核解析:四个属性手写旋转立方体
前端·css·html
a1117763 天前
无限森林漫游(简约几何版 html
前端·html
霸道流氓气质3 天前
Spring Boot 微服务性能优化完全指南
spring boot·微服务·性能优化
LaughingZhu3 天前
Product Hunt 每日热榜 | 2026-06-16
前端·人工智能·经验分享·chatgpt·html
地瓜伯伯3 天前
从MESI缓存一致性协议讲透synchronized的底层
java·spring boot·spring·spring cloud·微服务·springcloud