Skip to content

web components

web components 浏览器原生支持的组件化方案,实现自定义标签,隔离样式和行为。

核心概念

参看示例定义一个可复用的自定义元素,涉及如下核心概念

  • Custom Elements 自定义元素
    • 采用 extends HTMLElement 采用扩展 HTMlElement 或者内置的标签元实现自定义元素和原生标签的增强使
    • customElements.define 利用该方法注册自定义元素,使得浏览器可以正确识别并解析自定义标签
    • connectedCallback 通过内置的生命周期钩子控制自定义元素的行为
  • Shadow DOM 通过 Element 的 attachShadow 方法创建 shadow DOM, 利用 HTMLElement 的 shadowRoot 属性访问 shadowRoot ,通过 shadowRoot.innerHTML 或者 appendChild 等方法注入 style 和标签实现自定义元素的内容和样式,注意 shadow DOM 的样式和 DOM 树的样式完全隔离不会互相影响
  • Templates and slots 通过 template 标签定义模板,利用 <slot>、<slot name="xx"> 定义插槽, 在消费自定义组件的时候通过 slot="xx" 指定默认和具名插槽,实现定制化的嵌套组件

上面技术共同实现了 Web Components 的核心特性。涉及的规范如下, 具体细节可以参考 webcomponents 规范

html
<!-- 1. 定义模版和插槽 -->
<template id="my-element-template">
   <style>
      :host {
         display: block;
         width: 200px;
         height: 100px;
         background: lightgray;
         border: 1px solid black;
         margin: 10px;
      }

      /* 直接作用于模板中的 h2,而不是宿主元素 */
      h2.title {
         color: red;
      }

      :host(.highlight) {
         border-color: orange;
      }
   </style>
   <h2 class="title">这是标题</h2>
   <slot></slot>
</template>
<script>
   //2. 创建自定义元素
   class MyElement extends HTMLElement {
      constructor() {
         super();
         // 3. 创建 shadow DOM
         const shadowRoot = this.attachShadow({ mode: 'open' });

         const template = document.getElementById('my-element-template');
         const templateContent = template.content;

         // 4. 插入模版到 shadow DOM
         shadowRoot.appendChild(templateContent.cloneNode(true));
      }


      set title(value) {
         this.setAttribute('title', value);
         if (this.shadowRoot) {
            const h2 = this.shadowRoot.querySelector('h2');
            if (h2) {
               h2.textContent = value;
            }
         }
      }

      // 5. 绑定挂载回调
      connectedCallback() {
         if (this.hasAttribute('title')) {
            const title = this.getAttribute('title');
            this.title = title;
         }
      }

   }

   // 6. 全局自定义元素
   customElements.define('my-element', MyElement);
</script>
<!-- 7. 使用自定义元素 -->
<my-element class="highlight" title="自定义标题">
   <p>这是一个插槽</p>
</my-element>

<!-- 8. 使用自定义元素 -->
<my-element title="自定义标题">
   <p>这是一个插槽</p>
</my-element>

Shadow DOM

浏览器在 Element 元素的实例上定义了 attachShadow 方法,用于创建一个新的 shadow root。可以在元素上通过 shadowRoot 属性访问 shadow root。

html
<div id="root"></div>
<script>
   const el = document.getElementById('root');
   const shadowRoot = el.attachShadow({ mode: 'open' });
   shadowRoot.innerHTML = `<p>自定义元素</p>`
   // 注意返回的 shadowRoot 可以通过 el.shadowRoot 访问
   console.log(shadowRoot === el.shadowRoot);
</script>

attachShadow

attachShadow(options) 支持的核心配置 options 包括

  • mode
    • open 通过 element.shadowRoot 可以访问 shadow root
    • closed 通过 element.shadowRoot 无法访问 shadow root, 返回的为 null, 如果不期望外部通过 api 修改 shadow dom 则可以设置为 closed
  • delegatesFocus 该配置决定了当 shadow host 获取焦点时,焦点是否会自动转移到 shadow DOM 内部的第一个可聚焦元素上,默认值为 false

WARNING

注意不是所有元素都支持 attachShadow 方法, 例如 video, input 等元素由于本省就是自定义元素所以不支持 attachShadow 方法

此外一个元素只能有一个 shadow root, 多次调用 attachShadow 方法会抛出异常

shadowRoot

attachShadow 方法会返回一个 shadow root 元素, 该元素集继承 DocumentFragment 对象,整个原型链为

ShadowRoot -> DocumentFragment -> Node -> EventTarget -> Object

意味着你可以使用原型链上的所有方法和属性操作 shadow root。

可以使用例如 innerHTML 或者 appendChild 等方法向 shadow root 中注入内容和样式,注入的样式只作用于 shadow DOM 内部,不会影响外部的样式。外部的样式也无法影响 Shadow DOM 内部的元素。

html
<style>
   /* 注意这里的样式无法影响 shadow DOM 内部元素 */
   p {
      background: orange;
      color: purple;
   }
</style>
<div id="root"></div>
<p>外部元素</p>
<script>
   const el = document.getElementById('root');
   const shadowRoot = el.attachShadow({ mode: 'open' });
   shadowRoot.innerHTML = `
   <style>
      /* 注意这里的样式不会影响外部的 <p> 元素 */
      p {
         background: lightgray;
         color: red;
      }
   </style>
   <p>自定义元素</p>
   `
</script>

TIP

实际上外部根元素上设置的属性,如果具有继承关系,例如 color, font-size 等属性,shadow DOM 内部的元素是可以继承到的,后有详述

innerHTML vs appendChild

可以采用 innerHTML 或者 appendChild 来设置 shadow DOM 内的元素。 由于 innerHTML 是一个字符串,因此对于 script 标签内容无法正常解析,但是可以创建 script 标签后通过 appendChild 添加到 shadow DOM 内部。触发执行,具体可以参考 script in shadow dom

html
<style>
  /* 注意这里的样式无法影响 shadow DOM 内部元素 */
  p {
    background: orange;
    color: purple;
  }
</style>
<div id="root"></div>
<div id="root1"></div>
<script>
  const el = document.getElementById("root");
  const shadowRoot = el.attachShadow({ mode: "open" });
  shadowRoot.innerHTML = `<script>alert('自定义元素')<\/\script><p>自定义元素, alert 无法触发</p>`;

  const el1 = document.getElementById("root1");
  const shadowRoot1 = el1.attachShadow({ mode: "open" });
  const fragment = document.createDocumentFragment();
  const script = document.createElement("script");
  script.textContent = "alert('自定义元素1')";
  fragment.appendChild(script);
  const p = document.createElement("p");
  p.textContent = "自定义元素1, alert 可以触发";
  fragment.appendChild(p);
  shadowRoot1.appendChild(fragment);
</script>

style

shadowroot 内部的样式和外部完全隔离不会相互影响

html
<style>
  h1 {
    color: red;
  }
</style>
<h1>外部样式</h1>
<div id="root"></div>
<script>
  const el = document.getElementById("root");
  const shadowRoot = el.attachShadow({ mode: "open" });
  shadowRoot.innerHTML = `
  <style>
    h1 {
      color: blue;
    }
  </style>
  <h1>内部样式</h1>
  `;
</script>

你也可以通过导入外部样式表来控制 shadow DOM 内部的样式

html
<style>
  h1 {
    color: red;
  }
</style>
<h1>外部样式</h1>
<div id="root"></div>
<script>
  const el = document.getElementById("root");
  const shadowRoot = el.attachShadow({ mode: "open" });
  shadowRoot.innerHTML = `
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
    <h1 class="text-primary fs-2 fw-bold">内部样式 (Bootstrap)</h1>
  `;
</script>

继承属性

custom properties

虽然 shadow DOM 内部的样式和外部完全隔离,但是 shadow dom 内部元素任然可以通过属性集成和 custom properties 继承外部的样式。

详细的继承属性可以参考 继承属性 参看示例, font-size 和 color 作为继承属性, shadow DOM 内部的元素可以继承外部的样式

html
<style>
   #root {
      color: red;
      font-weight: bold;
   }
</style>
<div id="root"></div>
<script>
   const el = document.getElementById('root');
   el.attachShadow({ mode: 'open' });
   el.shadowRoot.innerHTML = `<span>继承样式</h1>`;
</script>

TIP

页面可能引用了全局的重置样式,为了避免这些外部重置样式对 shadow DOM 内部的影响,可以通过 all: initial 重置 shadow DOM 内部的样式,避免受到属性继承导致的影响

html
<style>
   body {
      color: red;
      font-weight: bold;
   }
</style>
<div id="root"></div>
<script>
   const el = document.getElementById('root');
   el.attachShadow({ mode: 'open' });
   // 通过设置 all: initial 避免了 body 的样式由于是继承属性,对 shadow DOM 内部的影响
   el.shadowRoot.innerHTML = `
   <style>
      :host {
         all: initial;
      }
   </style>
   <span>继承样式</span>
`;
</script>

示例中的 :host 用来表示 shadow DOM 的宿主元素, 此处为 <div id="root"> 元素, 后续会讲解

由于继承属性的副作用,对于 shadow dom 元素更标准的策略是采用自定义属性控制内部元素的核心样式。

html
<style>
   #root {
      --my-highlight-color: red;
   }
</style>
<div id="root"></div>
<script>
   const el = document.getElementById('root');
   el.attachShadow({ mode: 'open' });
   // 通过设置 all: initial 避免了 body 的样式由于是继承属性,对 shadow DOM 内部的影响
   el.shadowRoot.innerHTML = `
   <style>
      span {
         all: initial;
         color: var(--my-highlight-color, blue);
      }
   </style>
   <span>继承样式</span>
`;
</script>

伪元素

为了实现对 shadow DOM 内部样式的控制,浏览器提供了一系列伪元素

:host

:host 伪类选择器用于选择 shadow DOM 的宿主元素,可以通过 :host 选择器为宿主元素设置样式。 注意 :host 只在 shadow DOM 内部有效, 无法在 shadow DOM 外部使用

html
<style>
  h1 {
    color: red;
  }
</style>
<div id="root"></div>
<script>
  const el = document.getElementById("root");
  const shadowRoot = el.attachShadow({ mode: "open" });
  shadowRoot.innerHTML = `
  <style>
    :host {
      padding: 10px;
      border: 1px solid red;
      color: blue;
    }
  </style>
  <h1>内部样式</h1>
  `;
</script>

你可以把采用 :host 设置样式理解为 shadow DOM 的默认样式,而外部定义的宿主元素样式为用户样式可以覆盖内部的默认样式行为。

html
<style>
  #root {
    /* 覆盖了内部 blue 控制  */
    color: red;
    border: 2px dashed blue;
    padding: 20px;
  }
  /* 注意只能覆盖 root 的样式,内部元素无法覆盖 */
  #root h1 {
    color: purple;
  }
</style>
<div id="root"></div>
<script>
  const el = document.getElementById("root");
  const shadowRoot = el.attachShadow({ mode: "open" });
  shadowRoot.innerHTML = `
  <style>
    :host {
      padding: 10px;
      border: 1px solid red;
      color: blue;
    }

    h1 {
      color: yellow;
    }
  </style>
  <h1>内部样式</h1>
  `;
</script>

WARNING

注意示例中只能覆盖根元素的样式,但是内部元素无法覆盖。

:host()

:host() 伪类选择器用于根据条件选择 shadow DOM 的宿主元素,可以通过 :host() 选择器为宿主元素设置条件样式。来匹配满足特定选择器规则的宿主元素

html
<div id="root" class="demo"></div>
<script>
  const el = document.getElementById("root");
  const shadowRoot = el.attachShadow({ mode: "open" });
  shadowRoot.innerHTML = `
  <style>
    :host {
      padding: 10px;
      border: 1px solid red;
      color: blue;
    }
    :host(.demo) {
      background: lightgray;
    }
  </style>
  <h1>内部样式</h1>
  `;
</script>

:host-context()

基于祖先的选择器来控制样式。

html
<div class="warp">
  <div id="root" class="demo"></div>
</div>
<script>
  const el = document.getElementById("root");
  const shadowRoot = el.attachShadow({ mode: "open" });
  shadowRoot.innerHTML = `
  <style>
    :host {
      padding: 10px;
      border: 1px solid red;
      color: blue;
    }
    :host(.demo) {
      background: lightgray;
    }
    :host-context(.warp) {
      background: lightgreen;
    }
  </style>
  <h1>内部样式</h1>
  `;
</script>

WARNING

参考 MDN :host-context 该属性后续可能会被废弃不推荐使用

自定义元素

除了直接调用元素上 attachShadow 创建 shadow DOM 之外,更常用的方式是通过自定义元素实现组件化。 可以通过 extends 方法扩展 HTMLElement 或者内置的标签元素实现自定义元素。

extends HTMLElement

html
<script>
   class MyElement extends HTMLElement {
      constructor() {
         super();
         // 创建 shadow DOM
         this.attachShadow({ mode: 'open' });
         // 注入样式和内容
         this.shadowRoot.innerHTML = `<p>这是一个自定义元素</p>`;
      }

   }

   // 定义自定义元素
   customElements.define('my-element', MyElement);
</script>

<!-- 消费自定义元素 -->
<my-element></my-element>

WARNING

注意自定义元素必须采用小写,中划线分隔的命名方式,例如 my-element,否则会抛出异常,详细规则参考 规范 valid custom element name

extends 内置标签

html
<p is="my-paragraph">测试扩展 P 标签</p>
<script>
   class MyParagraph extends HTMLParagraphElement {
      constructor() {
         super();
         this.style.color = 'red';
         this.style.fontWeight = 'bold';
         this.textContent = this.textContent + ' (已扩展)';
      }
   }

   // 定义“自定义内置元素”必须带第三个参数 { extends: 'p' }
   customElements.define('my-paragraph', MyParagraph, { extends: 'p' });
</script>

采用自定义标签, 可以使用自定义标签覆盖内置标签的默认行为

connectedCallback

一般我们会在自定义元素上定义属性,参考如下示例

html
<script>
   class MyHighlight extends HTMLElement {
      constructor() {
         super()
         debugger
         this.attachShadow({
            mode: 'open'
         })
         const content = this.getAttribute('content') || '默认内容'
         this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`
      }
   }
   customElements.define('my-highlight', MyHighlight)
</script>
<!-- 显示为默认内容而不是 hello world -->
<my-highlight content="hello world"></my-highlight>
<input id="input" type="text" value="abc">

:::waning 此处 contructor 会在浏览器解析到自定义标签时触发一次,由于 HTML 标签是流式解析的,所以当读取到标签 token 时就会立即触发 contructor, 而此时并未读取到属性值,所以在 contructor 中通过 getAttribute 获取属性值会返回 null, 从而显示默认值。 :::

因此自定义元素提供了 connectedCallback 确保在元素标签完全解析,并且插入到 DOM 树后才触发。

html
<script>
   class MyHighlight extends HTMLElement {
      constructor() {
         super()
         debugger
         this.attachShadow({
            mode: 'open'
         })
         const content = this.getAttribute('content') || '默认内容'
         this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`
      }

      connectedCallback() {
         debugger
         const content = this.getAttribute('content') || '默认内容'
         this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`
      }
   }
   customElements.define('my-highlight', MyHighlight)
</script>
<!-- 显示为默认内容而不是 hello world -->
<my-highlight content="hello world"></my-highlight>

可以打开断点查看示例,会先显示默认内容,然后触发 connectedCallback 获取到

disconnectedCallback

除了 connectedCallback 之外, 自定义元素还提供了 disconnectedCallback 生命周期钩子,当自定义元素从 DOM 树中移除时触发。 可以利用 disconnectedCallback 释放资源,例如取消定时器,取消网络请求等。

html
<script>
  class MyHighlight extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
      this.timer = null;
    }

    connectedCallback() {
      const content = this.getAttribute("content") || "默认内容";
      this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`;
      // 模拟实际场景:定时高亮闪烁
      this.timer = setInterval(() => {
        this.shadowRoot.querySelector("strong").style.background =
          this.shadowRoot.querySelector("strong").style.background === "yellow"
            ? ""
            : "yellow";
      }, 500);
    }

    disconnectedCallback() {
      // 清理定时器,避免内存泄漏
      if (this.timer) {
        // 注意此处如果不销毁该定时器会导致,即使组件移除任然有定时器在运行
        clearInterval(this.timer);
        this.timer = null;
      }
      console.log("MyHighlight 元素已被移除");
    }
  }
  customElements.define("my-highlight", MyHighlight);

  // 演示动态移除组件
  setTimeout(() => {
    document.querySelector("my-highlight").remove();
  }, 3000);
</script>

<my-highlight content="hello world"></my-highlight>

通过定时器实现高亮闪烁自定义元素,当移除元素的时候自动销毁定时器

connectedMoveCallback

除了添加或者删除元素,当调用 moveBefore 方法移动元素时,会触发 connectedMoveCallback 钩子。

html
<script>
  // 假设浏览器支持 connectedMoveCallback
  class MyHighlight extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    connectedCallback() {
      this.render("已插入文档");
    }
    // connectedMoveCallback 在元素移动到文档中的新位置时触发
    connectedMoveCallback() {
      this.render("已移动到新位置");
    }

    render(status) {
      const content = this.getAttribute("content") || "默认内容";
      this.shadowRoot.innerHTML = `<strong style="color: red;">${content}(${status})</strong>`;
    }
  }
  customElements.define("my-highlight", MyHighlight);

  // 演示动态移动组件
  setTimeout(() => {
    const container = document.querySelector("#container");
    const el = document.querySelector("my-highlight");
    const item = document.querySelector("#item");
    // 将 my-highlight 移动到 item 之前
    container.moveBefore(el, item);
  }, 2000);
</script>

<div id="container">
  <div id="item">test</div>
  <my-highlight content="hello world"></my-highlight>
</div>

WARNING

注意只有通过 moveBefore 方法移动元素,才会触发 connectedMoveCallback 钩子, 采用 insertBefore 方法添加元素,则不会触发 connectedMoveCallback 钩子。

html
<script>
  // 假设浏览器支持 connectedMoveCallback
  class MyHighlight extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    connectedCallback() {
      console.log("MyHighlight 元素已被插入");
      this.render("已插入文档");
    }
    disconnectedCallback() {
      console.log("MyHighlight 元素已被移除");
    }
    connectedMoveCallback() {
      console.log("MyHighlight 元素已被移动");
    }

    render(status) {
      const content = this.getAttribute("content") || "默认内容";
      this.shadowRoot.innerHTML = `<strong style="color: red;">${content}(${status})</strong>`;
    }
  }
  customElements.define("my-highlight", MyHighlight);

  // 演示动态移动组件
  setTimeout(() => {
    const container = document.querySelector("#container");
    const el = document.querySelector("my-highlight");
    const item = document.querySelector("#item");
    // 注意 interBefore 方法是卸载元素后再插入元素,所以会触发 disconnectedCallback 和 connectedCallback
    container.insertBefore(el, item);
  }, 2000);
</script>

<div id="container">
  <div id="item">test</div>
  <my-highlight content="hello world"></my-highlight>
</div>

attributeChangedCallback

为了实现修改属性的时候,触发内容也实时变化可以采用 attributeChangedCallback 钩子。监听属性的修改。

html
<script>
  class MyHighlight extends HTMLElement {
    static get observedAttributes() {
      return ["content"];
    }

    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    connectedCallback() {
      this._updateContent();
    }

    attributeChangedCallback(name, oldValue, newValue) {
      if (name === "content" && oldValue !== newValue) {
        this._updateContent();
      }
    }

    _updateContent() {
      const content = this.getAttribute("content") || "默认内容";
      this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`;
    }
  }
  customElements.define("my-highlight", MyHighlight);
</script>
<!-- 修改 content 属性时内容会动态更新 -->
<my-highlight id="highlight" content="hello world"></my-highlight>
<button
  onclick="document.getElementById('highlight').setAttribute('content', '动态内容已更新')"
>
  修改 content 属性
</button>

observedAttributes

为了优化性能,示例中必须采用 observedAttributes 属性定义需要监听的属性,只有在 observedAttributes 定义的属性修改时才会触发 attributeChangedCallback 钩子。

adoptedCallback

当自定义元素被移动到新的文档时,会触发 adoptedCallback 钩子。 一般在自定义元素移动到 iframe 时触发

html
<script>
  // 假设浏览器支持 adoptedCallback
  class MyHighlight extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    connectedCallback() {
      // 只在主文档插入时渲染,adopted 后不重复渲染
      if (this.ownerDocument === document) {
        this.render("已插入文档");
      }
    }

    // adoptedCallback 在元素被 adopted 到新文档时触发
    adoptedCallback(oldDocument, newDocument) {
      this.render("已被 adopted 到新文档");
    }

    render(status) {
      const content = this.getAttribute("content") || "默认内容";
      this.shadowRoot.innerHTML = `<strong style="color: red;">${content}(${status})</strong>`;
    }
  }
  customElements.define("my-highlight", MyHighlight);

  // 演示 adoptedCallback 的调用
  setTimeout(() => {
    const el = document.querySelector("my-highlight");
    const f1 = document.getElementById("f1");
    // 先移除元素,确保不会再次触发 connectedCallback
    el.remove();
    f1.onload = () => {
      // 使用 adoptNode 方法将自定义元素 adopt 到新文档
      const adoptedEl = f1.contentDocument.adoptNode(el);
      f1.contentDocument.body.appendChild(adoptedEl);
    };
    f1.srcdoc = "<body></body>";
  }, 2000);
</script>

<div id="container">
  <iframe id="f1" frameborder="0"></iframe>
  <my-highlight content="hello world"></my-highlight>
</div>

:::waring

注意由于此时挂载到了新的文档,所以不会触发 connectedMoveCallback 钩子,同时由于在新文档上挂载会重新触发 connectedCallback 钩子,为了避免此情况,可以参考示例中的逻辑,判断自定义元素所属的文档不是主文档时,不执行初次渲染逻辑, 避免重新挂载覆盖 adoptedCallback 的行为

js
// 只在主文档插入时渲染,adopted 后不重复渲染
if (this.ownerDocument === document) {
  this.render("已插入文档");
}

:::

hooks 总结

整个自定义元素的生命周期如下

  1. constructor 每次解析到自定义元素标签就会触发,此时无法读取属性,用于初始化自定义元素基本信息
  2. connectedCallback 添加元素到 DOM 树时触发,此时可以读取属性,可以在此时初始化或者更新元素
  3. attributeChangedCallback 修改属性时触发
  4. disconnectedCallback 从 DOM 树移除元素时触发
  5. connectedMoveCallback 通过 moveBefore 方法移动元素时触发
  6. adoptedCallback 元素被移动到新的文档时触发, 注意由于移动到新的 document 会重新触发 connectedCallback 钩子, 可以利用 this.ownerDocument 判断是否是主文档,避免重复渲染

attribute vs prperty

  • attribute 属于 HTML 标签上定义的属性,可以通过 getAttribute、setAttribute、removeAttribute 方法访问和修改
  • property 采用自定义元素时实例上的属性,可以通过 this.xx 访问和修改

HTML 规范在处理 attribute 和 property 上有一系列规则,可以阅读 HTML attributes vs DOM properties 了解细节

属性反射

除了直接通过 getAttribute、setAttribute、removeAttribute 方法访问和修改属性之外,你也可以直接修改 this.xx 的值,值的改变会同步到 attribute 上, 这种行为称为属性反射。详细资料可以参考 attribute reflection

html
<script>
  class MyHighlight extends HTMLElement {
    constructor() {
      super();
      // 创建 shadow DOM
      this.attachShadow({ mode: 'open' });
    }
    connectedCallback() {
      const content = this.getAttribute('content') || '默认内容';
      this.shadowRoot.innerHTML = `<strong  style="color: red;">${content}</strong>`;
    }
  }
  customElements.define('my-highlight', MyHighlight);
</script>
<my-highlight id="my-highlight" content="hello world"></my-highlight>
<script>
  const el = document.getElementById('my-highlight');
  console.log('id', el.id)
  setTimeout(() => {
    el.setAttribute('id', 'my-highlight-modified');
    console.log('id modified', el.id)
  }, 2000);
</script>

可以看到当修改元素上内置属性 id 的时候,属性值会同步到 attribute 上

但是针对 content 采用 this.content 修改并不会触发内容更新。 核心的原因在于 attributeChangedCallback 只会监听采用 setAttribute 等方式来修改属性,为了实现属性反射的效果,可以额外添加 content 的 setter 和 getter 方法,结合 attributeChangedCallback 实现属性反射

html
<script>
  class MyHighlight extends HTMLElement {
    static get observedAttributes() {
      return ["content"];
    }

    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    get content() {
      return this.getAttribute("content");
    }
    set content(value) {
      this.setAttribute("content", value);
    }

    connectedCallback() {
      this._updateContent();
    }

    attributeChangedCallback(name, oldValue, newValue) {
      if (name === "content" && oldValue !== newValue) {
        this._updateContent();
      }
    }

    _updateContent() {
      const content = this.getAttribute("content") || "默认内容";
      this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`;
    }
  }
  customElements.define("my-highlight", MyHighlight);
</script>
<!-- 修改 content 属性时内容会动态更新 -->
<my-highlight id="highlight" content="hello world"></my-highlight>
<button onclick="document.getElementById('highlight').setAttribute('content', '动态内容已更新')">
  setAttribute 修改 content 属性
</button>
<button onclick="document.getElementById('highlight').content = '属性反射修改 content'">
  属性反射,利用 property 修改 content 属性
</button>

事件处理

由于自定义元素继承自 HTMLElement, 因此根元素上的可以直接绑定事件,参看示例

html
<script>
   class MyHighlight extends HTMLElement {
      constructor() {
         super()
      }

      connectedCallback() {
         const content = this.getAttribute('content') || '默认内容'
         this.innerHTML = `<strong style="color: red;">${content}</strong>`
      }
   }

   customElements.define('my-highlight', MyHighlight)
</script>
<my-highlight onclick="alert(1)"></my-highlight>

自定义事件

对于内部元素可以使用 dispatchEvent 触发自定义事件,参看示例

html
<script>
   class MyHighlight extends HTMLElement {
      constructor() {
         super()
      }

      connectedCallback() {
         this.attachShadow({ mode: 'open' })
         const content = this.getAttribute('content') || '默认内容'
         this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`
         const strongEl = this.shadowRoot.querySelector('strong')
         strongEl.addEventListener('click', (e) => {
            this.dispatchEvent(new CustomEvent('highlight-click', {
               detail: {
                  message: 'highlight clicked'
               },
               bubbles: true,
               composed: true
            }))
         })
      }
   }

   customElements.define('my-highlight', MyHighlight)
</script>
<my-highlight></my-highlight>
<script>
   document.body.addEventListener('highlight-click', (e) => {
      console.log(e)
   })
</script>

TIP

由于内置事件属性例如 onclick 会自动处理绑定的表达式,而自定义事件没有此功能,可以通过监听属性绑定结合属性反射的功能实现支持在属性上绑定事件表达式, 此外需要设置 bubbles: true, composed: true 使得事件可以冒泡到 shadow DOM 外部。注意内部元素会被隐藏所以,事件的 target 始终为 shadow host

template 和 slot

为了实现可复用的结构来简化自定义元素的的创建,同时提供内置的扩展能力,web components 提供了 template 和 slot 来实现此功能

html
<template id="my-highlight">
  <style>
    :host {
      color: red;
    }
  </style>
  <slot></slot>
</template>
<script>
  class MyHighlight extends HTMLElement {
    constructor() {
      super();
      const template = document.getElementById("my-highlight").content;
      this.attachShadow({ mode: "open" }).appendChild(template.cloneNode(true));
    }
  }
  customElements.define("my-highlight", MyHighlight);
</script>
<my-highlight><h2>自定义内容</h2></my-highlight>

示例中

  1. template 定义模版可以被多个自定义元素引用
  2. slot 使得可以在自定义组件内部插入不同的标签实现自定义的高亮内容,而不受通过 content 属性只能注入文本的限制

template

除了直接使用 template 标签定义模版外,你可以直接将 template 模版写在自定义元素内部,采用 shadowrootmode="open" 的方式创建 shadow DOM, 浏览器会自动将 template 内部的内容注入到 shadow DOM 内部

html
<my-highlight>
  <template shadowrootmode="open">
    <style>
      :host {
        color: red;
      }
    </style>
    <slot></slot>
  </template>
  <h2>高亮内容</h2>
</my-highlight>

TIP

对于不复用的自定义组件可以简单采用此方式定义,一般还是通过 CustomElement 的方式来创建 web components

name slot

slot 支持 name 属性插入多个插槽,在自定义组件内部使用 slot 属性制定具体的插入点

html
<template id="my-highlight">
  <style>
    :host {
      color: red;
    }
  </style>
  <slot></slot>
  <slot name="content"></slot>
</template>
<script>
  class MyHighlight extends HTMLElement {
    constructor() {
      super();
      const template = document.getElementById("my-highlight").content;
      this.attachShadow({ mode: "open" }).appendChild(template.cloneNode(true));
    }
  }
  customElements.define("my-highlight", MyHighlight);
</script>
<my-highlight>
  <h2>标题内容</h2>
  <p slot="content">这是具名插槽的内容。</p>
</my-highlight>

duplicate slot

你可以在 template 中定义多个同名的插槽,但是浏览器只会默认使用第一个插槽, 在自定义元素中,你也可以同时插入多个同名的插槽,浏览器会自动根据插槽的 name 属性匹配,一次插入对应位置。

html
<template id="my-highlight">
  <style>
    :host {
      color: red;
    }
  </style>
  <!-- 只会使用第一个默认插槽 -->
  <slot></slot>
  <h2>2 slot</h2>
  <slot></slot>
  <!-- 只会使用第一个具名插槽 -->
  <slot name="content"></slot>
  <h2>2 slot content</h2>
  <slot name="content"></slot>
</template>
<script>
  class MyHighlight extends HTMLElement {
    constructor() {
      super();
      const template = document.getElementById("my-highlight").content;
      this.attachShadow({ mode: "open" }).appendChild(template.cloneNode(true));
    }
  }
  customElements.define("my-highlight", MyHighlight);
</script>
<my-highlight>
  <h2>标题1</h2>
  <p slot="content">内容1</p>
  <h2>标题2</h2>
  <p slot="content">内容2</p>
  <h2>标题3</h2>
  <p slot="content">内容2</p>
</my-highlight>

TIP

注意示例说明的规则

  1. 当在 template 中定义多个同名插槽的时候,默认使用第一个插槽作为插入点
  2. 当自定义元素消费插槽时,浏览器会自定基于插槽名称插入对应的插槽点,如果重复注入多个同名插槽内容,会依次按顺序插入对应的插槽点

在 Vue 的框架中不允许定义和注入重复插槽,此时会触发报错,因为框架在做 update 操作的时候如果定义重复的插槽或者注入重复的插槽会导致更新算法没法利用 key 或者其他属性来高性能的更新视图,注意此问题

::part

除了之前介绍的 custom properties 之外,shadow DOM 还提供了 part 属性来实现对 shadow DOM 内部元素的样式控制, 你可以在 元素上定义 part 属性, 然后通过 ::part(xx) 伪元素选择器控制 shadow dom 内部元素样式

html
<template id="my-element">
   <style>
      ::part(title) {
         color: red;
         font-weight: bold;
      }
   </style>
   <h1 part="title">
      <slot></slot>
   </h1>
</template>
<script>
   class MyElement extends HTMLElement {
      constructor() {
         super();
         const template = document.getElementById('my-element');
         const templateContent = template.content;
         const shadowRoot = this.attachShadow({ mode: 'open' });
         shadowRoot.appendChild(templateContent.cloneNode(true));
      }
   }
   customElements.define('my-element', MyElement);
</script>
<style>
   #custom::part(title) {
      color: blue;
      font-weight: normal;
   }
</style>
<my-element>默认样式</my-element>
<my-element id="custom">覆盖 part 后样式</my-element>

::slotted

此外可以通过 ::slotted(xx) 伪元素选择器控制插入到 slot 内部的元素样式

html
<!-- 12.slotted.html 演示 ::slotted 对匿名(默认)和具名插槽的样式设置 -->
<template id="my-element">
   <style>
      /* 匿名插槽(默认插槽)内所有分发元素的基础样式 */
      ::slotted(*) {
         font-family: sans-serif;
         color: #444;
      }

      /* 匿名插槽中特定类名的元素 */
      ::slotted(.highlight) {
         color: crimson;
         font-weight: bold;
      }

      /* 具名插槽 subtitle 中的所有分发元素 */
      ::slotted([slot=subtitle]) {
         color: steelblue;
         font-size: 14px;
         letter-spacing: 1px;
      }

      /* 具名插槽中特定更细粒度的匹配 */
      ::slotted(span[slot=subtitle].warn) {
         color: orange;
      }
   </style>
   <h1>
      <slot></slot> <!-- 匿名(默认)插槽 -->
   </h1>
   <h2>
      <slot name="subtitle"></slot> <!-- 具名插槽 -->
   </h2>
</template>

<script>
   class MyElement extends HTMLElement {
      constructor() {
         super();
         const tpl = document.getElementById('my-element');
         const shadowRoot = this.attachShadow({ mode: 'open' });
         shadowRoot.appendChild(tpl.content.cloneNode(true));
      }
   }
   customElements.define('my-element', MyElement);
</script>

<!-- 用例 1:最基本 -->
<my-element>
   默认标题
   <span slot="subtitle">默认副标题</span>
</my-element>

<hr>

<!-- 用例 2:匿名插槽中的高亮样式 -->
<my-element>
   <span class="highlight">高亮的主标题</span>
   <span slot="subtitle">普通副标题</span>
</my-element>

<hr>

<!-- 用例 3:具名插槽中更细粒度选择器 -->
<my-element>
   普通主标题
   <span slot="subtitle" class="warn">警告副标题</span>
</my-element>

<!-- 说明:
1. ::slotted 只能写在 Shadow DOM 内部样式表中,且仅作用于直接分发进插槽的顶层节点。
2. 不能通过 ::slotted 选择分发元素的后代,如 ::slotted(div span) 无效。
3. [slot=subtitle] 仅匹配放入 name="subtitle" 插槽的顶层元素。 -->

最佳实践

组件设计

结构定义

attribute 和 property

样式控制

:defined

采用 :defined 伪类解决自定义组件 FOUC 问题

:defined 伪类选择器用于选择已经定义的自定义元素,可以通过 :defined 选择器为已经定义的自定义元素设置样式。一般通过该元素解决样式闪烁问题

html
<!-- 07.defined.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<meta charset="UTF-8" />
<title>:defined 解决自定义元素 FOUC 示例</title>
<style>
  body {
    font-family: system-ui, -apple-system, Segoe UI, Arial, sans-serif;
    line-height: 1.5;
    padding: 24px;
  }

  h1 {
    margin-top: 0;
  }

  section {
    margin-bottom: 32px;
  }

  fouc-panel,
  fixed-panel {
    display: block;
    margin: 12px 0;
  }

  /* 方案 A:直接隐藏未定义元素,避免看到“原始未升级内容”闪烁 */
  fixed-panel:not(:defined) {
    visibility: hidden;
  }

  /* 方案 B:骨架占位(带 skeleton 类的才使用),覆盖上面的隐藏规则 */
  fixed-panel.skeleton:not(:defined) {
    visibility: visible;
    position: relative;
  }

  fixed-panel.skeleton:not(:defined)::before {
    content: '加载中…';
    display: block;
    padding: 16px;
    background: #f2f3f5;
    color: #888;
    border: 1px solid #ddd;
    border-radius: 6px;
    font-size: 14px;
    animation: pulse 1s linear infinite;
  }

  @keyframes pulse {

    0%,
    100% {
      opacity: 1;
    }

    50% {
      opacity: .45;
    }
  }
</style>

<h1>:defined 解决 FOUC(样式闪烁 / 内容闪现)</h1>

<section>
  <h2>对比演示</h2>
  <ol>
    <li>无处理:会先短暂显示「原始未升级内容」,随后被 Shadow DOM 替换,发生 FOUC。</li>
    <li>隐藏方案:用 :not(:defined) 隐藏,升级后再显示,避免闪烁。</li>
    <li>骨架方案:未定义前显示占位骨架,升级后自动替换成真正内容。</li>
  </ol>
</section>

<h3>1. 无处理(会 FOUC)</h3>
<fouc-panel title="未做处理">原始未升级内容(会闪一下)</fouc-panel>

<h3>2. 使用 :not(:defined) 隐藏(无闪烁)</h3>
<fixed-panel title="隐藏避免闪烁">原始未升级内容(不会看到)</fixed-panel>

<h3>3. 使用骨架占位(平滑过渡)</h3>
<fixed-panel class="skeleton" title="骨架占位">原始未升级内容(被骨架遮挡)</fixed-panel>

<script>
  // 延迟注册,模拟网络/代码分片加载
  setTimeout(() => {
    class BasePanel extends HTMLElement {
      constructor() {
        super();
        const root = this.attachShadow({ mode: 'open' });
        const title = this.getAttribute('title') || '默认标题';
        root.innerHTML = `
          <style>
            :host {
              display:block;
              border:1px solid #409eff;
              padding:14px 16px 16px;
              border-radius:8px;
              background:#f8fbff;
              box-shadow:0 2px 4px rgba(0,0,0,.08);
              font-size:14px;
              color:#444;
              animation:fade .28s;
            }
            h2 {
              margin:0 0 8px;
              font-size:16px;
              color:#1f2d3d;
            }
            p { margin:0; }
            @keyframes fade {
              from { opacity:0; transform:translateY(4px); }
              to { opacity:1; transform:translateY(0); }
            }
          </style>
          <h2>${title}</h2>
          <p>这是组件升级后的正式内容。</p>
        `;
      }
    }

    // fouc-panel:未使用任何 :defined 预处理
    customElements.define('fouc-panel', class extends BasePanel { });

    // fixed-panel:页面级样式使用 :not(:defined) 做隐藏或骨架
    customElements.define('fixed-panel', class extends BasePanel { });
  }, 1200);
</script>

<!-- 说明:
1. :not(:defined) 仅在自定义元素尚未注册时匹配,注册后元素立即匹配 :defined,隐藏/骨架规则失效,显示 Shadow DOM。
2. 隐藏方案最简单;骨架方案提供视觉占位,减少布局跳动与空白。
3. 两个标签 fouc-panel 与 fixed-panel 的区别只在“未定义时期的样式处理”。 -->

</html>

事件处理

库和框架

总结

  1. 快速使用

    1. 基础示例

    2. 注意事项

      1. 自定义元素名称必须包含连字符 -
      2. 自定义元素类必须继承自 HTMLElement 或其子类
      3. 消费自定义元素无法自闭和,必须手动闭合
  2. 样式设置

    1. `:host
    2. 样式覆盖优先级
      1. 对于内置的样式可以理解为默认样式,所以用户对 slot 的样式可以覆盖默认样式,但是如果设置了 !important 则无法规则相反,详细资料参考
        1. shadow 层叠
        2. 样式讨论
  3. class CustomElement extends HTMLElement

    1. constructor 只会初始化一次,connectedCallback 每次插入 DOM 都会调用, 建议对于初始化操作放在 constructor 中, 对于可以延迟和多次调用的放在 connectedCallback 中, 参考资料, 注意 consturctor 在遇到标签的时候就会解析,此时在 contructor 中直接调用 getAttribute 获取不到值,可以参考 cannot-access-attributes-of-a-custom-element-from-its-constructor 进一步理解
      1. Web component gotcha: constructor vs connectedCallback
      2. You're (probably) using connectedCallback wrong
  4. 自定义元素属性

    1. 创建属性
  5. 调试

    1. Chrome DevTools -> 设置 -> Elements -> Show user agent shadow DOM 可以看到 video 标签就是自定义元素
    2. 原生的自定义元素包括
    3. video 视频
    4. input 输入框
    5. select 下拉框

延伸阅读